- New auth.RequireAdmin middleware (gates by paliad.users.role='admin')
with API/browser-aware reject paths and a fail-closed lookup-error 500.
- Service: AdminCreateUser (onboard from existing auth.users), AdminUpdate
(full profile fields incl. additional_offices), AdminDeleteUser (also
removes project_teams + department_members memberships and clears any
led-Dezernat seat — auth.users is left intact), ListUnonboardedAuthUsers,
IsAdmin (implements auth.AdminLookup).
- Handlers: GET/POST /api/admin/users, GET /api/admin/users/unonboarded,
PATCH/DELETE /api/admin/users/{id}, plus GET /admin/team for the page.
All registered through RequireAdminFunc so non-admins get 403/302.
- Refuses to delete the last remaining admin and rejects role='admin'
assignment via the admin UI (still SQL-only) — same rules as PATCH /api/me.
- /admin/team page: full users table with inline edit (display_name, office,
role, dezernat, additional_offices, lang), trash with confirm, search +
office filters, "Onboard existing account" modal driven by
/api/admin/users/unonboarded, and an Invite button that re-opens the
shared sidebar invite modal.
- Sidebar gains a hidden Admin section that sidebar.ts reveals after a
successful /api/me lookup confirms role='admin' (fails closed on error).
- DE+EN i18n strings for the page, modal and table.
- Tests: require_admin_test.go covers admin-allowed, non-admin 403/302,
unauthenticated 401 and lookup-error fail-closed paths.
- Edit pencil on /projects/{id} now opens a modal with the same form as
/projects/new, pre-filled from the project. Type and parent are
intentionally read-only — re-typing/reparenting are structural ops not
exposed via PATCH today.
- Form body extracted into <ProjectFormFields/> + shared
client/project-form.ts so create and edit share the same fields,
visibility logic, parent picker, and payload builder.
- Inline title/description edit removed; one edit path is clearer than two.
- Breadcrumb rewritten as pill chips with type icons (matching the project
tree), chevron separators, hover lime accent, ellipsis truncation, and
horizontal-scroll fallback on mobile.
- Tab toolbar action buttons standardised — same height, padding, font
weight across Verlauf/Team/Untergeordnet/Parteien/Fristen/Termine plus
the "Mehr laden" secondary so they no longer drift visually.
Reminders used to fire whenever the hourly ticker happened to scan after
a user's first eligible event — m got mail at 02:28. We now gate delivery
to a user-chosen hour-of-day in their local timezone.
* Migration 022 adds reminder_morning_time / reminder_evening_time /
reminder_timezone (defaults 09:00, 16:00, Europe/Berlin).
* New "due_today_evening" reminder kind with its own template — fires only
for due_date = today AND status = pending, in the evening slot.
* Reminder service computes user-local hour each tick and skips users
outside their slot. SQL widens to a 3-day band; in-process filter
narrows to per-user local date.
* Settings → Notifications gains time inputs and a timezone field.
* Tests: pure (inSlot, slotForKind, matchesLocalDueDate) plus a live-DB
TestReminderSlots covering morning, evening, outside-slot, and the
completed-deadline case.
Adds a 6px col-resize strip on the right edge of the desktop sidebar.
Drag updates --sidebar-width on document.documentElement (clamped
180-480px). Mouse + touch handlers; double-click resets to the 240px
default. Width persists via localStorage["paliad-sidebar-width"], read
on every page load before first paint so layout is stable from frame 1.
The handle is opacity-faded on the icon-rail (collapsed) state and
hidden entirely under the mobile breakpoint, since the mobile sidebar
is an overlay drawer that always uses the fixed --sidebar-expanded
width.
Pin/unpin behaviour is preserved: pinned state keeps the user's chosen
width; unpinning drops to the icon rail; hover-expand restores the
chosen width. The hover-collapse mouseleave handler ignores transitions
during an active drag so the sidebar doesn't snap shut mid-resize.
Implements the design from docs/design-command-palette.md. Adds a fzf-style
command palette on top of the existing global search overlay:
- Cmd+K (Mac) / Ctrl+K (Win/Lin) opens the palette in discoverability mode
(all 20 actions visible, no entity fetch). Existing "/" shortcut preserved.
- preventDefault + stopPropagation suppress browser-native Ctrl+K behavior
(Firefox URL-bar focus). Cmd+K explicitly ignores the in-text-input skip
rule so power users can open the palette from anywhere.
- Action catalog (frontend/src/client/palette-actions.ts) — 12 navigate +
3 create + 4 toggle/app actions. Substring filter on DE+EN labels (no
fuzzy lib). runAction dispatcher reuses existing DOM handlers (lang
toggle button, sidebar pin, invite modal) — no duplicated state.
- Filtered state shows actions on top, entity search results below.
- First item auto-selected so ↵ works without an arrow press first.
- Footer shows ↑↓ / ↵ / Esc kbd hints (hidden on <480px viewports).
- 25 i18n keys (DE + EN) under palette.action.* / palette.section.* /
palette.footer.*.
- Mobile: BottomNav stays as-is (5 slots full); palette accessed via the
drawer search input. Documented decision in design doc.
Build / vet / test all clean. Smoke verified on local: login page loads
with no console errors, palette code is bundled into authenticated page
JS bundles. Production verification via Playwright after Dokploy
auto-deploy.
The global search palette emits /links?q=<title> when a user clicks a link
result, but /links had no search input — only category filter pills — so
the deep link silently landed on the unfiltered catalog.
Added a search input matching the glossary/courts pattern: live keystroke
filtering across title + DE/EN description + URL, combined with the
existing category filter, and ?q= URL prefill on init. Result count chip
("2 / 47") added next to the input for parity with the other catalogs.
i18n: links.search.placeholder added in DE + EN.
Completed deadlines were irreversible — accidental completions could not be
undone. Adds a symmetric reopen path for global admins and project leads.
Server:
- PATCH /api/deadlines/{id}/reopen flips status back to pending and clears
completed_at, audit-logged as project_event kind 'deadline_reopened'.
- DeadlineService.Reopen mirrors Complete shape; new
assertCanAdminProject helper gates on global users.role='admin' OR
paliad.project_teams.role IN ('admin','lead') walking the project path.
- Service test (skipped without TEST_DATABASE_URL) covers admin + non-admin
paths and idempotent no-op.
UI:
- /deadlines/{id} detail: Wieder öffnen / Reopen button replaces the
disabled completed-state Erledigt button (admin/partner only).
- /deadlines list: per-row ↻ icon for completed rows (admin/partner only;
project-lead-only users use the detail page).
- i18n: fristen.detail.reopen, fristen.action.reopen,
dashboard.action.deadline_reopened (DE + EN).
The global search palette emits /courts?q=... and /glossary?q=... but the
client bundles only wired the search input to live keystrokes — the URL
parameter was ignored on load. Clicking a court or glossary result in the
palette landed on the right page but showed all 41 courts / all glossary
terms instead of the expected single match.
Fix: in each page's initSearch(), read URLSearchParams.get('q') and prefill
the input value + module-level searchQuery before the data loads. The
existing render() call after fetch resolves already reads searchQuery.
Out of scope: /links has no search input today (palette also emits
/links?q=...). Flagged for a follow-up — adding a search input is a feature
addition, not a bug fix.
Cache-Control: no-cache on /assets/* (step 3) only applies to NEW
responses — cached entries from before the deploy are still served
without revalidation under heuristic freshness, which is exactly the
window that kept users stuck on the broken bundle.
The robust fix is to change the cache key on every deploy:
- frontend/build.ts now post-processes every dist/*.html and appends
`?v=<buildVersion>` to every /assets/*.js and /assets/*.css URL.
Same buildVersion the SW already uses, so the SW cache, the asset
URL, and the HTML reference all rotate together.
- internal/handlers/handlers.go wraps the protected mux (and the
public /login, /logout, /{$} pages) in a noCachePages middleware.
HTML pages now revalidate on every navigation; combined with the
versioned asset URLs, a deploy reaches users on their next request:
new HTML → new ?v= → fresh script load, every time.
After step 2 deployed the IIFE-wrapped bundles, m's browser still saw
the broken page because /assets/projects.js was being served from the
local HTTP cache (no Cache-Control, just heuristic freshness from
Last-Modified). Even after the new SW activated and cleared its own
caches, its cacheFirst handler did `fetch(req)` which goes through the
browser HTTP cache — re-fed the SW cache from the stale bundle and the
loop perpetuated forever.
Three mutually reinforcing fixes:
1. SW cacheFirst now does `fetch(req, { cache: "reload" })` for the
network leg. Forces the network fetch to bypass the browser's HTTP
cache, so the SW always seeds its own cache from a true network read.
2. Go static handlers for /assets/* and /icons/* set
`Cache-Control: no-cache, must-revalidate`. Combined with the
Last-Modified that http.FileServer already emits, browsers send
If-Modified-Since and the server replies 304 when unchanged — fast
for repeat loads, fresh on every deploy. Users without a SW (or after
the kill-switch unregistered theirs) now also pick up new bundles
immediately.
3. pwa-install.ts gates the install banner on
`(min-width: 768px)` — same breakpoint the BottomNav and other
mobile-shell elements use. Desktop partners no longer get an install
prompt covering their work area.
ROOT CAUSE of /projects empty state: the per-page bundles (app.js,
projects.js, dashboard.js, …) were emitted by bun build without an IIFE
wrapper, and loaded as classic <script> tags. Every top-level `var`,
`let`, `const`, and `function` declaration therefore became a property
of the global object.
After t-paliad-042 added app.js to every page (loaded with defer, before
DOMContentLoaded), the minified `var d = "patholo-sidebar-pinned"`
inside app.js (the legacy sidebar-pinned localStorage key constant)
clobbered projects.js's minified `function d() { … }` (the
`applyTranslations` helper). When projects.js's DOMContentLoaded handler
called initI18n → applyTranslations → `d()`, `d` was now the string
"patholo-sidebar-pinned" → "TypeError: d is not a function" → the
fetch to /api/projects never even fired → table stayed empty → empty
state showed.
Fix: pass `format: "iife"` to Bun.build so every entry is wrapped in
`(()=>{ … })()`. Top-level identifiers are now scoped per bundle and
cannot collide on `window`. Verified locally: window.d, window.r,
window.K all `undefined` after both app.js and projects.js execute.
While here, replace the t-paliad-043 step 1 kill-switch SW with the
proper versioned cache pattern the brief asked for:
- frontend/public/sw.js carries `__PALIAD_BUILD_VERSION__` placeholder
- frontend/build.ts substitutes `v<Date.now()>` after copying public/
into dist/, so every deploy opens a fresh `<version>-static` cache
- activate handler deletes any cache whose name doesn't match current,
which evicts both the old paliad-v1-static cache and any kill-switch
survivors the moment a user lands on the new deploy
- skipWaiting + clients.claim so the new SW takes over on the next
navigation rather than waiting for every tab to close
Emergency: t-paliad-042 shipped a service worker that cached a broken
/assets/projects.js (crashes on init with "d is not a function"), making
/projects show the empty state. Mobile Safari users have no devtools to
manually unregister the SW.
Replace sw.js with a self-destructing variant: on activate, delete every
cache, unregister itself, and force every open client to navigate to a
fresh page. /sw.js is served with no-cache headers so browsers refetch
on the next navigation and propagate the kill-switch automatically.
Step 2 (separate commit): fix the projects.js bundle bug, then ship a
properly versioned SW that evicts stale caches on every deploy.
Ship the installability bits that t-paliad-041 deferred so iOS / Android
users can add Paliad to their home screen.
What landed:
- frontend/public/manifest.json — name=Paliad, theme_color #65a30d (lime),
display=standalone, scope=/, start_url=/dashboard, four icon entries
(192/512 × any/maskable). Served from /manifest.json with the
spec-mandated application/manifest+json content type (servePWAManifest
in internal/handlers/pwa.go).
- frontend/public/icons/ — lime "p" logo rendered to 192/512 PNGs in both
"any" and maskable variants (maskable variant has extra safe-zone
padding), 180×180 apple-touch-icon, 32×32 favicon. SVG sources kept
under frontend/icons-src/ for regeneration via rsvg-convert.
- frontend/public/sw.js — minimal cache-first for /assets/* and /icons/*,
network-first for /api/*, network passthrough for everything else.
CACHE_VERSION + activate-clean lets us bump and purge cleanly. Served
from /sw.js so its scope can claim /; Service-Worker-Allowed: / header
set, no-cache on the SW file itself so updates take effect on next load.
- frontend/src/components/PWAHead.tsx — head fragment (manifest link,
apple-touch-icon, favicon, app-name metas, <script src="/assets/app.js"
defer>). Added to all 30 page TSX files via mechanical insertion.
- frontend/src/client/app.ts — universal client bundle loaded on every
page. Three jobs: register the service worker, init the BottomNav
(icarus flagged that bottom-nav.ts was written but never wired into
the build — m reproduced the broken [+] Anlegen and Menü buttons in
prod), and surface the install banner.
- frontend/src/client/pwa-install.ts — install banner UI. Two flows:
beforeinstallprompt for Chromium/Android (deferred → CTA → prompt),
one-time iOS Safari hint pointing at the share sheet. Both dismissals
persist in localStorage (paliad-install-dismissed / -ios-shown).
- frontend/src/styles/global.css — banner styles, sits above BottomNav on
mobile and pinned bottom-right on desktop, lime-on-white card with the
brand "p" mark.
- frontend/build.ts — copies frontend/public → dist verbatim so the
manifest, icons, and SW land at the application root.
Verification before merge:
- bun run build clean, go build/vet/test clean.
- Local server smoke: curl -sI confirmed manifest.json (200,
application/manifest+json), all icon files (200, image/png), sw.js
(200, Service-Worker-Allowed: /), app.js (200, text/javascript).
- Playwright at 390×844: Chrome fired beforeinstallprompt, the banner
rendered with "Paliad installieren" + "Installieren" CTA in German,
dismiss persisted across reload via localStorage. Manifest validated
in-browser (name/short_name/start_url/display/scope all correct, all
four icon URLs returned 200).
- The InvalidStateError on serviceWorker.register() seen in the MCP
Playwright profile is a known headless flag; SW registration works in
real Chrome / Safari on localhost and HTTPS production.
Out of scope: push notifications, runtime offline mode (SW intentionally
stays minimal — cache shell + assets, network passthrough for everything
else).
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.
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).
Root cause of the URGENT bug: parseAkteID() in
frontend/src/client/projects-detail.ts only accepted /projekte/{id} and
/akten/{id} URL prefixes. After t-paliad-025 renamed pages to /projects/{id},
parts[0] === "projects" failed both checks → null id → notfound branch
fired before any /api/projects/{id} fetch. The 200 from curl was real;
the page just never asked.
Fix: parseProjectID() now reads /projects/{id}. Old bookmark tab slugs
(verlauf, parteien, fristen, …) are mapped to their English successors so
deep links don't silently fall back to the default tab.
Bundled cleanup — every per-project subpath the client TS still hit was a
404 because the rename only touched top-level routes. Lockstep rename of
URLs, function names, DOM IDs, and the TabId union in projects-detail.ts
+ projects-detail.tsx:
- /api/projects/{id}/parteien|fristen|termine|notizen|checklisten →
/parties|deadlines|appointments|notes|checklists
- loadParteien/loadFristen/loadTermine/loadAkte/parseAkteID →
loadParties/loadDeadlines/loadAppointments/loadProject/parseProjectID
(the old loadParteien/loadFristen/loadTermine bodies even assigned to
undeclared `parteien`/`fristen`/`termine` — would have thrown
ReferenceError as soon as the catch branch ran)
- DOM IDs: akten-detail-* → project-detail-*, parteien-* → parties-*,
partei-* → party-*, project-fristen-* → project-deadlines-*,
project-termin(e)-* → project-appointment(s)-*,
project-checklisten-* → project-checklists-*, akten-events-* →
project-events-*, kinder-* → children-*, projekt-breadcrumb →
project-breadcrumb, frist-add-link → deadline-add-link,
termin-add-btn → appointment-add-btn
- Tab slugs in URL + data-tab + tab-* IDs: verlauf/kinder/parteien/
fristen/termine/notizen/checklisten →
history/children/parties/deadlines/appointments/notes/checklists
- frist-add-link href: /projects/{id}/fristen/neu →
/projects/{id}/deadlines/new
Sweep across the rest of frontend/src/client/:
- notes.ts: NotizParentType → NotesParentType, "frist"/"termin" →
"deadline"/"appointment", baseURL paths /…/notizen → /…/notes; updated
callers in deadlines-detail.ts and appointments-detail.ts.
- deadlines-new.ts: undeclared `akten` reference (loadAkten was assigning
to a never-declared name) replaced with `projects`; URL /…/fristen →
/…/deadlines; path-parsing of /akten/{id}/fristen/neu rewritten as
/projects/{id}/deadlines/new; preselectedAkteID → preselectedProjectID;
Project.aktenzeichen field (no longer emitted by API) → reference.
- fristenrechner.ts: bulk endpoint /…/fristen/bulk → /…/deadlines/bulk;
request body { fristen } → { deadlines } (server expects "deadlines"
key); ProjectOption interface now uses reference instead of
aktenzeichen.
- deadlines.ts, appointments.ts, deadlines-detail.ts, appointments-detail.ts,
checklists-detail.ts, appointments-new.ts: Project interface field
aktenzeichen → reference (the API returns "reference"; the old field
rendered as undefined in select options and detail headers).
i18n key strings (akten.detail.*, projekte.*, fristen.*, termine.*,
checklisten.*, notizen.*) intentionally kept in German per the
t-paliad-025 convention. CSS class names (frist-row, akten-table-wrap,
termin-dot, etc.) untouched — separate stylistic cleanup.
Verified: go build/vet/test clean, bun run build clean, dist HTML +
bundled JS contain only the new English IDs (remaining German strings
are i18n keys).
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
The marketing landing (`/`) renders the same Sidebar as protected pages, so
`initChangelogBadge()` was firing `GET /api/changelog/unseen-count` on every
anon visit and getting 401. Cosmetic noise + wasted round-trip.
Add an `authenticated` prop to Sidebar (defaults to true, no behavior change
on protected pages) and pass `false` from `renderIndex()`. The badge `<a>`
is omitted server-side; the existing `if (!badge) return` guard in
sidebar.ts naturally skips the fetch when the element is absent — no
client change needed.
Also append a clarifying note under the env-var table in .claude/CLAUDE.md:
"work without DB" doesn't mean "ungated for anon". The knowledge-platform
routes (Kostenrechner, Glossar, etc.) are still behind the auth gate; only
`/`, `/login`, `/logout`, and `/assets/*` are public. Misread by the smoke
tester briefer; spelled out now to prevent recurrence.
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 changed TS/TSX filenames but left <script src> tags
pointing at old German JS names (akten-neu.js, fristen.js, termine.js,
glossar.js, einstellungen.js, gerichte.js, checklisten.js). These 404'd
in production.
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
1. **Remove Billing-Referenz from the client create form.** Per m: the field stays
in the DB (projekte.billing_reference column) but no longer in the UI. Dropped
the input + label from akten-neu.tsx, the payload write from akten-neu.ts, and
the projekte.field.billing_reference i18n keys (DE + EN).
2. **Add a Notizen (Notes) free-text field to project create + detail at every level.**
Uses the existing projekte.description column (added in migration 018 — nullable
text). Not to be confused with the polymorphic Notizen feature (threaded notes
per projekt/frist/termin), which stays as-is.
- akten-neu.tsx: textarea (rows=4) inserted above the Status select, rendered
for every type (not type-conditional). akten-neu.ts: payload.description set
on submit when non-empty.
- akten-detail.tsx: new description block between header + tabs with
akte-description-display (read) + akte-description-edit (textarea, edit mode).
Edit/save flow on initTitleEdit extended to also PATCH description. Batch PATCH:
only sends keys that actually changed.
renderHeader() populates both, tags the wrapper data-empty=1 when nothing set
(CSS can hide when empty if desired).
- i18n: projekte.field.description / projekte.field.description.placeholder /
projekte.detail.description.heading in DE (Notizen) + EN (Notes).
go build/vet/test + bun run build all clean.
Root cause: applyTranslations() in client/i18n.ts unconditionally overwrote
textContent/placeholder/title with t(key), and t() falls back to the raw key
name when no translation exists. Result: every projekte.* data-i18n attr in
the v2 pages rendered the literal key string ('projekte.heading',
'projekte.subtitle', ...) because I shipped the pages with new i18n keys
without adding the translations.
Two fixes, both in client/i18n.ts:
1. **Fallback behaviour**: applyTranslations() now uses a new internal
tOrEmpty(key) that returns '' when the key is missing in DE and EN,
and the call site only overwrites the DOM when the lookup yielded a
real value. Missing keys no longer clobber the author-provided default
text. This is belt-and-braces for any future page that ships a key
before its translation does.
2. **Missing translations added**: ~90 projekte.* keys for DE and EN,
covering the list page (projekte.heading/subtitle/new/search/filter.*/
view.*/col.*/empty.*/unavailable), the create form (projekte.neu.*,
projekte.field.*, projekte.cancel/submit/error.*), and the detail page
(projekte.detail.title/back/loading/notfound/edit/save, tab.* for all
eight tabs, verlauf.*, team.form.*/col.*, kinder.*, parteien.*
form/role/col/empty, fristen.*, termine.*, checklisten.*, delete.*).
go build/vet/test + bun run build all clean.
**akten-detail.tsx rewrite (now projekte-detail-shaped):**
- Removed office-chip, firmwide-chip (v2 no longer uses them).
- Added type-chip, ClientMatter display (inherits via ancestors when absent),
netDocuments external link.
- Breadcrumb nav above header, populated from /api/projekte/{id}/ancestors.
- New 'Untergeordnet' tab with children list from /kinder endpoint;
'Untervorhaben anlegen' link pre-fills parent via ?parent=<id>.
- New 'Team' tab: lists direct + inherited members (inheritance badge
shows ancestor title), remove button gated on self-or-partner/admin,
add form with user typeahead and role picker.
- akten-detail.ts: Akte interface rewritten (reference/type/parent_id/
path/client_number/matter_number/netdocuments_url/court/case_number).
parseAkteID now accepts both /projekte/{id} and /akten/{id}. New loaders
loadAncestors/loadChildren/loadTeam/loadUserList. TabId extended with
'team' and 'kinder'.
- akten-neu.ts: applyParentFromQueryString pre-fills parent picker when
navigated from a projekt's 'Untervorhaben anlegen' link, auto-switches
type from 'client' to 'case'.
**Tree view in Projekte list:**
- Third view mode 'tree' alongside flat/roots. Sorts filtered rows by
path (ancestors precede descendants); depth-indented title cell with
↳ branch glyph based on depthOf(path).
**Per-Dezernat member manager:**
- einstellungen Dezernat tab 'Verwalten' button now toggles an inline
manage panel per Dezernat (expanded row below the admin table row).
- Panel shows current members with per-row remove (confirm dialog).
- Add-member form with user typeahead against /api/users, posts to
/api/dezernate/{id}/members.
- Wires once per Dezernat (data-wired guard); reloads My Dezernat on
any membership change.
i18n: DE + EN keys for dezernat.manage_heading/loading/no_members/
add_member*/add/remove/confirm_remove/error.user_required and for every
projekte.type.* / projekte.team.role.* / projekte.team.direct /
projekte.team.inherited.hint / projekte.view.tree / projekte.detail.team.*
/ projekte.detail.clientmatter.inherited.
go build/vet/test + bun run build all clean.
- 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.
- akten.tsx + client/akten.ts rewritten for v2: renders /projekte list with
type filter (client/litigation/patent/case/project), status filter, flat
vs roots view toggle, search across title/reference/client_number/
matter_number. Columns now Title / Type / Reference / ClientMatter /
Status / Updated (no more office column per v2 team-based visibility).
- akten-neu.tsx + client/akten-neu.ts rewritten: type selector drives
conditional fields (client industry/country/billing; patent number +
filing/grant dates; case court + case_number). Parent projekt picker
(typeahead over /api/projekte, stores parent_id). ClientMatter
client_number + matter_number (7-digit patterns) + netdocuments_url
fields on every type.
- dashboard.ts field renames: akte_id → projekt_id, akte_title →
projekt_title, akte_ref → projekt_ref. Activity/deadline/appointment
links now point to /projekte/{id}.
- Mass field rename across fristen-*, termine-*, checklisten-*,
fristenrechner.ts, notizen.ts, akten-detail.ts: akte_id → projekt_id,
akte_aktenzeichen → projekt_reference, akte_title → projekt_title,
akte_office → projekt_office. URL paths /akten/${...} → /projekte/${...}.
Pages still referencing deprecated shape (owning_office, collaborators,
firm_wide_visible) render blank for those columns — acceptable during
transition, full akten-detail rewrite (add breadcrumb + Team tab with
inheritance) still pending.
go build/vet/test + bun run build all clean.
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.
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.