Exposes paliad.users.escalation_contact_id (added in migration 025) via
the Benachrichtigungen tab so users can route DRINGEND/overdue
escalation to a specific colleague instead of the global_admins
fallback.
Service:
- UpdateProfileInput.EscalationContactID *string (empty = clear, matches
Dezernat tri-state pattern). Server-side validation rejects self-
pointer (also enforced by CHECK in migration 025) and unknown UUIDs.
Reminder read path:
- digestRow now carries owner.escalation_contact_id and the audience
predicate adds the override. visibleForCategory's "global admin"
branch suppresses when an override is set, so escalation does not
fan out to the whole admin team. Test table extended with override
cases (escalation contact sees overdue / DRINGEND, admin suppressed).
UI / client:
- New "Eskalations-Kontakt" section under Benachrichtigungen with a
select populated from /api/users (excluding self, sorted by name).
First option is the default-fallback marker; selecting it clears.
- savePrefs PATCHes escalation_contact_id alongside the existing
reminder fields.
i18n: einstellungen.prefs.escalation.{heading,hint,default_option}
in DE + EN.
docs/project-status.md: flips the open follow-up to "shipped".
791 lines
28 KiB
Go
791 lines
28 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"`
|
|
// EscalationContactID overrides the DRINGEND/overdue escalation channel:
|
|
// when non-NULL, the named user replaces the global_admins fallback for
|
|
// this user's deadlines. Empty string clears (back to fallback). nil =
|
|
// don't touch — matches the Dezernat tri-state pattern in the same file
|
|
// (a UUID and "no override" are different types, so we encode the clear
|
|
// signal as "" rather than juggling JSON null / missing semantics).
|
|
EscalationContactID *string `json:"escalation_contact_id,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 input.EscalationContactID != nil {
|
|
raw := strings.TrimSpace(*input.EscalationContactID)
|
|
var val any
|
|
if raw == "" {
|
|
val = nil
|
|
} else {
|
|
target, err := uuid.Parse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid escalation_contact_id: %w", err)
|
|
}
|
|
// Self-pointer is silly: routing your own escalations back to
|
|
// yourself doesn't escalate anywhere. The DB CHECK constraint
|
|
// users_escalation_contact_self_check (migration 025) enforces
|
|
// this too, but we want a typed message instead of a raw
|
|
// pq violation.
|
|
if target == id {
|
|
return nil, fmt.Errorf("cannot set yourself as escalation contact")
|
|
}
|
|
var exists bool
|
|
if err := s.db.GetContext(ctx, &exists,
|
|
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, target); err != nil {
|
|
return nil, fmt.Errorf("verify escalation contact: %w", err)
|
|
}
|
|
if !exists {
|
|
return nil, fmt.Errorf("escalation contact user not found")
|
|
}
|
|
val = target
|
|
}
|
|
sets = append(sets, fmt.Sprintf("escalation_contact_id = $%d", i))
|
|
args = append(args, val)
|
|
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, ¤t,
|
|
`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
|
|
}
|