273 lines
8.5 KiB
Go
273 lines
8.5 KiB
Go
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
|
|
}
|