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/paliad/internal/models" "mgit.msbls.de/m/paliad/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, 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, forum_pref, 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). // // PartnerUnitID is optional — when set, the onboarding flow inserts a // paliad.partner_unit_members row in the same tx as the user-create and // emits a 'member_added' audit event with source='onboarding'. When unset, // the user is onboarded without any partner-unit membership and an admin // must assign one later via /admin/partner-units. // // Profession (t-paliad-148) is the structured firm-tier value that drives // the approval ladder — partner / of_counsel / associate / senior_pa / // pa / paralegal. Defaults to 'associate' when empty (the most common // case for self-service signup). Admins can edit later via /admin/team. // Distinct from JobTitle which is free-text display only. type CreateUserInput struct { DisplayName string `json:"display_name"` Office string `json:"office"` JobTitle string `json:"job_title"` Profession string `json:"profession,omitempty"` PartnerUnitID *uuid.UUID `json:"partner_unit_id,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") } profession := strings.TrimSpace(input.Profession) if profession == "" { profession = ProfessionAssociate } if !IsValidProfession(profession) { return nil, fmt.Errorf("invalid profession %q", profession) } 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, profession, global_role) VALUES ($1, $2, $3, $4, $5, $6, $7)`, id, email, displayName, input.Office, jobTitle, profession, globalRole, ); err != nil { return nil, fmt.Errorf("insert user: %w", err) } // Optional initial partner-unit membership picked from the onboarding // form. RLS on partner_unit_members allows user_id = auth.uid() so this // works even after we strip superuser; the audit event records the user // as their own actor with source='onboarding' so admins can see how the // membership originated. if input.PartnerUnitID != nil { if err := insertPartnerUnitMembership(ctx, tx, *input.PartnerUnitID, id); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit create user: %w", err) } return s.GetByID(ctx, id) } // insertPartnerUnitMembership inserts a paliad.partner_unit_members row plus // a paliad.partner_unit_events audit row inside the caller's tx. Used by // onboarding (Create) — admin-driven membership writes go through // PartnerUnitService.AddMember which has its own emit. // // The user is recorded as the actor for the audit event because the // onboarding form is self-service. unitName is fetched inside the tx so // the audit row stays readable if the unit is later deleted. func insertPartnerUnitMembership(ctx context.Context, tx *sqlx.Tx, partnerUnitID, userID uuid.UUID) error { res, err := tx.ExecContext(ctx, `INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at) VALUES ($1, $2, now()) ON CONFLICT (partner_unit_id, user_id) DO NOTHING`, partnerUnitID, userID) if err != nil { return fmt.Errorf("insert partner_unit membership: %w", err) } n, _ := res.RowsAffected() if n == 0 { return nil } var unitName string if err := tx.GetContext(ctx, &unitName, `SELECT name FROM paliad.partner_units WHERE id = $1`, partnerUnitID); err != nil { return fmt.Errorf("lookup partner_unit name: %w", err) } payload := map[string]any{ "user_id": userID, "source": "onboarding", } pj, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal audit payload: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO paliad.partner_unit_events (partner_unit_id, actor_id, event_type, unit_name, payload) VALUES ($1, $2, 'member_added', $3, $4)`, partnerUnitID, userID, unitName, pj); err != nil { return fmt.Errorf("emit member_added event: %w", err) } return nil } // 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"` 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 (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"` // ForumPref is the persisted Fristenrechner inbox-channel preference // (#15). Allowed values: "cms" | "bea" | "posteingang". Empty string // clears the preference (NULL in the DB). nil = don't touch. ForumPref *string `json:"forum_pref,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.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 input.ForumPref != nil { raw := strings.TrimSpace(*input.ForumPref) var val any if raw == "" { val = nil } else { if !isValidForumPref(raw) { return nil, fmt.Errorf("invalid forum_pref %q (expected cms, bea, or posteingang)", raw) } val = raw } sets = append(sets, fmt.Sprintf("forum_pref = $%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). // // Partner-unit membership is intentionally NOT settable here; admins assign // memberships separately via /admin/partner-units after the row exists. 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' // Profession is the structured firm-tier value driving the approval // ladder (t-paliad-148). Defaults to 'associate' when empty. Distinct // from JobTitle which is a free-text display label. Use the empty // string to indicate "no firm tier" (external collaborator); the // admin form's "Extern" option submits "" here. Profession string `json:"profession,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" } // Profession may be empty to flag an external collaborator; only // non-empty values must validate against the enum. profession := strings.TrimSpace(input.Profession) if profession == "" { profession = ProfessionAssociate } if !IsValidProfession(profession) { return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, profession) } 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) } 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, profession, global_role, lang) VALUES ($1, $2, $3, $4, $5, $6, 'standard', $7)`, authID, email, displayName, input.Office, jobTitle, profession, 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"` // Profession (t-paliad-148). Empty string clears the column to NULL // (external collaborator). Any non-empty value must be one of the // recognised firm-tier values. Profession *string `json:"profession,omitempty"` GlobalRole *string `json:"global_role,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.Profession != nil { // Empty string clears the column to NULL (external). prof := strings.TrimSpace(*input.Profession) var val any if prof == "" { val = nil } else { if !IsValidProfession(prof) { return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, prof) } val = prof } sets = append(sets, fmt.Sprintf("profession = $%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.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 / partner_unit_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.partner_unit_members WHERE user_id = $1`, id); err != nil { return fmt.Errorf("delete partner_unit_members: %w", err) } // A partner unit this user led keeps existing — the lead seat just goes empty. if _, err := tx.ExecContext(ctx, `UPDATE paliad.partner_units SET lead_user_id = NULL WHERE lead_user_id = $1`, id); err != nil { return fmt.Errorf("clear partner unit 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 } // ForumPref* are the allowed values of paliad.users.forum_pref (#15). // CMS = UPC; beA / Posteingang = national-DE (Posteingang is the slower // channel for the same forums). Empty string clears the preference. const ( ForumPrefCMS = "cms" ForumPrefBeA = "bea" ForumPrefPosteingang = "posteingang" ) // isValidForumPref returns true when v is one of the allowed channel // slugs. The DB CHECK constraint mirrors this list; we validate in the // service layer so callers see a typed error instead of a raw pq // constraint violation. func isValidForumPref(v string) bool { switch v { case ForumPrefCMS, ForumPrefBeA, ForumPrefPosteingang: return true } return false } // 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 }