Commit Graph

1143 Commits

Author SHA1 Message Date
m
d55e98806f Merge main into mai/ritchie/admin-team-management
# Conflicts:
#	frontend/src/styles/global.css
2026-04-27 13:41:26 +02:00
m
c697fe3418 feat(admin): /admin/team page + admin-only user CRUD (t-paliad-050)
- 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.
2026-04-27 13:40:00 +02:00
m
c68e464d67 Merge: full project edit modal + breadcrumb polish (t-paliad-049) 2026-04-27 13:38:08 +02:00
m
59cf47b5ed feat(projects): full edit modal + breadcrumb polish + tab toolbar buttons (t-paliad-049)
- 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.
2026-04-27 13:37:56 +02:00
m
94222f790b Merge: customizable reminder send times + due-today evening sweep (t-paliad-048) 2026-04-27 11:47:30 +02:00
m
e68ff5b434 feat(reminders): per-user send times + due-today evening sweep (t-paliad-048)
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.
2026-04-27 11:47:10 +02:00
m
fa1525b620 Merge: Resizable sidebar width — drag handle + persistence (t-paliad-047) 2026-04-26 15:27:39 +02:00
m
132992ba2a feat(sidebar): resizable width with drag handle + persistence (t-paliad-047)
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.
2026-04-26 15:27:24 +02:00
m
fde4cbe2a9 Merge: Cmd/Ctrl+K command palette (t-paliad-044) 2026-04-26 15:16:10 +02:00
m
75b52d49ba feat(palette): Cmd/Ctrl+K command palette with actions + entities (t-paliad-044)
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.
2026-04-26 15:15:58 +02:00
m
c226a8b14d docs(palette): design Cmd/Ctrl+K command palette (t-paliad-044)
Choose Option B (full palette: actions + entities) over Option A
(keybind-only) because:

- pwa-baseline.md canon for multi-entity sites (paliad has 8 entity types).
- 80% of infrastructure already exists in search.ts (sectioned results,
  keyboard nav, i18n, debounce, abortable fetch).
- Patent-lawyer audience benefits from keyboard-first creation flows.
- A-then-B would mean revisiting in 2 weeks anyway.

Scope guardrails: no fuzzy lib, no MRU persistence, no extension API,
no per-action shortcut keys (Cmd+K only). 20-action catalog (12 navigate,
3 create, 4 toggle/action). Mobile gets palette via drawer (no new
BottomNav slot). Desktop preventDefault on Ctrl+K to suppress URL-bar.

Doc covers: trigger surface, UX shape (empty + filtered), action catalog,
component architecture (extend search.ts, new palette-actions.ts data
file), render flow, i18n keys, mobile considerations, acceptance,
implementation plan, risks. Implementer choice deferred to m.

