Commit Graph

236 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
83d5973dd6 fix(sidebar): omit changelog badge for anon visitors + clarify CLAUDE.md auth gate (t-paliad-035)
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.
2026-04-25 23:09:36 +02:00
m
c4e6d0eeef Merge: team directory browse (t-paliad-029) 2026-04-25 13:25:42 +02:00
m
28d747e656 feat(team): browsable team directory grouped by office or department (t-paliad-029)
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.
2026-04-25 13:22:17 +02:00
m
aafbfbbf12 feat(projects): interactive tree view of project hierarchy (t-paliad-028)
- 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).
2026-04-25 13:22:16 +02:00
m
34194aedd5 fix(rename): align TSX element IDs, REST endpoints, and migration 020 with English rename
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.
2026-04-23 01:00:31 +02:00
m
01de3f736b fix: update all script src references from German to English filenames
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.
2026-04-23 00:32:22 +02:00
m
544149114c fix: resolve leftover merge conflict markers in sidebar.ts 2026-04-23 00:24:13 +02:00
m
9705290f3d Merge: Agenda — upcoming deadlines + appointments timeline
# Conflicts:
#	frontend/src/styles/global.css
2026-04-23 00:04:37 +02:00
m
f25113abe0 Merge: What's New changelog with sidebar badge
# Conflicts:
#	frontend/src/client/sidebar.ts
#	frontend/src/components/Sidebar.tsx
#	frontend/src/styles/global.css
2026-04-23 00:04:22 +02:00
m
0d6c58a337 feat(agenda): unified timeline of deadlines + appointments across projects
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.
2026-04-22 23:38:03 +02:00
m
9bb9f0c3df feat(search): global search across projects, deadlines, appointments, glossary, courts, checklists, links, users
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.
2026-04-22 23:36:10 +02:00
m
94e2fc0024 feat(changelog): What's New page with sidebar badge
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
2026-04-22 23:34:52 +02:00
m
caf319e7ee refactor(rename): frontend TSX + client TS files, fetch URLs, nav hrefs
t-paliad-025 Phase 3 — frontend rename pass:

File renames (git mv, preserving history):
  frontend/src/
    akten.tsx               → projects.tsx
    akten-neu.tsx           → projects-new.tsx
    akten-detail.tsx        → projects-detail.tsx
    fristen.tsx             → deadlines.tsx
    fristen-neu.tsx         → deadlines-new.tsx
    fristen-detail.tsx      → deadlines-detail.tsx
    fristen-kalender.tsx    → deadlines-calendar.tsx
    termine.tsx             → appointments.tsx
    termine-neu.tsx         → appointments-new.tsx
    termine-detail.tsx      → appointments-detail.tsx
    termine-kalender.tsx    → appointments-calendar.tsx
    einstellungen.tsx       → settings.tsx
    checklisten*.tsx        → checklists*.tsx
    gerichte.tsx            → courts.tsx
    glossar.tsx             → glossary.tsx

  frontend/src/client/ — same renames, plus notizen.ts → notes.ts.

Render exports renamed (renderAkten → renderProjects, renderFristen →
renderDeadlines, …). build.ts rewired to new names.

Client-side changes:
* fetch() API paths: /api/projekte → /api/projects, /api/fristen →
  /api/deadlines, /api/termine → /api/appointments, /api/notizen →
  /api/notes, /api/gerichte → /api/courts, /api/glossar → /api/glossary,
  /api/dezernate → /api/departments, /api/parteien → /api/parties,
  /api/checklisten → /api/checklists. Legacy /api/akten aliases removed.
* Navigation href/template strings: /akten → /projects, /fristen →
  /deadlines, /termine → /appointments, /einstellungen → /settings,
  /notizen → /notes, /checklisten → /checklists, /gerichte → /courts,
  /glossar → /glossary. Nested paths /neu → /new, /verlauf → /events,
  /kinder → /children, /kalender → /calendar, /dokumente → /documents.
* Interface names in client TS: Frist → Deadline, Termin → Appointment,
  Notiz → Note, Partei → Party, Akte → Project, ProjektMini → ProjectMini,
  Dezernat → Department, DezernatMitglied → DepartmentMember.
* JSON wire-format keys follow backend: projekt_id → project_id, akte_id
  → project_id, frist_id → deadline_id, termin_id → appointment_id,
  akten_event_id → project_event_id, dezernat_id → department_id,
  termin_type → appointment_type.

Go handlers (projects_pages.go, deadlines_pages.go, appointments_pages.go,
checklists.go, courts.go, glossary.go) serve the correctly-named HTML
files from dist/.

Kept German (user-facing i18n + product names):
* i18n keys/strings (src/client/i18n.ts) — DE labels and their keys
* Product names: fristenrechner, kostenrechner, gebuehrentabellen

Build verified: go build / vet / test clean; bun run build clean;
dist/ contains all 26 English-named HTML pages.
2026-04-20 17:44:45 +02:00
m
79889a2b83 Merge: remove Billing-Referenz UI + add Notizen (description) field 2026-04-20 17:17:18 +02:00
m
bde4b57099 feat: remove billing reference UI + add Notizen (description) field at every level
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.
2026-04-20 17:14:11 +02:00
m
ff1c5ceb0e fix(checklisten): consistent button sizing — Feedback uses outline variant of btn-cta-lime 2026-04-20 17:07:08 +02:00
m
59e1cb1445 Merge: i18n fallback fix + missing projekte.* translations 2026-04-20 17:06:38 +02:00
m
449075deaf fix(i18n): preserve default HTML text when key missing + add all projekte.* keys
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.
2026-04-20 17:06:21 +02:00
m
adb0ce2c9d fix(modals): add padding to .modal-card — content no longer flush to edges 2026-04-20 17:05:53 +02:00
m
7e0c06342b feat: projekte-detail rewrite + tree view + per-Dezernat member manager (follow-ups)
**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.
2026-04-20 15:35:01 +02:00
m
41cc295500 feat: Dezernate settings tab + best-effort seeding migration (Phase 4)
- 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.
2026-04-20 15:12:24 +02:00
m
640d5c1a23 feat: frontend v2 — Projekte list/create, dashboard + downstream field renames
- 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.
2026-04-20 15:09:22 +02:00
m
4ac9dacaa0 feat: /projekte routes + sidebar label + legacy POST shim (Phase 3 partial)
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.
2026-04-20 14:55:06 +02:00
m
5fb55164b3 feat: settings page — profile, email preferences, CalDAV as tabs (t-paliad-022)
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.
2026-04-20 13:17:24 +02:00
m
11217f7bfa feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)
- 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.
2026-04-20 12:34:38 +02:00
m
7c44bbec7e refactor: onboarding form — drop Praxisgruppe, free-text role, add Dezernat (t-paliad-020)
- 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.
2026-04-18 20:26:11 +02:00
m
b8f95f5d7a feat: user onboarding flow — first-login profile capture (t-paliad-019)
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.
2026-04-18 19:13:57 +02:00
m
0cdc644b50 fix: audit medium items — Verlauf pagination, patholo→paliad rename, offices (t-paliad-018)
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.
2026-04-18 18:56:35 +02:00
m
67cd66e054 fix: audit quick wins — important + polish batch (t-paliad-017)
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.
2026-04-18 09:14:43 +02:00