Files
paliad/internal/services/user_service.go
m 765bfe0648 feat(t-paliad-064): bundled-digest reminder service + settings UI (PR-3/4)
Replaces the per-deadline reminder model (overdue / tomorrow /
due_today_evening / weekly templates and four per-kind send paths) with
one bundled digest per (user, slot, local-date) — owner + project leads +
global_admins as audience tiers, three category sections per email.

Service rewrite (internal/services/reminder_service.go):
- RunOnce iterates users, evaluates morning/evening slot per user's tz,
  calls runSlotForUser for each match.
- runSlotForUser checks the slot+date dedup (migration 025), fetches the
  three pending-deadline categories visible to u (overdue / due_today /
  due_warning at u.reminder_warning_offset_days), composes a digest, and
  inserts the dedup row only on successful send.
- Audience filter applied per row in Go: due_warning to owner/lead,
  due_today to owner/lead (+global_admin in evening), overdue to
  owner/global_admin (NOT lead — system failure escalates past the team).
- Subject ladder: ÜBERFÄLLIG / SYSTEMAUSFALL when overdues are in the
  bundle; DRINGEND on evening when due_today still pending; "Frist-
  Erinnerung: N offen" otherwise. EN equivalents.
- Retired sendPerFrist, sendWeekly, deliverFristReminder, deliverWeekly,
  buildSubject, slotForKind, matchesLocalDueDate.

Templates:
- Added deadline_digest.html with three category sections (red/amber/
  neutral), DRINGEND wording on evening, IsOtherOwner attribution row.
- Removed deadline_reminder.html, deadline_due_today.html, deadline_weekly.html.

User schema (Go side):
- models.User gains ReminderWarningOffsetDays (int, default 7) and
  EscalationContactID (*uuid.UUID, nullable).
- userColumns SELECT updated; UpdateProfileInput accepts the new offset
  with 1..30 validation.

Settings → Notifications UI (PR-4):
- New reminder categories: overdue / due_today / due_warning. Legacy
  toggles (tomorrow, due_today_evening, weekly) removed and the legacy
  pref keys are explicitly deleted from the email_preferences object on
  next save so they don't linger.
- New "Vorwarnung (Tage vorher)" input (1..30, required), wired into the
  PATCH /api/me payload as reminder_warning_offset_days.
- Times-section copy refreshed: "Morgen-Slot" / "Abend-Slot (Eskalation)"
  with new hint text reflecting the bundled-digest model.
- DE + EN i18n strings added/updated.

Tests:
- TestCategorize, TestVisibleForCategory, TestBuildDigestSubject lock
  the boundary, recipient-rule, and subject-ladder logic.
- TestRunSlotForUser (live DB, skipped without TEST_DATABASE_URL) covers
  the morning/evening flow, slot+date dedup, and off-slot tick.
- TestRunSlotForUser_EmptyDigest enforces the no-spam rule.
- TestDeliverDigest_RendersTemplate runs the new template on the
  digestRow shape so a typo would fail before any SMTP I/O.
- TestRenderTemplateDeadlineDigest replaces the deleted reminder/weekly
  template tests.

go build/vet/test + bun run build all clean.
2026-04-28 13:17:30 +02:00

