Phone-first bottom navigation per pwa-baseline.md. Renders only at
<768px; tablets and desktop are unchanged.
Slots: Start / Projekte / [+] Anlegen / Agenda / Menü.
- Center [+] opens a slide-up <dialog> sheet with three rows: Frist,
Termin, Projekt. Native showModal() + ::backdrop, ESC and backdrop-tap
dismiss, transform-based slide-up transition.
- Right Menü slot reuses the existing Sidebar mobile drawer via a new
exported toggleMobileSidebar() (DRY with the legacy hamburger handler).
- Agenda slot carries a red-dot badge: count = today + overdue pending
deadlines (live via /api/deadlines/summary, refreshed every 60s). Pulse
animation when overdue > 0 — m: "Due is the latest we can do, OVERDUE
is a catastrophy."
- visualViewport resize watcher hides the bar when the on-screen keyboard
opens (>100px height shrink) so it doesn't cover form fields.
- safe-area-inset-bottom padding on the bar; main padding-bottom adjusts
on phones so the last row stays above the bar.
PWA shell groundwork (defers manifest/SW/install-prompt to follow-ups):
- viewport-fit=cover on every page (required for safe-area to register)
- theme-color #65a30d (lime), apple-mobile-web-app-capable, status-bar
style — all 30 page heads updated in one sweep.
Backend: deadline_service.SummaryCounts gains a `today` bucket so the
Agenda badge can distinguish "due today" from "this week" without a new
endpoint.
Files added:
frontend/src/components/BottomNav.tsx
frontend/src/client/bottom-nav.ts
Verified visually via headless chromium at 375x812, 800x600, 1280x800:
phone shows BottomNav (5 slots, lime [+] elevated), tablet shows the
existing hamburger only, desktop sidebar untouched. go build/vet/test
and bun run build all clean.
Bug 7 — /whatsnew was bare 404. Sidebar uses /changelog (canonical) but
users typing /whatsnew from memory hit the not-found chrome. Added
/whatsnew → /changelog as a 301 to internal/handlers/redirects.go,
following the existing legacy-redirect pattern. Wired on the OUTER mux so
unauthenticated bookmarks redirect one-hop instead of round-tripping
through /login. /search left as-is per the brief — sidebar's global-search
overlay is the live UX, /search would only be hit via typo and falls back
to the chromed 404 from t-paliad-037.
Bug 8 — /settings/caldav worked (200 → 301 → /settings?tab=caldav) but
/settings/notifications, /settings/dezernat, /settings/profile all 404'd.
Tabs themselves were fine in-page; only the deep-link form was broken.
Replaced the single CalDAV-only handler with a generic /settings/{tab}
redirector backed by a slug→canonical map that accepts both the German
tab IDs the client TS understands (profil, benachrichtigungen, dezernat)
and intuitive English aliases (profile, notifications, department).
Unknown slugs fall back to /settings (default tab) instead of 404 so
typos don't break.
Bug 10 — login form 401 console replay: skipped per brief permission.
Reproduced in Playwright; the console message is the browser's automatic
"Failed to load resource: the server responded with a status of 401"
emitted by the network stack itself. login.ts has no console.error call.
The only workarounds (server returns 200 with {ok:false}, or 422 instead
of 401) either compromise the security pattern or don't actually suppress
the browser log. Documented in the smoke delta report.
Verified: go build/vet/test clean, bun run build clean.
m reported /projects/{id} loaded the chrome and tabs but every panel was
empty even with deadlines/appointments/team rows that should render.
Console error: "Cannot read properties of null (reading 'length')" at
projects-detail.js — the Project Detail page expects every list endpoint
to return [] but at least two were returning literal JSON null.
Reproduced via the in-page fetch console:
/api/projects/{id}/parties → 200, body: "null"
/api/projects/{id}/children → 200, body: "null"
/api/projects/{id}/deadlines → 200, body: "[…]" (had data, fine)
/api/projects/{id}/team → 200, body: "[…]" (had data, fine)
Root cause: every list service in internal/services declared its result
as `var rows []models.X` and returned that to the handler, which
encoding/json marshals as `null` when the SELECT returns zero rows
(nil slice, not empty slice). Most endpoints happen to have data so
the bug stayed dormant until t-paliad-038 hit /projects/{id} where
parties + children are commonly empty.
Fix at the source — every list service that JSON-marshals to a client
now initialises `rows := []models.X{}` so the encoder produces `[]`:
party_service ListForProjekt
project_service List, ListAncestors, BuildTree, GetTree
(ListChildren goes through List)
deadline_service List + ListForProjekt
appointment_service List + ListForProjekt
note_service ListForProjekt
checklist_instance_service ListForProjekt
team_service List
department_service List + ListMembers + ListWithMembers
caldav_service was deliberately left alone — its lists are admin-only
debug surfaces, not user-facing tab fillers, and changing them would
mix scopes.
Belt-and-braces on the client too — projects-detail.ts now coerces every
`await resp.json()` for an array endpoint with `?? []` so a future
service regression can't crash the page.
Verified: go build/vet/test clean, bun run build clean.
URGENT bug: /deadlines/{id} rendered "Frist nicht gefunden oder keine
Berechtigung" while the underlying /api/deadlines/{id} returned 200, and
/deadlines list showed "Invalid Date" in the date column.
Root causes — same class as t-paliad-038, this time on deadlines and
appointments client TS:
1. parseFristID/parseTerminID still checked URL prefix "fristen"/"termine".
After t-paliad-025 renamed pages to /deadlines and /appointments,
parts[0] no longer matched → null id → notfound branch fired before any
API fetch. Renamed to parseDeadlineID/parseAppointmentID with the
correct "deadlines"/"appointments" prefix.
2. fmtDate in deadlines.ts blindly appended "T00:00:00" to the API's
due_date string. After the v2 schema, the API returns full ISO
datetime ("2026-04-22T00:00:00Z"), and "...ZT00:00:00" is invalid →
"Invalid Date". Guarded both fmtDate and urgencyClass with
iso.length === 10 / iso.slice(0, 10).
3. Half-renamed variables (`let allDeadlines` declared, `allFristen`
used; `let deadline`, `frist` referenced). Worked at runtime only
because the undeclared identifier became a non-strict global. Cleaned
up to use the declared English names everywhere.
Lockstep DOM ID + variable rename in client TS + matching TSX:
- frist-* → deadline-* (deadlines-detail, deadlines, deadlines-new,
deadlines-calendar)
- termin-* / termine-* → appointment-* / appointments-* (appointments-detail,
appointments, appointments-new, appointments-calendar)
- fristen-body/empty/unavailable → deadlines-* (list page)
- termine-body/empty/unavailable → appointments-* (list page)
- frist-cal-grid / termin-cal-grid → deadline-cal-grid /
appointment-cal-grid (calendars)
- loadFristen/loadTermine/loadAkten/loadFrist/loadTermin/loadAkte →
loadDeadlines/loadAppointments/loadProjects/loadDeadline/loadAppointment/loadProject
- deadlines.ts: dropped unused projekt_office field from Deadline interface
- appointments.ts: dropped unused projekt_office field from Appointment
interface
Dashboard cleanup — Go service was still emitting `projekt_ref`:
- internal/services/dashboard_service.go: UpcomingDeadline /
UpcomingAppointment / ActivityEntry json+db tags `projekt_ref` →
`project_reference`; SQL aliases `AS projekt_ref` → `AS project_reference`.
- frontend/src/client/dashboard.ts: interfaces switched to
project_reference; activity link href /projects/{id}/fristen →
/deadlines, /termine → /appointments (the German per-project subpaths
were dead — t-paliad-038 already renamed projects-detail tabs).
i18n key strings (fristen.*, termine.*) intentionally kept in German per
the t-paliad-025 convention (frontend default language is German). CSS
class names (frist-row, frist-due-chip, frist-cal-cell, termin-dot,
termin-type-*, akten-table-wrap) untouched — separate stylistic cleanup,
no IDs are referenced in CSS so the rename is safe.
Verified: go build/vet/test clean, bun run build clean, dist HTML
contains only the new English IDs (remaining German strings are i18n
keys and product-name CSS classes).
Four bugs from tests/smoke-auth-2026-04-25.md.
Bug 4 — Dashboard activity log leaked raw i18n keys. Root cause was a mix
of three issues:
- Go services wrote German event_types (frist_created, termin_*,
projekt_*, notiz_created, checkliste_*) — no matching i18n key.
- i18n.ts only had keys for legacy `akte_*` types, none for what was
actually being written.
- The dashboard renderer always rendered `e.title` (a static label like
"Project angelegt") as a trailing detail, duplicating the action verb.
Old `akte_created` rows had English titles ("Akte created") that
bled into German output.
Switched all event_type writes to English (deadline_*, appointment_*,
project_*, note_created, checklist_*, deadlines_imported). Moved dynamic
text out of `title` into `description` for status_changed and
deadlines_imported so the static label/description split is consistent.
Added i18n keys for both new English types AND legacy German types so
historical project_events rows render cleanly. Dashboard now prefers
description over title; falls back to title only for events with no
i18n match (defensive for any unknown legacy kinds).
Bug 5 — /deadlines and /appointments matter-filter dropdowns showed raw
keys `fristen.filter.project.all` / `termine.filter.project.all`. The
client TS referenced English-prefix keys that didn't exist; the existing
keys use `fristen.filter.akte.*` / `termine.filter.akte.*`. Updated the
client refs to match the existing keys (kept i18n key namespace stable
to avoid touching every other reference).
Bug 6 — /api/departments?include=members returned 500. Reproduced via
curl: ListWithMembers (and ListMembers) used `LEFT JOIN paliad.users` on
a member.user_id that FKs auth.users — pre-onboarding members produced
NULL u.email/display_name/office/role, which sqlx can't scan into the
non-pointer string fields. Switched both to INNER JOIN; unonboarded
members are skipped (correct UX — without a profile there's nothing to
render anyway).
Bug 9 — Bare `404 page not found` on unknown auth-gated paths
(/whatsnew, /search, /settings/notifications, etc). Added a chromed 404
page (frontend/src/notfound.tsx) with sidebar + friendly card + "back
to dashboard" CTA, plus a catch-all handler on the protected mux that
serves it with HTTP 404 (and JSON 404 for /api/* misses). Anonymous
visitors keep being redirected to /login by the auth middleware before
the catch-all runs, so no separate marketing-shell variant needed.
Verification:
- go build ./... + go vet ./... + go test ./... clean
- bun run build clean (notfound.html + notfound.js produced)
- Visual checks pending after deploy
Bug 1 (smoke-auth-2026-04-25.md) had a third symptom beyond the RLS
function bodies and the visibilityPredicate `::uuid[]` issue:
/api/deadlines/summary and /api/appointments/summary returned 500 with
`sqlx: missing destination name this_week in *services.SummaryCounts`.
Cause: SummaryCounts (deadline) and AppointmentSummaryCounts had only
`json:` tags. sqlx falls back to the lower-cased field name when no `db:`
tag is present, so `ThisWeek` mapped to `thisweek` — but the SQL aliases
the column as `AS this_week`. Adding `db:"this_week"` (and matching tags
for the other fields) lets sqlx find the destination.
Verified by hitting both endpoints; previously 500 → now expected 200.
Bug 1 in tests/smoke-auth-2026-04-25.md (/api/projects, /api/deadlines,
/api/appointments returning HTTP 500) had a second root cause beyond the
RLS function bodies fixed in 021: sqlx v1.4.0's named-parameter parser
strips one colon from `::uuid[]` while compiling `:user_id` / `:role`
placeholders, producing invalid SQL `:uuid[]` that Postgres rejects with
`syntax error at or near ":"`. The bug was masked by the earlier
`relation "paliad.projekte" does not exist` error.
Replacing `::uuid[]` with the equivalent SQL-standard `CAST(... AS uuid[])`
sidesteps the parser issue without changing semantics. Verified with a
small repro: `sqlx.Named` no longer corrupts the cast.
Only `visibilityPredicate` (the named-bind variant) is affected — the
positional and `?`-placeholder variants don't go through `sqlx.Named`.
Migration 020 renamed paliad.can_see_projekt → can_see_project (and
notiz_is_visible → note_is_visible) via ALTER FUNCTION but never rewrote
the bodies. On production the bodies still queried paliad.projekte /
projekt_teams / fristen / termine / projekt_events — all of which were
dropped or renamed in 018+020. Every RLS-enforced read against the new
English tables exploded with `relation "paliad.projekte" does not exist`,
breaking /api/projects, /api/deadlines, /api/appointments etc.
Same problem for the trigger functions paliad.projekte_sync_path() and
paliad.projekte_rewrite_subtree() — kept their German names and German
bodies; the triggers on paliad.projects still pointed at them.
Migration 021:
* DROP FUNCTION ... CASCADE drops can_see_project / note_is_visible
along with their 21 dependent RLS policies (whose names were still
German on prod: projekte_*, projekt_teams_*, fristen_all, termine_*,
parteien_all, dokumente_all, projekt_events_all, notizen_all,
checklist_instances_*).
* Recreates the two functions with English bodies + English parameter
names and rebuilds every dependent policy under its canonical
English name (matching migration 018).
* Drops the German trigger functions/triggers on paliad.projects and
recreates them as projects_sync_path / projects_rewrite_subtree.
Idempotent on a fresh DB (where everything is already English): the
CASCADE drops the same policies and the recreate produces an identical
end state.
Verified by running the up.sql in BEGIN/ROLLBACK against the actual
youpc prod Postgres — 21 policies dropped, 21 recreated, function
bodies now reference paliad.projects / project_teams / etc.
Refs: tests/smoke-auth-2026-04-25.md (Bug 3, root cause for Bugs 1+2).
The German→English rename (t-paliad-025) renamed the projects table and
ReminderService struct fields, but the SQL aliases in sendPerFrist /
sendWeekly still spelled `frist_title`, `akte_aktenzeichen`, and
`akte_title`. sqlx.SelectContext could not map them to the
`deadline_title` / `project_reference` / `project_title` `db:` tags, so
every hourly reminder scan returned a "missing destination name" error
and emails silently stopped going out.
This commit:
* renames struct fields AkteAktenzeichen/AkteTitle on fristReminderRow
and weeklyRow to ProjectReference/ProjectTitle and updates the `db:`
tags to project_reference / project_title.
* rewrites the SELECT aliases (deadline_title, project_reference,
project_title) to match.
* propagates the new keys through deliverFristReminder /
deliverWeekly into the email template data and renames the matching
variables in deadline_reminder.html and deadline_weekly.html.
* updates mail_service_test.go fixtures to the new keys.
* adds TestSendPerFrist_ScansCleanly — a live-DB regression test
(skips without TEST_DATABASE_URL) that seeds a project + deadline
and asserts sendPerFrist / sendWeekly scan without error, so a
future tag/alias drift fails CI instead of going silent.
Adds /team page that lists every onboarded Paliad user, grouped by office
(default) or by department, with a free-text search and per-office filter
pills. Each card shows display name, role, primary office (with any
additional offices), department tag, and a mailto: link.
Backend:
- /api/users now also returns additional_offices (column was already on the
model + DB; just missing from the SELECT list).
- /api/departments?include=members returns each department enriched with
its lead user snapshot and the full member list — single fetch for the
"by department" grouping.
- New page handler /team behind the onboarding gate.
Frontend:
- frontend/src/team.tsx + frontend/src/client/team.ts (new) for the page
shell and client-side rendering / filtering.
- New "Team" entry in the Übersicht sidebar group with a users icon.
- DE/EN i18n keys (nav.team, team.*).
- Team-specific CSS for cards, group headers, avatars, and badges.
- Backend: GET /api/projects/tree returns the full visible project tree as
nested JSON with embedded children, open/overdue deadline counts per
node — visibility-scoped via the existing predicate, single round-trip.
- Frontend: new project-tree.ts module renders a collapsible, indented tree
with type icons (client/litigation/patent/case/project), status badges,
deadline-count chips and chevron toggles. Top two levels expand by
default; deeper nodes start collapsed. Expansion state persists in
sessionStorage so toggling list/tree keeps user choices.
- Wired to /projects via the existing Ansicht select (Liste/Baum/Wurzeln);
dedicated tree container coexists with the flat-list table.
- New i18n keys (de/en) + tree styles in global.css (lime accent on hover).
Three rename leftovers from t-paliad-025 fixed in one shot:
1. TSX/TS element ID mismatches — every page that worked via getElementById was
broken because the client TS was renamed (e.g. project-title) but the TSX
still used the German id (akte-title), so $() / getElementById would throw
"missing element". Renamed `akte-*` → `project-*`, `termin-akte-*` →
`termin-project-*`, `frist-akte-*` → `frist-project-*`, `new-instance-akte`
→ `new-instance-project`, `frist-filter-akte` → `frist-filter-project`,
`termin-filter-akte` → `termin-filter-project` across all affected TSX.
2. Migration 020 idempotency — every ALTER TABLE/FUNCTION/COLUMN now lives in
a DO $$…EXCEPTION WHEN undefined_table/column/function THEN NULL block.
Production already has English names (manually patched), and the rewritten
migration 018 creates English names directly on a fresh DB; the old
non-defensive 020 would have failed in both scenarios. Down migration
wrapped the same way for symmetry.
3. PostgREST endpoint names — `checklists_feedback` and `courts_feedback`
referenced tables that don't exist; migration 020 renames the source
tables to `checklist_feedback` / `court_feedback` (singular, matching
`link_feedback`). Handlers now point at those. `glossary_suggestions`
reverts to `glossar_suggestions` — that table lives in the shared public
schema (pre-paliad era) and is not under our migration control.
Verified: go build / go vet / go test / bun run build all clean. Migration 020
dry-runs clean against current production state inside a transaction.
knuth's rename (t-paliad-025) changed all Go code and URLs to English
but forgot the DB migration. Production tables still German (fristen,
termine, projekte etc.) while code references English names (deadlines,
appointments, projects). This caused reminder_service to fail with
'relation paliad.deadlines does not exist'.
t-paliad-030. Adds `/agenda` — a single page that merges every visible
deadline and appointment into a day-grouped timeline, the third overview
surface alongside Dashboard and the per-resource lists.
- AgendaService: merges paliad.deadlines + paliad.appointments, gated by
the same team-membership predicate used everywhere else; personal
appointments stay creator-only. Items are sorted by date and tagged
with urgency (overdue / today / tomorrow / this_week / later) so the
client can apply the traffic-light colours without re-deriving buckets.
- GET /api/agenda?from&to&types and GET /agenda with the same server-side
hydration pattern as /dashboard (JSON payload spliced into the shell so
the timeline paints on first frame).
- Frontend: agenda.tsx + client/agenda.ts render a day-grouped timeline
with type/range chips; filters round-trip through the URL.
- Sidebar entry under "Übersicht"; DE+EN i18n across all new keys.
Adds a sidebar-wide search bar (t-paliad-026) that hits a single GET
/api/search?q=... endpoint returning grouped results. Static content
(glossary, courts, link hub, checklist templates) is scanned in memory
against the curated Go slices; DB content (projects, deadlines,
appointments, checklist instances, users) is visibility-gated through
the same predicates the normal list endpoints use.
Frontend: new sidebar.ts-owned controller debounces 200ms, renders a
grouped dropdown, supports "/" to focus, Escape/arrows/Enter for
navigation, mobile-full-width overlay, and highlights matches.
Adds a hardcoded changelog (internal/changelog) served via
GET /api/changelog and /api/changelog/unseen-count?since=<iso>, a
/changelog page that renders entries newest-first, and a sidebar
"Neuigkeiten" link with a lime badge showing the count of unseen
entries since the caller's last visit (localStorage stamp).
- internal/changelog: Entry struct, 11 pre-populated entries covering
everything shipped so far (Dashboard, Projects/Deadlines/Appointments,
CalDAV, Checklists v2, Glossary, Courts, Invitations, Settings,
Paliad rename, and the changelog itself).
- Handler: public via auth-gated protected mux. Lexicographic string
compare treats YYYY-MM-DD entries and ISO 8601 cutoffs symmetrically.
- Sidebar: new sidebar-changelog link before the Einladen button; the
badge is populated by a fetch on every page load, suppressed on
/changelog itself to avoid flash, and cleared on visit by stamping
localStorage in changelog.ts's DOMContentLoaded handler.
- i18n: DE + EN keys for nav, page chrome, and tag labels.
- Unit tests for sort order, copy semantics, and same-day cutoff.
Task: t-paliad-027
Unauthenticated bookmark hits on old German URLs (/akten, /fristen, …)
should 301 to the new English path directly. Previously the redirects
lived under the auth middleware, so bookmarked URLs triggered a 302 →
/login → (after login) → 301 round-trip. Registering them on the outer
mux gives the expected one-hop 301.
- einstellungen.tsx: fourth tab 'Dezernat'. My Dezernat card (name, office,
lead, member list). Admin-only 'Dezernate verwalten' section with table
(name/office/lead/members/delete) + 'Neues Dezernat anlegen' form behind
a details summary. Admin controls hidden unless /api/me.role='admin'.
- client/einstellungen.ts: loadDezernatTab() fetches /api/dezernate, then
per-dezernat /api/dezernate/{id}/members to resolve membership for the
'My Dezernat' view. Admin table with delete-with-confirm. New-Dezernat
form posts to /api/dezernate; inserts into in-memory list on success.
TabName + TABS + loadedTabs dispatcher extended.
- i18n: dezernat.* keys (DE+EN) — heading/subtitle/admin section/table
columns/form labels/error strings.
- Migration 019: best-effort seed of paliad.dezernate + dezernat_mitglieder
from paliad.users.dezernat free-text. Each distinct non-empty name
becomes one Dezernat (office = MIN(members.office)); every user whose
free-text matches joins. free-text column preserved so a second pass
can clean it up later. down-migration only deletes rows we inserted
(matches name = btrim(user.dezernat)), leaves admin-created Dezernate
alone.
go build/vet/test + bun run build all clean.
Branch mai/cronus/implement-data-model-v2 now covers all four phases.
Server-side:
- GET /projekte[...] routes alias the existing Akten list/detail/tab pages
so users can reach the v2 URL without a 404 during the cutover. The TSX
pages themselves still render the legacy HTML shell pointing at
/api/akten legacy aliases.
- POST /api/projekte (and legacy POST /api/akten alias) now accepts BOTH
old shape ({aktenzeichen, owning_office, court_ref}) and new shape
({reference, type, parent_id, client_number, matter_number,
netdocuments_url, case_number}). aktenzeichen → reference,
court_ref → case_number. owning_office is ignored (no longer part of
visibility model).
Frontend:
- Sidebar nav link 'Akten' → 'Projekte' → /projekte.
- i18n: nav.projekte added (DE: 'Projekte', EN: 'Projects').
Still PENDING (Phase 3 remainder + Phase 4):
- Frontend TSX pages (akten.tsx, akten-detail.tsx, akten-neu.tsx etc.) still
use legacy field names (aktenzeichen, owning_office, collaborators,
firm_wide_visible). GET /api/akten returns NEW shape (reference, type,
parent_id, path, client_number, matter_number, netdocuments_url). UI will
display blank fields where the old column is missing. Full rewrite needed
per task spec (tree view, type filter, breadcrumb, team tab with
inheritance badges, client create form, projekt create with type +
parent typeahead).
- Dezernate settings tab (Phase 4) not yet built — API endpoints exist at
/api/dezernate[...] but no UI.
- Dashboard JSON shape changed (akte_id → projekt_id, akte_title →
projekt_title, akte_ref → projekt_ref); frontend dashboard.tsx needs an
update to read the new field names.
Build: go build/vet/test and bun run build all clean.
- handlers/projekte.go (was akten.go): Projekt CRUD + tree ops (children,
tree, ancestors), events cursor-paginated, parteien endpoints.
- handlers/teams.go: GET/POST/DELETE on /api/projekte/{id}/team. ListEffectiveMembers
returns direct + inherited (annotated with inherited_from_id/title).
- handlers/dezernate.go: admin-gated CRUD for paliad.dezernate + member
add/remove. Readable by any authenticated user.
- handlers/fristen.go, termine.go, notizen.go, checklist_instances.go updated
to use projekt_id. Kept /api/akten/{id}/fristen|termine|notizen|checklisten
as legacy aliases pointing at the same projekt-aware handlers.
- handlers/users.go: dropped handleListAkteEvents (superseded by
handleListProjektEvents under /api/projekte/{id}/events).
- cmd/server/main.go: ProjektService + TeamService + DezernatService wired
into handlers.Services. Downstream services (Parteien, Frist, Termin,
Notiz, Checklist) take projektSvc.
- Removed obsolete internal/services/akte_service_test.go. go build/vet/test
all clean.
Legacy /api/akten routes still resolve (handlers/JSON shape unchanged on
the GET/POST path) so frontend stays functional during the client cutover.
New /api/projekte routes live alongside.
Phase 3 (frontend tree UI, /projekte page, team tab) + Phase 4 (Dezernat
settings tab) still pending.
paliad.projekte — single self-referential tree (types: client/litigation/patent/case/project).
Materialised path (text, '.'-joined UUIDs, inclusive of self) + trigger maintenance.
ClientMatter numbers (client_number + matter_number, 7-digit CHECK each) and netdocuments_url.
paliad.projekt_teams — team membership with inherited flag (writes = false; services annotate
inherited rows on read by walking up path). Unique (projekt_id, user_id).
paliad.dezernate + paliad.dezernat_mitglieder — structural partner units (orthogonal to project
teams; informational office).
paliad.users — adds additional_offices text[] for partners across multiple offices.
Visibility simplified to team-based only: can_see_projekt() = admin OR direct/ancestor team
membership (path @> ancestor). owning_office GONE from every projekt — location is no longer
an access gate. Per head (2026-04-20): cases associate with lead partners, not offices.
Data migration: akten → projekte (same UUIDs, type='case', parent NULL orphans). Creator →
projekt_teams(role='lead'); collaborators → projekt_teams(role='associate'). Orphan akten with
no creator + no collaborators become admin-only until reassigned.
Child FK rename: akte_id → projekt_id on parteien, fristen, termine, dokumente, akten_events,
notizen, checklist_instances. No data move (same UUIDs). akten_events renamed to projekt_events.
notizen keeps its polymorphic 4-FK shape.
paliad.akten dropped. can_see_akte() and notiz_is_visible(akte) replaced.
Down-migration restores v1 schema best-effort: only type='case' projekte come back as akten;
non-case tree rows are lost (documented). owning_office backfilled from creator's primary office.
Followups (Phase 2): replace AkteService with ProjektService + TeamService + DezernatService,
wire creator-auto-lead into Create path, update all child services to use projekt_id.
No code changes in this commit — server will fail to build/start until Phase 2 lands.
Unified /einstellungen page replaces the standalone CalDAV screen. Three
tabs today (Profil / Benachrichtigungen / CalDAV); adding more is additive
(one <a> in the tab nav, one <section> panel, one loader). Tab switching
is client-side from ?tab=<name> — default tab is Profil.
Profil tab lets users fix onboarding data without admin intervention:
display name, office, role, Dezernat, language. Email is read-only (the
source of truth is auth.users and an account-level change is out of
scope for the settings page).
Benachrichtigungen tab exposes deadline reminder preferences as a master
toggle plus three per-kind sub-toggles (overdue / tomorrow / weekly).
Preferences land in paliad.users.email_preferences (JSONB); missing keys
are treated as opt-in so existing users keep the behaviour they had
before the page shipped.
CalDAV tab is the old /einstellungen/caldav screen ported inline.
/einstellungen/caldav now 301-redirects to /einstellungen?tab=caldav so
bookmarks keep working.
Backend:
- PATCH /api/me (handlers/users.go) mutates the caller's paliad.users
row. Attempts to include "email" in the body return 400 — the field is
always server-authoritative.
- UserService.UpdateProfile builds a dynamic UPDATE from the pointer
fields supplied; omitted keys are left untouched. Re-uses the
admin-bootstrap guard for role changes.
- GetByID SELECT now includes lang + email_preferences so /api/me
returns the data the settings page needs without a second round-trip.
- ReminderService consults email_preferences before sending — the helper
reminderEnabled covers the master switch and per-kind overrides; corrupt
JSON falls back to on so a bad row can't silence reminders.
- Migration 017 adds email_preferences jsonb NOT NULL DEFAULT '{}' and
upgrades lang from nullable (from 016) to NOT NULL DEFAULT 'de' with a
one-shot backfill. Down restores the nullable lang and drops
email_preferences.
Model change: User.Lang moved from *string to string — it's NOT NULL in
the DB now, so the indirection was carrying no information. Inviter.Lang
and reminder row structs followed suit; the templates and callers used
""/"en" comparisons that translate 1:1.
Sidebar: the "Einstellungen" group now links to /einstellungen (instead
of just /einstellungen/caldav); the CalDAV sub-item is folded into the
tab nav on the page itself.
Tests: reminderEnabled has table-driven coverage (master switch,
per-kind, corrupt JSON, non-bool values). DB-backed user tests still
skip without TEST_DATABASE_URL as before.
Verified: go build ./..., go vet ./..., go test ./..., bun run build —
all clean.
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
html/template rendering, branded base layout + content templates, silent
no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
overdue / due tomorrow / due within the week (Monday digest). Dedup via
paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
whitelist, in-memory 10/day/user rate limit, audit row in
paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
question; current pilot keeps identity mails on Supabase default sender.
Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
- Drop the Praxisgruppe field from the onboarding form. Every Paliad user
is in patent practice, so the field carried no signal. The DB column is
retained for future use (set to NULL on insert).
- Switch role from a 4-value enum (partner/associate/pa/admin) to free
text with a <datalist> of suggestions (Partner, Associate, PA, Of
Counsel, Referendar/in, Trainee, wiss. Mitarbeiter/in, Sekretariat).
German firms have many roles beyond the original four.
- Add an optional Dezernat field — the team led by a specific partner.
Free text, no FK (the partner may not be registered yet).
Backend:
- Migration 015: drop the role enum CHECK, replace with non-empty CHECK;
ADD COLUMN dezernat text.
- UserService.Create: drop validRoles map, require non-empty role string,
trim and persist Dezernat. Admin bootstrap gate unchanged.
- models.User gains Dezernat *string; userColumns SELECT updated so
/api/me returns it.
Frontend:
- onboarding.tsx: replace role <select> with <input list=...>; add
dezernat input; remove practice_group.
- onboarding.ts: send dezernat (if non-empty), require role.
- i18n: add onboarding.role.placeholder, onboarding.dezernat[.placeholder],
onboarding.error.role; remove the role.* enum and practice_group keys.
New users were stuck on the dashboard with a dead-end "Bitte schließen Sie das
Onboarding ab" message because nothing created the paliad.users row that all
matter-management features depend on. This adds the missing Phase D flow.
Backend
- UserService.Create: validates display_name / office / role, inserts the
paliad.users row with (id, email) from the verified JWT claims (never from
the request body — prevents onboarding as someone else).
- Admin bootstrap: only the very first paliad.users row may self-assign
role='admin'; subsequent requests get ErrAdminBootstrapOnly (403). Guarded
by pg_advisory_xact_lock so two concurrent first-logins can't race past
the count=0 check under READ COMMITTED.
- POST /api/onboarding + GET /onboarding; the page is authenticated but NOT
behind the onboarding gate (it's the one page users without a paliad.users
row may reach).
- gateOnboarded middleware wraps the matter-management pages (Dashboard,
Akten, Fristen, Termine, Einstellungen/CalDAV) and 302s to /onboarding
when the caller has no paliad.users row. Knowledge-platform pages
(Kostenrechner, Glossar, Links, Downloads, Gerichte, Gebührentabellen,
Checklisten, Fristenrechner) stay ungated.
- auth.VerifiedClaims now carries the email claim; auth.ClaimsFromContext
exposes it to handlers. GET /api/me includes the email in the 404 body so
the onboarding form can pre-fill the display name from the local-part.
Frontend
- frontend/src/onboarding.tsx + src/client/onboarding.ts: centred card on the
existing .login-card styling. Fields: display_name (required, pre-filled
from email local-part), office (dropdown from /api/offices), role
(dropdown, default associate), practice_group (optional).
- Dashboard client: toggleOnboardingHint now redirects to /onboarding
instead of showing the dead-end hint — belt-and-braces behind the server
gate in case the DB lookup fell through.
- DE + EN i18n keys for every label, placeholder, and error.
- Added onboarding to build.ts.
Tests: internal/services/user_service_test.go covers the valid path,
per-field validation, duplicate (ErrUserAlreadyOnboarded), and the
admin-bootstrap gate. Follows the existing TEST_DATABASE_URL skip pattern.
Three items from docs/improvement-audit.md §2:
I-5 Verlauf pagination
- AkteService.ListEvents now accepts a (before *uuid.UUID, limit int) cursor
- SQL uses a composite (created_at, id) cursor subquery — stable across
rows written in the same microsecond
- Handler parses ?before=<uuid>&limit=<n>, service clamps to 200
- Frontend fetches first page (50) on init and exposes a "Mehr laden" /
"Load more" button that keeps paging until the tail returns < page size
- i18n keys akten.detail.verlauf.loadMore / .loadingMore in DE + EN
I-8 patholo → paliad client-side rename with migrations
- i18n.ts: STORAGE_KEY is now paliad-lang; one-shot migration reads the
old patholo-lang value, writes the new key, deletes the old
- sidebar.ts: same pattern for paliad-sidebar-pinned
- Cookie rename with dual-read grace period: SessionCookieName is
paliad_session, LegacySessionCookieName keeps patholo_session as
read-only fallback. Requests using the legacy cookie get upgraded to
paliad_session in the response; legacy cookie is expired in the same
response. ClearAuthCookies clears both names to prevent stale-cookie
resurrection. Remove the legacy fallback after 2026-05-18 (30d cookie
max age).
- handlers/links.go:extractEmailFromCookie reads either cookie name via
auth.SessionCookieName / auth.LegacySessionCookieName
P-6 Single source of truth for offices
- New internal/offices package: Office struct + All + IsValid + Keys
- akte_service.go switched from inline isValidOffice to offices.IsValid
- GET /api/offices returns the list with DE + EN labels
- Akte create form (akten-neu.tsx) has an empty <select>; the client TS
fetches /api/offices and populates options, re-rendering on lang change
Tests:
- internal/offices/offices_test.go covers IsValid + Keys + label coverage
- internal/auth: three new Middleware tests — legacy cookie still
authenticates + upgrades the browser, new cookie wins when both are
present (no clobber), missing cookie returns 401 on API paths
Build: go build ./... + go vet ./... + go test ./... + bun run build all clean.
Known out-of-scope: handlers/links.go still POSTs to public.patholo_link_*
via PostgREST; migration 011 created fresh paliad.link_* tables but the
handler refactor (move to direct DB, copy data, drop public tables) is a
separate phase documented in that migration's header.
Items from docs/improvement-audit.md §2 + §3:
I-1 Hide Dokumente tab entirely from Akten detail (Phase H deferred);
drop placeholder TSX panel, VALID_TABS entry, and orphaned
akten.detail.soon.* i18n keys.
I-2 Add data-i18n keys for all 7 office labels on the landing page.
EN mode now correctly renders "Milan" (was "Mailand").
I-3 Unify UPC URLs in Gerichtsverzeichnis to the canonical hyphenated
form (unified-patent-court.org) matching links.go — 43 occurrences.
I-6 Add SEP/FRAND glossary category with 13 entries (FRAND, SEP,
Standard-essentielles Patent, Patentpool, Anti-Suit, Anti-Anti-Suit,
Injunction Gap, Orange-Book-Standard, Huawei/ZTE, RAND, ETSI IPR,
Patent-Hold-up, Patent-Hold-out) + filter pill + suggest-modal option.
I-7 Refresh README: list migration 014 (checklist_instances), mark
Phase I (Notizen) and Phase J (docs) shipped.
P-1 Remove HL Intern stub links (URL "#") and the now-empty "hl" category.
P-2 Dashboard heading: "Meine Mandate" → "Meine Akten" (matches CLAUDE.md
naming convention). Onboarding hint updated likewise.
P-4 Drop "Hogan Lovells Patent Practice" from the footer — Paliad is the
firm-agnostic brand.
P-5 Empty-state text on Fristen- and Termine-Kalender when the viewed
month has no items.
Verified: bun run build clean, go build / vet / test ./... clean.
C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
outright and clear cookies.
C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
TerminService.canSee: personal Termine (akte_id IS NULL) are
creator-only; admins do NOT see other users' personal Termine.
C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
partner/admin (or the original creator). Personal Termine stay
creator-only.
C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
(default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.com → name@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.
C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
ANTHROPIC_API_KEY line for Phase H readiness.
Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
Checklisten move from one-per-slug localStorage state to a template/instance
model. A user creates multiple named instances of each template (UPC SoC,
EPA Einspruch, …), each with its own checkbox state in paliad.checklist_instances
and an optional akte_id for office-wide visibility.
- Migration 014: paliad.checklist_instances + RLS mirroring the Termine
pattern (akte_id nullable → creator-only; akte_id set → can_see_akte gate).
- Static template data moves out of internal/handlers into internal/checklisten
so both handlers and the new ChecklistInstanceService can reference it
without an import cycle.
- ChecklistInstanceService: CRUD + state merge via `state || $n::jsonb`
so concurrent checkbox toggles don't clobber each other. Reset clears
state to {}. Akte-linked mutations append akten_events audit rows.
- Handlers: GET/POST /api/checklisten/{slug}/instances, GET/PATCH/DELETE
/api/checklisten/instances/{id}, POST .../reset, GET /api/akten/{id}/checklisten.
- /checklisten/{slug} redesigned to show template metadata + instance
table + "Neue Instanz" modal (with optional Akte dropdown). The
interactive checkboxes move to /checklisten/instances/{id} where the
state is DB-backed and Reset posts to the server. Fixes the original
Reset button regression — it now operates on real server state rather
than silently failing client-side.
- Akten detail grows a Checklisten tab listing linked instances with
progress bars; only loads on tab activation.
- localStorage-based progress removed from the overview grid (state no
longer lives there).
- DE + EN i18n keys added.
Verified: bun run build clean; go build ./...; go vet ./...; go test ./...
all green.
Polymorphic notes attached to Akten, Fristen, Termine, or AktenEvents.
Schema (paliad.notizen + paliad.notiz_is_visible) shipped with Phase A
migrations; this phase adds the service, handlers, and shared UI.
Backend
- NotizService (internal/services/notiz_service.go): ListForAkte /
ListForFrist / ListForTermin / ListForAktenEvent + Create / Update /
Delete. Visibility resolves through the parent row — AkteService.GetByID
for Akte/Frist/AktenEvent parents, TerminService.GetByID for Termin
parents (personal Termine are creator-only).
- Edit restricted to the original author; delete allows author +
partner/admin. Create on an Akte-scoped parent appends an akten_events
"notiz_created" audit row in the same transaction; personal Termin
notes skip the audit.
- Author join (paliad.users) surfaces display_name + email on every
listed note so the client can render "von <Name>" without per-row
/api/users fetches.
- Routes wired in handlers.go: GET/POST /api/akten|fristen|termine/{id}/
notizen, PATCH/DELETE /api/notizen/{id}.
Frontend
- Shared client module frontend/src/client/notizen.ts exposes
initNotes(container, parentType, parentId). Renders an add-note form,
list of note cards with relative timestamps (gerade eben / vor N
Minuten / gestern / …), edit + delete affordances gated by author/
role, optimistic add/edit/delete with rollback on error, Ctrl+Enter
submit, and URL auto-linkification inside sanitised note bodies.
- Integrated into akten-detail (Notizen tab — placeholder replaced),
fristen-detail (new "Notizen" section below the detail list), and
termine-detail (new "Notizen" section above the edit form).
- DE + EN i18n keys added; obsolete akten.detail.soon.notizen placeholder
keys removed.
- Notiz-card styles added to global.css (accent-coloured focus, hover
actions, relative-time colour) matching the existing Verlauf card look.
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.
Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
Termine follow AkteService.GetByID. Every Akte-attached mutation appends
an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
to start with malformed key; unset key leaves CalDAV disabled and all
/api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
/api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.
Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
test-connection button, status card, sync log table, delete button that
purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.
Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password
t-paliad-010
Adds the persistent-deadline layer on top of the Phase A schema:
Backend (Go)
- internal/services/frist_service.go: CRUD + bulk import + summary
counts, all gated through AkteService.GetByID for office-scoped
visibility. Every mutation writes an akten_events row.
- internal/handlers/fristen.go: GET/POST/PATCH/DELETE for /api/fristen,
/api/fristen/{id}, /api/fristen/{id}/complete, /api/fristen/summary,
/api/akten/{id}/fristen, /api/akten/{id}/fristen/bulk.
- internal/handlers/fristen_pages.go: serves the four new HTML pages.
- Models: Frist + FristWithAkte (joined for the list page).
- Service wired into cmd/server/main.go.
Frontend (Bun TSX + per-page client TS)
- /fristen — list with traffic-light summary cards (red/amber/
green), status + Akte filters, inline mark-complete.
- /fristen/neu — create form (Akte select, due date, optional rule
+ notes); /akten/{id}/fristen/neu pre-selects.
- /fristen/{id} — detail with inline edit, complete, role-gated delete.
- /fristen/kalender — month grid with deadline dots + day popup.
- Akten detail "Fristen" tab now shows the real list (Phase D
placeholder removed).
- Fristenrechner: "Als Frist(en) speichern" CTA opens a modal that
picks an Akte + which calculated rows to import (POSTs to /bulk).
- Sidebar: activates the Fristen entry (was greyed-out in Phase D).
- DE/EN i18n for all new copy.
- Traffic-light + calendar styles in global.css.
Visibility, audit and role-gating reuse the Phase B/D primitives —
no new RLS or auth surface.
New /dashboard route serves the authenticated home screen with a
server-rendered payload (no skeleton→fetch waterfall, per design
audit §2.3). / now redirects authenticated visitors to /dashboard
and keeps the marketing landing for anonymous visitors.
- DashboardService aggregates deadline + matter summaries, the next
7d of Fristen/Termine, and the last 10 akten_events, all scoped
by the standard office-visibility predicate.
- Dashboard handler splices the JSON payload into dist/dashboard.html
as window.__PALIAD_DASHBOARD__ so the client paints on first frame;
client re-fetches /api/dashboard every 60s to stay current.
- Sidebar gains an "Übersicht" group with the Dashboard entry at the
top; DE/EN i18n keys + traffic-light card styles added.
- Empty-state copy, onboarding hint, and 503 handling keep the page
intact when DATABASE_URL is unset.
- Delete internal/calc/deadlines.go/deadline_rules.go/holidays.go (ported to services)
- fristenrechner handler routes through FristenrechnerService when pool present
- Returns 503 with German message when DATABASE_URL unset (page still renders)
- Migration 012: add name_en columns + seed 9 UI-facing proceeding types
- Commit captures cronus's work after session termination
- TSX pages: list, create form, detail with Verlauf/Parteien tabs +
Fristen/Termine/Dokumente/Notizen placeholders for future phases
- Client TS bundles for each page (search, filter, tab switching, inline
title edit, party add/remove, delete-confirm modal, collaborator picker)
- Sidebar refactored into groups (Arbeit/Werkzeuge/Wissen/Ressourcen);
Akten as first Arbeit entry; Fristen/Termine shown disabled with tooltip
- Backend: /api/me, /api/users, /api/akten/{id}/events + AkteService.ListEvents
- Server routes for /akten, /akten/neu, /akten/{id} and tab sub-routes
- i18n: full DE/EN strings for Akten UI + sidebar groups; title attr support
- Lime CTAs (#c6f41c), office badges, status chips, audit-trail feed
- Office-scoped visibility (firm_wide_visible partner-only, delete
partner/admin-only) gated in UI; backend enforces regardless
- Graceful DATABASE_URL-unset message on list page; no 5xx
Production crash when DATABASE_URL was first set on the shared Supabase:
pq: column "dirty" does not exist at column 17 (42703)
in line 0: SELECT version, dirty FROM "public"."schema_migrations"
Root cause: the Supabase instance already had a differently-shaped
public.schema_migrations (version-only, no dirty column) from another app
or earlier tool. golang-migrate's default tracking table is called
"schema_migrations" and lives in current_schema() (public, since paliad
didn't exist yet at migrator startup). The driver tried to read its own
schema from the foreign table and blew up.
Fix:
1. Set postgres.Config.MigrationsTable = "paliad_schema_migrations" — a
uniquely-named tracker that cannot collide with another app's table.
2. Pre-create the paliad schema before invoking golang-migrate so
subsequent migrations target it cleanly. Idempotent via IF NOT EXISTS.
3. Leave the tracker in `public` (default SchemaName). Rationale: the
first migration's down-step is DROP SCHEMA IF EXISTS paliad CASCADE,
which would take a paliad.schema_migrations tracker with it and break
any subsequent migrate.Up(). Keeping it in public makes down-cycles
safe.
Verified locally:
- Reproduced the collision by creating a public.schema_migrations with
only a version column (matching the production shape) and running the
fixed migrator against it.
- Pre-existing public.schema_migrations untouched (version=42 preserved).
- New public.paliad_schema_migrations created at version=11.
- All 15 paliad.* tables created.
- Idempotent: second migrator run reports ErrNoChange, no double-apply,
seed data unchanged.
- Live tests (TEST_DATABASE_URL) still pass against the collision DB.