#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside "Onboard existing" and "Invite colleague". Creates both auth.users (via Supabase Admin API) and paliad.users in one click; new user is visible in dropdowns immediately and receives a paliad-branded magic-link email. - internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping. - internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route). - internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort). - internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB). - internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars). - internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject. - internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other). - internal/handlers/handlers.go: route registered behind adminGate. - cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active. - frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on). - frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur. - i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN. Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head. go build && go test -short ./internal/... + bun run build all green.
1114 lines
42 KiB
Go
1114 lines
42 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/mail"
|
|
"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.
|
|
//
|
|
// supabase + mail + baseURL are optional dependencies wired post-construction
|
|
// via SetAddUserDeps (t-paliad-223 Slice B). They power the new "Add User"
|
|
// path on /admin/team which creates an auth.users row directly and emails
|
|
// a paliad-branded welcome message. Older paths (Create / AdminCreateUser /
|
|
// AdminUpdateUser / AdminDeleteUser) do not touch these fields and stay
|
|
// runnable when supabase admin is unwired.
|
|
type UserService struct {
|
|
db *sqlx.DB
|
|
supabase *SupabaseAdminClient
|
|
mail *MailService
|
|
baseURL string
|
|
}
|
|
|
|
// NewUserService wires the service to the pool.
|
|
func NewUserService(db *sqlx.DB) *UserService {
|
|
return &UserService{db: db}
|
|
}
|
|
|
|
// SetAddUserDeps injects the dependencies needed for AdminCreateUserFull
|
|
// (t-paliad-223 Slice B). Called from cmd/server/main.go once supabase
|
|
// admin + mail services + base URL are known. Safe to omit when the
|
|
// deploy doesn't need the new "Add User" path — AdminCreateUserFull will
|
|
// return ErrSupabaseAdminUnavailable in that case.
|
|
func (s *UserService) SetAddUserDeps(supabase *SupabaseAdminClient, mail *MailService, baseURL string) {
|
|
s.supabase = supabase
|
|
s.mail = mail
|
|
s.baseURL = baseURL
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// AdminCreateFullInput is the payload for AdminCreateUserFull (t-paliad-223
|
|
// Slice B / m/paliad#49) — the "Konto direkt anlegen" path on /admin/team.
|
|
//
|
|
// Unlike AdminCreateUser this path does NOT require a pre-existing
|
|
// auth.users row: it creates that row via the Supabase Admin API before
|
|
// inserting paliad.users in the same tx. The two-step nature means an
|
|
// auth.users row may exist with no paliad.users row if the second step
|
|
// fails — recovery is via "Onboard existing".
|
|
type AdminCreateFullInput struct {
|
|
Email string `json:"email"` // required
|
|
DisplayName string `json:"display_name"` // required
|
|
Office string `json:"office"` // required, validated against offices.IsValid
|
|
JobTitle string `json:"job_title,omitempty"`
|
|
Profession string `json:"profession,omitempty"`
|
|
Lang string `json:"lang,omitempty"`
|
|
SendWelcomeMail bool `json:"send_welcome_mail"` // default-on at the handler layer
|
|
// InviterID + InviterName + InviterEmail describe the global_admin
|
|
// performing the create. Used for the welcome-email template variables
|
|
// + the system_audit_log row. Filled by the handler from auth.uid()
|
|
// before the call, NOT from the request body, so a malicious admin
|
|
// can't impersonate another inviter.
|
|
InviterID uuid.UUID `json:"-"`
|
|
InviterName string `json:"-"`
|
|
InviterEmail string `json:"-"`
|
|
}
|
|
|
|
// AdminCreateUserFull creates both an auth.users row (via Supabase Admin
|
|
// API) AND a paliad.users row in one operation. Returns the new
|
|
// paliad.users row.
|
|
//
|
|
// Two-step flow with best-effort rollback:
|
|
// 1. Validate input (email format, allowed-domain check happens at the
|
|
// handler; office + profession + lang validated here).
|
|
// 2. POST /auth/v1/admin/users → auth_id. ErrSupabaseEmailExists if taken.
|
|
// 3. INSERT paliad.users in a tx; on failure DELETE /auth/v1/admin/users/{id}
|
|
// to roll back.
|
|
// 4. system_audit_log row written (best-effort; failure logged not raised).
|
|
// 5. If SendWelcomeMail: GenerateRecoveryLink + MailService.SendTemplate
|
|
// (best-effort; the user-create succeeds regardless).
|
|
//
|
|
// Returns ErrSupabaseAdminUnavailable when SUPABASE_SERVICE_ROLE_KEY is
|
|
// unset (handler maps to 503). Returns ErrUserAlreadyOnboarded if a
|
|
// paliad.users row exists for the same email already (defensive — should
|
|
// be unreachable given step 2 catches the auth.users dup first).
|
|
func (s *UserService) AdminCreateUserFull(ctx context.Context, input AdminCreateFullInput) (*models.User, error) {
|
|
if s.supabase == nil || !s.supabase.Enabled() {
|
|
return nil, ErrSupabaseAdminUnavailable
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(input.Email))
|
|
if email == "" {
|
|
return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
|
|
}
|
|
if _, err := mail.ParseAddress(email); err != nil {
|
|
return nil, fmt.Errorf("%w: invalid email %q", ErrInvalidInput, input.Email)
|
|
}
|
|
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 := 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)
|
|
}
|
|
|
|
// Cheap pre-check on paliad.users — catches the rare case where
|
|
// paliad has a row but auth.users got swept (e.g. a Supabase support
|
|
// purge). The Admin-API call would still succeed and we'd hit a unique
|
|
// constraint on the FK in step 3.
|
|
var exists bool
|
|
if err := s.db.GetContext(ctx, &exists,
|
|
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE lower(email) = $1)`, email); err != nil {
|
|
return nil, fmt.Errorf("pre-check email: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, ErrUserAlreadyOnboarded
|
|
}
|
|
|
|
// Step 2 — auth.users via Supabase Admin API. ErrSupabaseEmailExists
|
|
// bubbles to the handler unchanged (409 with a "use Onboard existing"
|
|
// hint).
|
|
authID, err := s.supabase.CreateAuthUser(ctx, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Step 3 — paliad.users insert with rollback. The tx-rollback only
|
|
// reverts the paliad insert; the auth.users row needs an explicit
|
|
// delete because it lives in a different Postgres schema and is
|
|
// managed by Supabase's GoTrue, not our migration set.
|
|
rollbackAuth := func() {
|
|
// Detached context so a cancelled parent doesn't abort the cleanup.
|
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if delErr := s.supabase.DeleteAuthUser(cleanupCtx, authID); delErr != nil {
|
|
// Best-effort: log + leave a recoverable orphan rather than
|
|
// raising a new error.
|
|
slog.Warn("admin_create_full: rollback DeleteAuthUser failed", "auth_id", authID, "err", delErr)
|
|
}
|
|
}
|
|
|
|
if _, err := s.db.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 {
|
|
rollbackAuth()
|
|
return nil, fmt.Errorf("insert paliad.users: %w", err)
|
|
}
|
|
|
|
// Step 4 — audit row. Best-effort; an audit failure shouldn't break
|
|
// the user-create. Captured under a fresh context so the row is
|
|
// preserved even if the request context is on the verge of timing out.
|
|
auditCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
if _, err := s.db.ExecContext(auditCtx,
|
|
`INSERT INTO paliad.system_audit_log
|
|
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
|
VALUES ('user.added_by_admin', $1, $2, 'org', NULL, $3::jsonb)`,
|
|
nullableUUID(input.InviterID), input.InviterEmail,
|
|
fmt.Sprintf(`{"created_user_id":"%s","email":"%s","sent_welcome":%t}`,
|
|
authID, email, input.SendWelcomeMail),
|
|
); err != nil {
|
|
slog.Warn("admin_create_full: audit insert failed", "auth_id", authID, "err", err)
|
|
}
|
|
cancel()
|
|
|
|
// Step 5 — welcome email. Best-effort; failure logged + returned in
|
|
// the result so the admin can retry the recovery-link send separately.
|
|
if input.SendWelcomeMail {
|
|
if err := s.sendAddUserWelcome(ctx, email, lang, input); err != nil {
|
|
slog.Warn("admin_create_full: welcome mail failed", "auth_id", authID, "err", err)
|
|
// Surfaced as a non-fatal warning via the returned model's
|
|
// caller-visible side channel? For v1 we just log — the
|
|
// admin can re-send via /admin/team's "Recovery link" follow-up
|
|
// (filed as out-of-scope in design §3).
|
|
}
|
|
}
|
|
|
|
return s.GetByID(ctx, authID)
|
|
}
|
|
|
|
// sendAddUserWelcome generates the recovery link and dispatches the
|
|
// branded welcome email. Errors propagate so the caller can log them; the
|
|
// caller (AdminCreateUserFull) decides whether they're fatal.
|
|
func (s *UserService) sendAddUserWelcome(ctx context.Context, email, lang string, input AdminCreateFullInput) error {
|
|
if s.mail == nil {
|
|
return errors.New("mail service not wired")
|
|
}
|
|
link, err := s.supabase.GenerateRecoveryLink(ctx, email)
|
|
if err != nil {
|
|
return fmt.Errorf("generate recovery link: %w", err)
|
|
}
|
|
baseURL := s.baseURL
|
|
if baseURL == "" {
|
|
baseURL = "https://paliad.de"
|
|
}
|
|
return s.mail.SendTemplate(TemplateData{
|
|
To: email,
|
|
Lang: lang,
|
|
Name: EmailTemplateKeyAddUserWelcome,
|
|
Data: map[string]any{
|
|
"InviterName": input.InviterName,
|
|
"InviterEmail": input.InviterEmail,
|
|
"ToEmail": email,
|
|
"MagicLink": link,
|
|
"BaseURL": baseURL,
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|