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.
This commit is contained in:
m
2026-04-20 13:17:24 +02:00
parent e76056cfd1
commit 5fb55164b3
18 changed files with 1271 additions and 415 deletions

View File

@@ -22,11 +22,18 @@ type User struct {
Role string `db:"role" json:"role"`
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
// Lang is the preferred UI language for transactional email ("de"/"en").
// NULL → MailService falls back to German. Not collected at onboarding
// today; reserved for a future profile-edit screen.
Lang *string `db:"lang" json:"lang,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// NOT NULL (migration 017) with DB default 'de'; the settings page lets
// every user flip it.
Lang string `db:"lang" json:"lang"`
// EmailPreferences is an opaque JSONB bag. Well-known keys today:
// deadline_reminders (bool, default true if missing)
// deadline_reminders.overdue (bool, default true)
// deadline_reminders.tomorrow (bool, default true)
// deadline_reminders.weekly (bool, default true)
// Missing key = opt-in, matching the pre-settings-page default.
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Akte is a matter (case file). Office-scoped visibility: see paliad.can_see_akte.