- 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.
700 lines
24 KiB
Go
700 lines
24 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")
|
|
// 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, additional_offices, practice_group, role, dezernat,
|
|
lang, email_preferences,
|
|
reminder_morning_time::text AS reminder_morning_time,
|
|
reminder_evening_time::text AS reminder_evening_time,
|
|
reminder_timezone,
|
|
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"`
|
|
ReminderMorningTime *string `json:"reminder_morning_time,omitempty"`
|
|
ReminderEveningTime *string `json:"reminder_evening_time,omitempty"`
|
|
ReminderTimezone *string `json:"reminder_timezone,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 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 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 is an admin. Implements
|
|
// auth.AdminLookup so the requireAdmin middleware can stay in package auth
|
|
// without importing services. Returns (false, nil) for an unknown / unonboarded
|
|
// user — which is what we want: 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.Role == "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"`
|
|
Role string `json:"role,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).
|
|
//
|
|
// 'admin' is rejected here for the same reason it's rejected in UpdateProfile:
|
|
// promotions to admin must go through SQL, not the admin UI. This keeps the
|
|
// blast radius of an admin's leaked session contained.
|
|
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)
|
|
}
|
|
role := strings.TrimSpace(input.Role)
|
|
if role == "" {
|
|
role = "associate"
|
|
}
|
|
if role == "admin" {
|
|
return nil, ErrAdminBootstrapOnly
|
|
}
|
|
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, role, dezernat, lang)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
authID, email, displayName, input.Office, role, 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"`
|
|
Role *string `json:"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 (no role='admin' assignment, valid office/lang/timezone).
|
|
// Returns ErrUserNotOnboarded when the target row is missing.
|
|
//
|
|
// Why a separate method instead of routing through UpdateProfile: the self
|
|
// service path is intentionally narrow (the user touches their own row), and
|
|
// AdminUpdate also writes additional_offices, which we do not want exposed on
|
|
// PATCH /api/me.
|
|
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.Role != nil {
|
|
role := strings.TrimSpace(*input.Role)
|
|
if role == "" {
|
|
return nil, fmt.Errorf("%w: role cannot be empty", ErrInvalidInput)
|
|
}
|
|
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.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 role string
|
|
if err := tx.GetContext(ctx, &role,
|
|
`SELECT 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 role == "admin" {
|
|
var others int
|
|
if err := tx.GetContext(ctx, &others,
|
|
`SELECT count(*) FROM paliad.users WHERE role = 'admin' AND id <> $1`, id); err != nil {
|
|
return fmt.Errorf("count admins: %w", err)
|
|
}
|
|
if others == 0 {
|
|
return fmt.Errorf("%w: cannot delete the last remaining admin", ErrInvalidInput)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|