Awaiting m's go/no-go before coder shift.
2026-04-26 15:02:31 +02:00
m
79d332d5b2 Merge: /links search input + ?q= deep-link wiring (t-paliad-046 follow-up) 2026-04-26 14:54:20 +02:00
m
044166ffed feat(links): add text search input + honor ?q= from search palette (t-paliad-046 follow-up)
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.
2026-04-26 14:54:16 +02:00
m
2a178695ac Merge: reversible deadline status (t-paliad-045) 2026-04-26 14:52:28 +02:00
m
3aa8bae8e9 feat(deadlines): add reversible deadline status — admin/lead reopen (t-paliad-045)
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).
2026-04-26 14:52:00 +02:00
m
a3f778c86a Merge: search-palette deep-links honor ?q= on /courts + /glossary (t-paliad-046) 2026-04-26 14:48:25 +02:00
m
58692a4411 fix(courts, glossary): honor ?q= URL param on init for search-palette deep links (t-paliad-046)
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.
2026-04-26 14:48:07 +02:00
m
1b0de2f89c Merge: asset URL versioning + HTML no-cache (t-paliad-043 step 4) 2026-04-26 14:41:50 +02:00
m
ccbb7e9e33 fix(build, handlers): version-stamp /assets URLs + no-cache HTML pages (t-paliad-043 step 4)
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.
2026-04-26 14:41:47 +02:00
m
71d49d8b81 Merge: cache-bypass + install-prompt mobile gate (t-paliad-043 step 3) 2026-04-26 14:37:06 +02:00
m
0800ba97f3 fix(sw, assets, install): bypass HTTP cache + revalidate assets + mobile-only install banner (t-paliad-043 step 3)
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.
2026-04-26 14:37:02 +02:00
m
4c74b960e9 Merge: bundle IIFE wrap + versioned SW (t-paliad-043 step 2) 2026-04-26 14:31:52 +02:00
m
44ad50d5e4 fix(bundle, sw): IIFE-wrap per-page bundles + versioned SW (t-paliad-043 step 2)
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
2026-04-26 14:31:48 +02:00
m
134c807da3 Merge: kill-switch SW (t-paliad-043 step 1) — emergency unstick 2026-04-26 14:26:01 +02:00
m
dc70114d92 fix(sw): kill-switch SW to unstick users with broken cached bundle (t-paliad-043 step 1)
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.
2026-04-26 14:25:49 +02:00
m
4e06a5db39 Merge: PWA app-shell phase 2 — manifest + icons + SW + install (t-paliad-042) 2026-04-26 10:48:42 +02:00
m
8921830f43 feat(pwa): app-shell phase 2 — manifest + icons + service worker + install prompt (t-paliad-042)
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).
2026-04-26 10:48:27 +02:00
m
69efafeb33 Merge: PWA mobile BottomNav + Quick-Add (t-paliad-041) 2026-04-26 10:32:33 +02:00
m
ad77eb98a3 Merge: pull latest main into bottom-nav branch 2026-04-26 10:32:19 +02:00
m
3f0c26fd3a feat(frontend): PWA mobile BottomNav + Quick-Add sheet (t-paliad-041)
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.
2026-04-26 10:32:00 +02:00
m
2b6218ae2d Merge: smoke delta report (t-paliad-040) 2026-04-26 02:16:53 +02:00
m
2cf20448b3 docs(tests): smoke delta report after t-paliad-038/039/040 + project-tab nil fix
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).
2026-04-26 02:16:43 +02:00
m
3a1eb07781 Merge: smoke cleanup batch 2 (t-paliad-040) 2026-04-26 02:14:08 +02:00
m
d219ca7cdf fix(redirects, settings): /whatsnew alias + /settings/{tab} deep-links (t-paliad-040)
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.
2026-04-26 02:14:02 +02:00
m
263a4605e3 docs(design): add PWA mobile BottomNav design (t-paliad-041)
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.
2026-04-26 01:59:31 +02:00
m
b4a409a013 Merge: project tab nil/empty list fix 2026-04-26 01:44:15 +02:00
m
70c3f08668 fix(projects-detail, services): empty-list endpoints returned JSON null → tab content blank
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.
2026-04-26 01:44:09 +02:00
m
3ff982cc51 Merge: footer copy 'a tool by flexsiebels.de' 2026-04-26 01:35:55 +02:00
m
6698210e9b fix(footer): replace "Nur für internen Gebrauch" with "ein Werkzeug von flexsiebels.de"
The disclaimer was redundant with the separate flexsiebels.de credit line below.
Merge them into a single line that reads "© 2026 Paliad — ein Werkzeug von flexsiebels.de"
(German default) / "a tool by flexsiebels.de" (English).

Footer.tsx: collapse the two paragraphs into one. The translatable copy stops
before the link so the i18n textContent path doesn't strip the anchor; the
link itself is rendered as plain JSX.

i18n.ts: footer.text DE+EN updated.
2026-04-26 01:35:46 +02:00
m
f782ef7975 Merge: deadlines/{id} notfound + Invalid Date list (t-paliad-039) 2026-04-26 01:32:09 +02:00
m
5611e0154c fix(deadlines, appointments): /deadlines/{id} notfound + /deadlines list "Invalid Date" (t-paliad-039)
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).
2026-04-26 01:31:56 +02:00
m
d81da4b3a8 Merge: /projects/{id} notfound fix + German DOM/URL rename (t-paliad-038) 2026-04-26 01:06:13 +02:00
m
cf94f0ca25 fix(projects-detail): /projects/{id} notfound + rename German DOM/URL leftovers (t-paliad-038)
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).
2026-04-26 01:04:07 +02:00
m
05623b673a Merge: polish batch — i18n + departments + 404 chrome (t-paliad-037) 2026-04-26 00:38:14 +02:00
m
3111c7440a fix(polish): i18n leaks, untranslated labels, /api/departments 500, 404 chrome (t-paliad-037)
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
2026-04-26 00:36:33 +02:00
m
fc8275288a Merge: SummaryCounts db tags (t-paliad-036) 2026-04-25 23:44:56 +02:00
m
4bc23958ee fix(services): add db tags to SummaryCounts so sqlx maps this_week (et al.)
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.
2026-04-25 23:44:52 +02:00
m
144a08d409 Merge: visibilityPredicate sqlx ::uuid[] fix (t-paliad-036) 2026-04-25 23:43:11 +02:00
m
1f9c4d0296 fix(services): use CAST(...AS uuid[]) in visibilityPredicate (sqlx ::uuid[] bug)
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`.
2026-04-25 23:43:06 +02:00
m
5c887df5fa Merge: urgent RLS function-body fix after rename (t-paliad-036) 2026-04-25 23:38:18 +02:00