Files
paliad/internal/services/user_service.go
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

284 lines
9.4 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/models"
"mgit.msbls.de/m/patholo/internal/offices"
)
// Sentinel errors returned by UserService.
var (
// ErrUserAlreadyOnboarded is returned when POST /api/onboarding is called
// for a paliad.users row that already exists (409 Conflict on the wire).
ErrUserAlreadyOnboarded = errors.New("user already onboarded")
// ErrAdminBootstrapOnly signals an attempt to self-assign the 'admin' role
// when other paliad.users rows already exist. Only the very first user can
// bootstrap themselves as admin (403 Forbidden on the wire).
ErrAdminBootstrapOnly = errors.New("admin role reserved for the first user")
// ErrUserNotOnboarded is returned when an endpoint that requires an
// existing paliad.users row is called by a user who hasn't onboarded yet
// (404 Not Found on the wire — callers should redirect to /onboarding).
ErrUserNotOnboarded = errors.New("paliad.users row missing — onboarding required")
)
// UserService reads paliad.users. Writes happen via the Phase D onboarding
// endpoint and are not exposed here yet.
type UserService struct {
db *sqlx.DB
}
// NewUserService wires the service to the pool.
func NewUserService(db *sqlx.DB) *UserService {
return &UserService{db: db}
}
const userColumns = `id, email, display_name, office, practice_group, role, dezernat,
lang, email_preferences, created_at, updated_at`
// GetByID returns the user row, or (nil, nil) if the user hasn't completed
// onboarding yet. Real errors bubble up.
func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
var u models.User
err := s.db.GetContext(ctx, &u,
`SELECT `+userColumns+` FROM paliad.users WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &u, nil
}
// CreateUserInput is the payload for the onboarding flow (POST /api/onboarding).
type CreateUserInput struct {
DisplayName string `json:"display_name"`
Office string `json:"office"`
Role string `json:"role"`
Dezernat *string `json:"dezernat,omitempty"`
}
// Create inserts the paliad.users row for the authenticated user. The caller
// owns the (id, email) pair — they come from the verified JWT claims, never
// from the request body, which prevents a user from creating a row for a
// different auth.uid().
//
// Role is free-form text (German firms have many titles beyond the original
// four-value enum). The DB CHECK only requires non-empty. The one exception
// is 'admin', which is reserved: only allowed when the paliad.users table is
// empty (bootstrap admin). Subsequent users who ask for 'admin' are rejected
// — an existing admin must promote them via SQL / future admin UI.
//
// Returns ErrUserAlreadyOnboarded if the row exists (callers map to 409).
func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, input CreateUserInput) (*models.User, error) {
displayName := strings.TrimSpace(input.DisplayName)
if displayName == "" {
return nil, fmt.Errorf("display_name is required")
}
if !offices.IsValid(input.Office) {
return nil, fmt.Errorf("invalid office %q", input.Office)
}
role := strings.TrimSpace(input.Role)
if role == "" {
return nil, fmt.Errorf("role is required")
}
var dezernat *string
if input.Dezernat != nil {
trimmed := strings.TrimSpace(*input.Dezernat)
if trimmed != "" {
dezernat = &trimmed
}
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Refuse a second row for the same auth.uid(). The PRIMARY KEY would also
// catch this, but we want a typed error (not a raw pq unique-violation).
var exists bool
if err := tx.GetContext(ctx, &exists,
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, id); err != nil {
return nil, fmt.Errorf("check existing user: %w", err)
}
if exists {
return nil, ErrUserAlreadyOnboarded
}
// Admin bootstrap gate: only allow 'admin' when no other users exist yet.
// Under Postgres' default READ COMMITTED isolation, two concurrent
// first-logins both asking for 'admin' could both see count=0 and both
// succeed. A transaction-scoped advisory lock serialises the check+insert
// so only one bootstrap can win; the lock is auto-released on commit or
// rollback. The constant is arbitrary but stable — every admin-bootstrap
// tx takes the same lock.
if role == "admin" {
if _, err := tx.ExecContext(ctx,
`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
return nil, fmt.Errorf("lock for admin bootstrap: %w", err)
}
var count int
if err := tx.GetContext(ctx, &count,
`SELECT count(*) FROM paliad.users`); err != nil {
return nil, fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil, ErrAdminBootstrapOnly
}
}
// practice_group is intentionally left NULL — the column is retained for
// future use but no longer collected at onboarding (m, 2026-04-18: every
// Paliad user is in patent practice, so the field carried no signal).
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, dezernat)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, email, displayName, input.Office, role, dezernat,
); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create user: %w", err)
}
return s.GetByID(ctx, id)
}
// UpdateProfileInput is the payload for PATCH /api/me. Every field is a
// pointer so callers can omit keys they don't want to touch — the settings
// page sends only the fields the user changed. Email is deliberately absent:
// auth.users.email is the source of truth and the handler rejects any attempt
// to mutate it via this endpoint.
type UpdateProfileInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
Role *string `json:"role,omitempty"`
Dezernat *string `json:"dezernat,omitempty"`
Lang *string `json:"lang,omitempty"`
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
}
// UpdateProfile mutates the paliad.users row for the authenticated user.
// Returns the fresh row.
//
// The 'admin' role is never assignable via this endpoint — role changes
// downgrading an admin, or promoting a non-admin to admin, must go through
// SQL / a future admin UI (mirrors the onboarding restriction).
func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input UpdateProfileInput) (*models.User, error) {
sets := []string{}
args := []any{}
i := 1
if input.DisplayName != nil {
dn := strings.TrimSpace(*input.DisplayName)
if dn == "" {
return nil, fmt.Errorf("display_name cannot be empty")
}
sets = append(sets, fmt.Sprintf("display_name = $%d", i))
args = append(args, dn)
i++
}
if input.Office != nil {
if !offices.IsValid(*input.Office) {
return nil, fmt.Errorf("invalid office %q", *input.Office)
}
sets = append(sets, fmt.Sprintf("office = $%d", i))
args = append(args, *input.Office)
i++
}
if input.Role != nil {
role := strings.TrimSpace(*input.Role)
if role == "" {
return nil, fmt.Errorf("role cannot be empty")
}
if role == "admin" {
return nil, ErrAdminBootstrapOnly
}
sets = append(sets, fmt.Sprintf("role = $%d", i))
args = append(args, role)
i++
}
if input.Dezernat != nil {
trimmed := strings.TrimSpace(*input.Dezernat)
var val any
if trimmed == "" {
val = nil
} else {
val = trimmed
}
sets = append(sets, fmt.Sprintf("dezernat = $%d", i))
args = append(args, val)
i++
}
if input.Lang != nil {
lang := strings.ToLower(strings.TrimSpace(*input.Lang))
if lang != "de" && lang != "en" {
return nil, fmt.Errorf("invalid lang %q (expected de or en)", *input.Lang)
}
sets = append(sets, fmt.Sprintf("lang = $%d", i))
args = append(args, lang)
i++
}
if input.EmailPreferences != nil {
raw := *input.EmailPreferences
// Reject anything that isn't a JSON object — the column is JSONB but
// the app model is "bag of feature flags", not arbitrary scalars.
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("email_preferences must be a JSON object")
}
sets = append(sets, fmt.Sprintf("email_preferences = $%d", i))
args = append(args, []byte(raw))
i++
}
if len(sets) == 0 {
// No-op PATCH is legal — just return the current row.
return s.GetByID(ctx, id)
}
sets = append(sets, "updated_at = now()")
args = append(args, id)
query := fmt.Sprintf(
`UPDATE paliad.users SET %s WHERE id = $%d`,
strings.Join(sets, ", "), i,
)
res, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("update user: rows affected: %w", err)
}
if n == 0 {
return nil, ErrUserNotOnboarded
}
return s.GetByID(ctx, id)
}
// List returns all users (used by collaborator-picker in Phase D).
func (s *UserService) List(ctx context.Context) ([]models.User, error) {
var users []models.User
if err := s.db.SelectContext(ctx, &users,
`SELECT `+userColumns+`
FROM paliad.users
ORDER BY display_name, email`); err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
return users, nil
}