feat: backend service
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user