752 lines
27 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/models"
"mgit.msbls.de/m/patholo/internal/offices"
)
// normaliseTimeOfDay accepts "HH:MM" or "HH:MM:SS" (the two shapes the HTML
// time input and Postgres TIME column produce) and returns "HH:MM:SS" for
// consistent storage. Empty / out-of-range inputs are rejected.
func normaliseTimeOfDay(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", fmt.Errorf("required (HH:MM or HH:MM:SS)")
}
for _, layout := range []string{"15:04", "15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t.Format("15:04:05"), nil
}
}
return "", fmt.Errorf("invalid time %q (want HH:MM)", raw)
}
// 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")
// ErrLastGlobalAdmin guards demoting / deleting the last global_admin so
// the firm can't lock itself out of its own admin UI.
ErrLastGlobalAdmin = errors.New("cannot remove the last remaining global admin")
// ErrGlobalAdminAssignment signals a non-global-admin trying to write
// global_role through a path that doesn't permit it (e.g. /api/onboarding,
// /api/me, the create form on /admin/team). Promotion to global_admin is
// only legal via PATCH /api/admin/users/{id} from an existing global_admin
// — and the bootstrap path, where the first paliad.users row may flip
// itself.
ErrGlobalAdminAssignment = errors.New("global_admin must be granted by an existing global admin")
// 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, additional_offices, practice_group,
job_title, global_role, dezernat,
lang, email_preferences,
reminder_morning_time::text AS reminder_morning_time,
reminder_evening_time::text AS reminder_evening_time,
reminder_timezone,
reminder_warning_offset_days,
escalation_contact_id,
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"`
JobTitle string `json:"job_title"`
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().
//
// JobTitle is free-form text (Partner / Counsel / PA / Trainee / Sekretariat /
// "Counsel Knowledge Lawyer" / …). The DB CHECK only requires non-empty.
//
// global_role is decided server-side: every new row defaults to 'standard',
// EXCEPT the bootstrap path — when paliad.users is otherwise empty, the
// inserter is promoted to 'global_admin' so the firm has at least one admin.
// The pg_advisory_xact_lock serialises concurrent first-logins so only one
// can win the bootstrap; the lock auto-releases on commit/rollback.
//
// 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)
}
jobTitle := strings.TrimSpace(input.JobTitle)
if jobTitle == "" {
return nil, fmt.Errorf("job_title 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
}
// Bootstrap gate: the very first paliad.users row is promoted to
// global_admin so the firm has at least one admin from day one. Under
// Postgres' default READ COMMITTED isolation two concurrent first-logins
// could both see count=0; the advisory lock serialises the check + insert
// so only one bootstrap can win. The lock auto-releases on commit/rollback.
// The constant is arbitrary but stable — every bootstrap tx takes the
// same lock.
if _, err := tx.ExecContext(ctx,
`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
return nil, fmt.Errorf("lock for bootstrap: %w", err)
}
var existingCount int
if err := tx.GetContext(ctx, &existingCount,
`SELECT count(*) FROM paliad.users`); err != nil {
return nil, fmt.Errorf("count users: %w", err)
}
globalRole := "standard"
if existingCount == 0 {
globalRole = "global_admin"
}
// 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, job_title, global_role, dezernat)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, email, displayName, input.Office, jobTitle, globalRole, 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. global_role is also deliberately absent:
// promotion/demotion is a privileged operation that goes through
// PATCH /api/admin/users/{id}, never through self-service settings.
type UpdateProfileInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
JobTitle *string `json:"job_title,omitempty"`
Dezernat *string `json:"dezernat,omitempty"`
Lang *string `json:"lang,omitempty"`
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
ReminderMorningTime *string `json:"reminder_morning_time,omitempty"`
ReminderEveningTime *string `json:"reminder_evening_time,omitempty"`
ReminderTimezone *string `json:"reminder_timezone,omitempty"`
ReminderWarningOffsetDays *int `json:"reminder_warning_offset_days,omitempty"`
}
// UpdateProfile mutates the paliad.users row for the authenticated user.
// Returns the fresh row.
//
// global_role is intentionally NOT writable here — see UpdateProfileInput.
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.JobTitle != nil {
jt := strings.TrimSpace(*input.JobTitle)
if jt == "" {
return nil, fmt.Errorf("job_title cannot be empty")
}
sets = append(sets, fmt.Sprintf("job_title = $%d", i))
args = append(args, jt)
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 input.ReminderMorningTime != nil {
t, err := normaliseTimeOfDay(*input.ReminderMorningTime)
if err != nil {
return nil, fmt.Errorf("reminder_morning_time: %w", err)
}
sets = append(sets, fmt.Sprintf("reminder_morning_time = $%d", i))
args = append(args, t)
i++
}
if input.ReminderEveningTime != nil {
t, err := normaliseTimeOfDay(*input.ReminderEveningTime)
if err != nil {
return nil, fmt.Errorf("reminder_evening_time: %w", err)
}
sets = append(sets, fmt.Sprintf("reminder_evening_time = $%d", i))
args = append(args, t)
i++
}
if input.ReminderTimezone != nil {
tz := strings.TrimSpace(*input.ReminderTimezone)
if _, err := time.LoadLocation(tz); err != nil {
return nil, fmt.Errorf("invalid reminder_timezone %q", tz)
}
sets = append(sets, fmt.Sprintf("reminder_timezone = $%d", i))
args = append(args, tz)
i++
}
if input.ReminderWarningOffsetDays != nil {
days := *input.ReminderWarningOffsetDays
if days < 1 || days > 30 {
return nil, fmt.Errorf("reminder_warning_offset_days must be between 1 and 30")
}
sets = append(sets, fmt.Sprintf("reminder_warning_offset_days = $%d", i))
args = append(args, days)
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
}
// IsAdmin reports whether the given user has the global_admin permission.
// Implements auth.AdminLookup so the requireAdmin middleware can stay in
// package auth without importing services. Returns (false, nil) for an
// unknown / unonboarded user — a missing paliad.users row is not an admin.
func (s *UserService) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
u, err := s.GetByID(ctx, id)
if err != nil {
return false, err
}
return u != nil && u.GlobalRole == "global_admin", nil
}
// AdminCreateInput is the payload an admin uses to onboard a colleague who
// already exists in auth.users. Email is required (must already be in
// auth.users with an allowed domain — both checks happen in AdminCreateUser).
type AdminCreateInput struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Office string `json:"office"`
JobTitle string `json:"job_title,omitempty"` // defaults to 'Associate'
Dezernat *string `json:"dezernat,omitempty"`
Lang string `json:"lang,omitempty"` // defaults to 'de'
}
// AdminCreateUser inserts a paliad.users row for an auth.users entry that has
// not yet onboarded. Used by the admin team-management page to bulk-onboard
// real colleagues without forcing each one through the self-service flow.
//
// Returns ErrUserAlreadyOnboarded if a paliad.users row already exists for
// the given email's auth.users id. Returns a wrapped ErrInvalidInput when the
// email isn't in auth.users at all (so the handler can map to 404).
//
// global_role is always 'standard' on this path. Promotion to global_admin
// is a separate AdminUpdateUser call so it can't be smuggled into create.
func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInput) (*models.User, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" {
return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
}
displayName := strings.TrimSpace(input.DisplayName)
if displayName == "" {
return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
}
if !offices.IsValid(input.Office) {
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
}
jobTitle := strings.TrimSpace(input.JobTitle)
if jobTitle == "" {
jobTitle = "Associate"
}
lang := strings.ToLower(strings.TrimSpace(input.Lang))
if lang == "" {
lang = "de"
}
if lang != "de" && lang != "en" {
return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang)
}
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()
// Look up the auth.users.id for the requested email. Admins bulk-onboard
// colleagues who have already signed in — if the email isn't in auth.users
// the right path is to invite them, not create a half-attached profile.
var authID uuid.UUID
if err := tx.GetContext(ctx, &authID,
`SELECT id FROM auth.users WHERE lower(email) = $1`, email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: email %q is not in auth.users — invite first", ErrInvalidInput, email)
}
return nil, fmt.Errorf("lookup auth.users: %w", err)
}
// Refuse a second paliad.users row for the same auth.uid().
var exists bool
if err := tx.GetContext(ctx, &exists,
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, authID); err != nil {
return nil, fmt.Errorf("check existing user: %w", err)
}
if exists {
return nil, ErrUserAlreadyOnboarded
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat, lang)
VALUES ($1, $2, $3, $4, $5, 'standard', $6, $7)`,
authID, email, displayName, input.Office, jobTitle, dezernat, lang,
); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit admin create user: %w", err)
}
return s.GetByID(ctx, authID)
}
// AdminUpdateInput is the payload for AdminUpdateUser. Same shape as
// UpdateProfileInput but additionally allows the additional_offices array
// (which the self-service settings page does not expose).
type AdminUpdateInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
JobTitle *string `json:"job_title,omitempty"`
GlobalRole *string `json:"global_role,omitempty"`
Dezernat *string `json:"dezernat,omitempty"`
AdditionalOffices *[]string `json:"additional_offices,omitempty"`
Lang *string `json:"lang,omitempty"`
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
ReminderMorningTime *string `json:"reminder_morning_time,omitempty"`
ReminderEveningTime *string `json:"reminder_evening_time,omitempty"`
ReminderTimezone *string `json:"reminder_timezone,omitempty"`
}
// AdminUpdateUser mutates any paliad.users row. Same validation rules as
// UpdateProfile, plus: AdminUpdate may write additional_offices and
// global_role (the privileged fields that self-service must not touch).
// Returns ErrUserNotOnboarded when the target row is missing. Returns
// ErrLastGlobalAdmin when the call would demote the last global_admin.
//
// Note: this method assumes the caller already passed
// auth.RequireAdmin — the handler enforces "only existing global admins
// may call this endpoint". The last-admin guard runs unconditionally here
// regardless, as a belt-and-braces safety net.
//
// JobTitle of "" (empty after trim) clears job_title to NULL — admins
// without a real job title legitimately store NULL.
func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input AdminUpdateInput) (*models.User, error) {
sets := []string{}
args := []any{}
i := 1
if input.DisplayName != nil {
dn := strings.TrimSpace(*input.DisplayName)
if dn == "" {
return nil, fmt.Errorf("%w: display_name cannot be empty", ErrInvalidInput)
}
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("%w: invalid office %q", ErrInvalidInput, *input.Office)
}
sets = append(sets, fmt.Sprintf("office = $%d", i))
args = append(args, *input.Office)
i++
}
if input.JobTitle != nil {
jt := strings.TrimSpace(*input.JobTitle)
var val any
if jt == "" {
val = nil
} else {
val = jt
}
sets = append(sets, fmt.Sprintf("job_title = $%d", i))
args = append(args, val)
i++
}
if input.GlobalRole != nil {
gr := strings.TrimSpace(*input.GlobalRole)
if gr != "standard" && gr != "global_admin" {
return nil, fmt.Errorf("%w: invalid global_role %q (expected 'standard' or 'global_admin')", ErrInvalidInput, gr)
}
// Last-admin guard: refuse to demote the only remaining global_admin
// so the firm can't lock itself out of /admin/team.
if gr == "standard" {
var current string
if err := s.db.GetContext(ctx, &current,
`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotOnboarded
}
return nil, fmt.Errorf("lookup global_role: %w", err)
}
if current == "global_admin" {
var others int
if err := s.db.GetContext(ctx, &others,
`SELECT count(*) FROM paliad.users
WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
return nil, fmt.Errorf("count admins: %w", err)
}
if others == 0 {
return nil, ErrLastGlobalAdmin
}
}
}
sets = append(sets, fmt.Sprintf("global_role = $%d", i))
args = append(args, gr)
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.AdditionalOffices != nil {
// Validate each key against the canonical office list. A typo here
// would silently break the /team filter pills for that user.
clean := make([]string, 0, len(*input.AdditionalOffices))
seen := map[string]bool{}
for _, k := range *input.AdditionalOffices {
k = strings.TrimSpace(k)
if k == "" || seen[k] {
continue
}
if !offices.IsValid(k) {
return nil, fmt.Errorf("%w: invalid additional office %q", ErrInvalidInput, k)
}
seen[k] = true
clean = append(clean, k)
}
sets = append(sets, fmt.Sprintf("additional_offices = $%d", i))
args = append(args, pq.StringArray(clean))
i++
}
if input.Lang != nil {
lang := strings.ToLower(strings.TrimSpace(*input.Lang))
if lang != "de" && lang != "en" {
return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, *input.Lang)
}
sets = append(sets, fmt.Sprintf("lang = $%d", i))
args = append(args, lang)
i++
}
if input.EmailPreferences != nil {
raw := *input.EmailPreferences
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("%w: email_preferences must be a JSON object", ErrInvalidInput)
}
sets = append(sets, fmt.Sprintf("email_preferences = $%d", i))
args = append(args, []byte(raw))
i++
}
if input.ReminderMorningTime != nil {
t, err := normaliseTimeOfDay(*input.ReminderMorningTime)
if err != nil {
return nil, fmt.Errorf("%w: reminder_morning_time: %v", ErrInvalidInput, err)
}
sets = append(sets, fmt.Sprintf("reminder_morning_time = $%d", i))
args = append(args, t)
i++
}
if input.ReminderEveningTime != nil {
t, err := normaliseTimeOfDay(*input.ReminderEveningTime)
if err != nil {
return nil, fmt.Errorf("%w: reminder_evening_time: %v", ErrInvalidInput, err)
}
sets = append(sets, fmt.Sprintf("reminder_evening_time = $%d", i))
args = append(args, t)
i++
}
if input.ReminderTimezone != nil {
tz := strings.TrimSpace(*input.ReminderTimezone)
if _, err := time.LoadLocation(tz); err != nil {
return nil, fmt.Errorf("%w: invalid reminder_timezone %q", ErrInvalidInput, tz)
}
sets = append(sets, fmt.Sprintf("reminder_timezone = $%d", i))
args = append(args, tz)
i++
}
if len(sets) == 0 {
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)
}
// AdminDeleteUser removes the paliad.users row for the given user and any
// project_teams / department_members rows pointing at the same auth.users.id.
// auth.users itself is left intact: Supabase identity is not the admin's to
// destroy, and the user can re-onboard later if they need to.
//
// Why explicit DELETEs instead of leaning on FK CASCADE: the cascade is from
// auth.users → paliad.*; leaving auth.users alone means the cascade never
// fires. We do the membership cleanup manually so the admin gets a single
// transactional "user is gone from this product" outcome.
//
// Returns ErrUserNotOnboarded when the row is missing (already gone).
// Refuses to delete the last remaining admin so the firm doesn't lock itself
// out of its own admin UI.
func (s *UserService) AdminDeleteUser(ctx context.Context, id uuid.UUID) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var globalRole string
if err := tx.GetContext(ctx, &globalRole,
`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUserNotOnboarded
}
return fmt.Errorf("lookup user: %w", err)
}
if globalRole == "global_admin" {
var others int
if err := tx.GetContext(ctx, &others,
`SELECT count(*) FROM paliad.users WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
return fmt.Errorf("count admins: %w", err)
}
if others == 0 {
return ErrLastGlobalAdmin
}
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.project_teams WHERE user_id = $1`, id); err != nil {
return fmt.Errorf("delete project_teams: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.department_members WHERE user_id = $1`, id); err != nil {
return fmt.Errorf("delete department_members: %w", err)
}
// A Department this user led keeps existing — the lead seat just goes empty.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.departments SET lead_user_id = NULL WHERE lead_user_id = $1`, id); err != nil {
return fmt.Errorf("clear dept leads: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete user: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete user: %w", err)
}
return nil
}
// UnonboardedAuthUser is one row in auth.users that has no matching
// paliad.users entry — i.e. the user logged in once via Supabase but never
// completed onboarding. Surfaced to admins so they can bulk-add real
// colleagues without chasing each one to fill in the form themselves.
type UnonboardedAuthUser struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ListUnonboardedAuthUsers returns auth.users rows with no paliad.users row.
// Sorted oldest-first so the longest-pending colleagues bubble to the top of
// the admin's "direct add" dropdown.
func (s *UserService) ListUnonboardedAuthUsers(ctx context.Context) ([]UnonboardedAuthUser, error) {
rows := []UnonboardedAuthUser{}
err := s.db.SelectContext(ctx, &rows,
`SELECT a.id, lower(a.email) AS email, a.created_at
FROM auth.users a
LEFT JOIN paliad.users p ON p.id = a.id
WHERE p.id IS NULL
AND a.email IS NOT NULL
AND a.email <> ''
ORDER BY a.created_at ASC`)
if err != nil {
return nil, fmt.Errorf("list unonboarded: %w", err)
}
return rows, nil
}