Files
galaxy-game/backend/internal/user/account.go
T
2026-05-07 00:58:53 +03:00

289 lines
9.1 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
// one of "user", "admin", "system" in MVP. ID carries a user UUID for
// Type=="user", an admin username for Type=="admin", and is empty for
// Type=="system".
type ActorRef struct {
Type string
ID string
}
// Validate rejects empty actor types and enforces the per-type shape
// of ID: a user actor requires a UUID id, a system actor must have an
// empty id. Other types pass through with no further check.
func (a ActorRef) Validate() error {
t := strings.TrimSpace(a.Type)
if t == "" {
return ErrInvalidActor
}
switch t {
case "user":
if strings.TrimSpace(a.ID) == "" {
return fmt.Errorf("%w: user actor requires id", ErrInvalidActor)
}
if _, err := uuid.Parse(a.ID); err != nil {
return fmt.Errorf("%w: user actor id must be a uuid: %v", ErrInvalidActor, err)
}
case "system":
if strings.TrimSpace(a.ID) != "" {
return fmt.Errorf("%w: system actor must have an empty id", 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
}