- 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.
Wraps up the post-rename cleanup arc. Records what shipped, what's still
open (none blocking), and the rationale for skipping Bug 10 (browser-
emitted console error, not suppressible from JS).
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.
Design only — no code changes. Five-slot bottom bar for phones (<768px),
center slot opens slide-up Quick-Add sheet (Frist / Termin / Projekt),
right slot reuses the existing mobile sidebar drawer. Tablets and
desktop unchanged. Awaiting m's review before implementation.
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
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`.