package user import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" ) // ActorRef identifies the principal that produced an audit-bearing // mutation. The wire shape mirrors the OpenAPI ActorRef schema. Type is // a free-form string ("user", "admin", "system" in MVP); ID is opaque // (a user UUID, an admin username, or empty for system). type ActorRef struct { Type string ID string } // Validate rejects empty actor types. Admin handlers always populate // Type; user-side mutations supply Type internally. func (a ActorRef) Validate() error { if strings.TrimSpace(a.Type) == "" { return ErrInvalidActor } return nil } // Account is the read-side aggregate served by GetAccount and the // admin/internal account fetches. It mirrors the OpenAPI `Account` // schema; handlers convert it to the JSON wire shape. type Account struct { UserID uuid.UUID Email string UserName string DisplayName string PreferredLanguage string TimeZone string DeclaredCountry string PermanentBlock bool Entitlement EntitlementSnapshot ActiveSanctions []ActiveSanction ActiveLimits []ActiveLimit CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time } // AccountPage is the paged listing returned by ListAccounts. type AccountPage struct { Items []Account Page int PageSize int Total int } // UpdateProfileInput carries the mutable profile fields exposed by // `PATCH /api/v1/user/account/profile`. The pointer fields keep the // "unspecified" / "explicit empty" distinction so a request that omits // `display_name` does not clear the stored value. type UpdateProfileInput struct { DisplayName *string } // UpdateSettingsInput carries the mutable settings fields exposed by // `PATCH /api/v1/user/account/settings`. type UpdateSettingsInput struct { PreferredLanguage *string TimeZone *string } // GetAccount loads the account aggregate for userID. Returns // ErrAccountNotFound when the row is missing or has been soft-deleted. // // The entitlement snapshot is read through the in-memory cache when // available, falling back to Postgres when the cache is cold (Warm not // yet completed for a freshly-restarted process). Sanctions and limits // are always read from Postgres. func (s *Service) GetAccount(ctx context.Context, userID uuid.UUID) (Account, error) { if userID == uuid.Nil { return Account{}, ErrAccountNotFound } row, err := s.deps.Store.LookupAccount(ctx, userID) if err != nil { if errors.Is(err, ErrAccountNotFound) { return Account{}, err } return Account{}, fmt.Errorf("user get account: %w", err) } snapshot, err := s.lookupSnapshot(ctx, userID) if err != nil { return Account{}, fmt.Errorf("user get account: snapshot: %w", err) } sanctions, err := s.deps.Store.ListActiveSanctions(ctx, userID) if err != nil { return Account{}, fmt.Errorf("user get account: sanctions: %w", err) } limits, err := s.deps.Store.ListActiveLimits(ctx, userID) if err != nil { return Account{}, fmt.Errorf("user get account: limits: %w", err) } return assembleAccount(row, snapshot, sanctions, limits), nil } // ListAccounts returns a paged listing of live accounts ordered by // `created_at DESC, user_id DESC`. Soft-deleted rows are excluded. func (s *Service) ListAccounts(ctx context.Context, page, pageSize int) (AccountPage, error) { page, pageSize = normalisePaging(page, pageSize) rows, total, err := s.deps.Store.ListAccountRows(ctx, page, pageSize) if err != nil { return AccountPage{}, fmt.Errorf("user list accounts: %w", err) } out := AccountPage{ Items: make([]Account, 0, len(rows)), Page: page, PageSize: pageSize, Total: total, } for _, row := range rows { snapshot, err := s.lookupSnapshot(ctx, row.UserID) if err != nil { return AccountPage{}, fmt.Errorf("user list accounts: snapshot for %s: %w", row.UserID, err) } sanctions, err := s.deps.Store.ListActiveSanctions(ctx, row.UserID) if err != nil { return AccountPage{}, fmt.Errorf("user list accounts: sanctions for %s: %w", row.UserID, err) } limits, err := s.deps.Store.ListActiveLimits(ctx, row.UserID) if err != nil { return AccountPage{}, fmt.Errorf("user list accounts: limits for %s: %w", row.UserID, err) } out.Items = append(out.Items, assembleAccount(row, snapshot, sanctions, limits)) } return out, nil } // ResolveByEmail returns the user_id of the live account whose email // matches the supplied (lower-cased, trimmed) value. Returns // ErrAccountNotFound when no live row exists; soft-deleted rows are // excluded. func (s *Service) ResolveByEmail(ctx context.Context, email string) (uuid.UUID, error) { normalised := strings.ToLower(strings.TrimSpace(email)) if normalised == "" { return uuid.Nil, ErrInvalidInput } id, ok, err := s.deps.Store.LookupAccountIDByEmail(ctx, normalised) if err != nil { return uuid.Nil, fmt.Errorf("user resolve by email: %w", err) } if !ok { return uuid.Nil, ErrAccountNotFound } return id, nil } // UpdateProfile patches the caller's mutable profile fields and // returns the refreshed account aggregate. func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, input UpdateProfileInput) (Account, error) { if userID == uuid.Nil { return Account{}, ErrAccountNotFound } if input.DisplayName != nil { // PATCH semantics: omitted fields are not touched. An explicit // empty value is allowed and clears the stored display name — // matching the OpenAPI description of UpdateProfileRequest. if err := s.deps.Store.UpdateAccountDisplayName(ctx, userID, *input.DisplayName, s.deps.Now().UTC()); err != nil { if errors.Is(err, ErrAccountNotFound) { return Account{}, err } return Account{}, fmt.Errorf("user update profile: %w", err) } } return s.GetAccount(ctx, userID) } // UpdateSettings patches the caller's mutable settings fields and // returns the refreshed account aggregate. func (s *Service) UpdateSettings(ctx context.Context, userID uuid.UUID, input UpdateSettingsInput) (Account, error) { if userID == uuid.Nil { return Account{}, ErrAccountNotFound } patch := settingsPatch{} if input.PreferredLanguage != nil { trimmed := strings.TrimSpace(*input.PreferredLanguage) if trimmed == "" { return Account{}, fmt.Errorf("%w: preferred_language must be non-empty", ErrInvalidInput) } patch.PreferredLanguage = &trimmed } if input.TimeZone != nil { trimmed := strings.TrimSpace(*input.TimeZone) if trimmed == "" { return Account{}, fmt.Errorf("%w: time_zone must be non-empty", ErrInvalidInput) } if _, err := time.LoadLocation(trimmed); err != nil { return Account{}, fmt.Errorf("%w: time_zone must be a valid IANA zone", ErrInvalidInput) } patch.TimeZone = &trimmed } if patch.empty() { return s.GetAccount(ctx, userID) } if err := s.deps.Store.UpdateAccountSettings(ctx, userID, patch, s.deps.Now().UTC()); err != nil { if errors.Is(err, ErrAccountNotFound) { return Account{}, err } return Account{}, fmt.Errorf("user update settings: %w", err) } return s.GetAccount(ctx, userID) } // lookupSnapshot consults the cache first and falls back to a direct // Postgres read when the cache is cold. The cache miss is silent: the // `Ready()` flag governs the readiness probe, not the live path. func (s *Service) lookupSnapshot(ctx context.Context, userID uuid.UUID) (EntitlementSnapshot, error) { if cached, ok := s.deps.Cache.Get(userID); ok { return cached, nil } snap, err := s.deps.Store.LookupEntitlementSnapshot(ctx, userID) if err != nil { return EntitlementSnapshot{}, err } s.deps.Cache.Add(snap) return snap, nil } // GetEntitlementSnapshot returns the latest entitlement snapshot for // userID through the cache-first read path. Used by the lobby package // to evaluate the per-user `max_registered_race_names` // quota at race-name registration time. func (s *Service) GetEntitlementSnapshot(ctx context.Context, userID uuid.UUID) (EntitlementSnapshot, error) { return s.lookupSnapshot(ctx, userID) } func assembleAccount(row AccountRow, snapshot EntitlementSnapshot, sanctions []ActiveSanction, limits []ActiveLimit) Account { return Account{ UserID: row.UserID, Email: row.Email, UserName: row.UserName, DisplayName: row.DisplayName, PreferredLanguage: row.PreferredLanguage, TimeZone: row.TimeZone, DeclaredCountry: row.DeclaredCountry, PermanentBlock: row.PermanentBlock, Entitlement: snapshot, ActiveSanctions: sanctions, ActiveLimits: limits, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, DeletedAt: row.DeletedAt, } } func normalisePaging(page, pageSize int) (int, int) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 50 } if pageSize > 200 { pageSize = 200 } return page, pageSize }