// Package user owns the platform's account identity records inside the // `backend.accounts` table together with the entitlement, sanction, // limit and soft-delete surfaces documented in `backend/PLAN.md` §5.2. // // The implementation expanded the surface introduced by currently: the package // now exposes account read/mutation flows, admin-side overrides // (sanctions, limits, entitlements), in-process soft-delete cascades // across `lobby`, `notification`, `geo`, and a write-through // entitlement-snapshot cache that mirrors the // `backend/internal/auth.Cache` pattern. // // External dependencies that have not landed yet (lobby in 5.4, // notification in 5.7) are injected through the LobbyCascade and // NotificationCascade interfaces; the package ships no-op // implementations that satisfy those contracts until the real services // arrive. package user import ( "context" "crypto/rand" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "go.uber.org/zap" ) // Constraint names mirror the names declared in // `backend/internal/postgres/migrations/00001_init.sql`. Keeping them as // constants avoids string-typo surprises at runtime when error // classification asks Postgres which UNIQUE was violated. const ( constraintAccountsEmailUnique = "accounts_email_unique" constraintAccountsUserNameUnique = "accounts_user_name_unique" ) // pgErrCodeUniqueViolation is the SQLSTATE value emitted by Postgres when // a UNIQUE constraint is violated. The pgx driver surfaces the value on // `*pgconn.PgError`. const pgErrCodeUniqueViolation = "23505" // userNameCharset is the alphabet of the placeholder `Player-XXXXXXXX` // suffix. Mixed-case letters plus digits gives 62^8 ≈ 2.18×10¹⁴ // possibilities, which makes 10 collision retries an enormous safety // margin even at MVP scale. const userNameCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // userNameSuffixLen is the length of the random suffix appended after // `Player-`. const userNameSuffixLen = 8 // Deps aggregates every collaborator the user Service depends on. // Constructing the Service through Deps (rather than positional args) // keeps wiring patches small when new dependencies are added. // // Store must be non-nil. Cache, Lobby, Notification, Geo and // SessionRevoker are tested-in-isolation interfaces; production wires // the matching real implementations through `cmd/backend/main.go`. type Deps struct { Store *Store Cache *Cache Lobby LobbyCascade Notification NotificationCascade Geo GeoCascade SessionRevoker SessionRevoker // UserNameMaxRetries caps the retry budget for synthesising a unique // placeholder `accounts.user_name` at registration. A zero or // negative value falls back to 1. UserNameMaxRetries int // Logger is named under "user" by NewService. Nil falls back to // zap.NewNop. Logger *zap.Logger // Now overrides time.Now for deterministic tests. A nil Now defaults // to time.Now in NewService. Now func() time.Time } // Service is the user-domain entry point. Concurrency safety is // delegated to Postgres for persisted state and to the embedded Cache // for the in-memory entitlement snapshot projection. type Service struct { deps Deps } // NewService constructs a Service from deps. A nil Now defaults to // time.Now; a nil Logger defaults to zap.NewNop. DB and Store must be // supplied — calling Service methods with nil values will panic at // first use, matching how main.go signals missing wiring. func NewService(deps Deps) *Service { if deps.Now == nil { deps.Now = time.Now } if deps.Logger == nil { deps.Logger = zap.NewNop() } deps.Logger = deps.Logger.Named("user") if deps.UserNameMaxRetries <= 0 { deps.UserNameMaxRetries = 1 } return &Service{deps: deps} } // EnsureByEmail returns the user_id of the live account whose email // matches the supplied (lower-cased, trimmed) value, creating a new // account if none exists. // // For new accounts the function uses the supplied "would-be" values: // preferredLanguage is written as-is, timeZone is written as-is, and // declaredCountry is written as NULL when empty. Existing accounts keep // every stored value; only their user_id is returned. // // EnsureByEmail is idempotent on email under concurrent calls. The // implementation uses ON CONFLICT (email) DO NOTHING RETURNING so a // concurrent inserter does not double-create. Synthetic user_name // collisions are retried with a fresh suffix up to UserNameMaxRetries // times. // // On a successful new-account insert the function additionally // materialises the default `free` entitlement snapshot inside the same // transaction so no account exists without a snapshot, and refreshes // the in-memory cache with the freshly persisted snapshot. func (s *Service) EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error) { normalised := strings.ToLower(strings.TrimSpace(email)) if normalised == "" { return uuid.Nil, errors.New("ensure account by email: email is empty") } if userID, ok, err := s.deps.Store.LookupAccountIDByEmail(ctx, normalised); err != nil { return uuid.Nil, fmt.Errorf("ensure account by email: lookup: %w", err) } else if ok { return userID, nil } return s.insertNew(ctx, normalised, preferredLanguage, timeZone, declaredCountry) } func (s *Service) insertNew(ctx context.Context, email, prefLang, tz, country string) (uuid.UUID, error) { for attempt := 0; attempt < s.deps.UserNameMaxRetries; attempt++ { userName, err := generatePlayerName() if err != nil { return uuid.Nil, fmt.Errorf("ensure account by email: generate user_name: %w", err) } userID := uuid.New() now := s.deps.Now().UTC() snapshot := defaultFreeSnapshot(userID, now) insertedID, err := s.deps.Store.InsertAccountWithSnapshot(ctx, accountInsert{ UserID: userID, Email: email, UserName: userName, PreferredLanguage: prefLang, TimeZone: tz, DeclaredCountry: country, }, snapshot) switch { case err == nil: s.deps.Cache.Add(snapshot) return insertedID, nil case errors.Is(err, errEmailRace): existing, ok, lerr := s.deps.Store.LookupAccountIDByEmail(ctx, email) if lerr != nil { return uuid.Nil, fmt.Errorf("ensure account by email: lookup after race: %w", lerr) } if !ok { return uuid.Nil, fmt.Errorf("ensure account by email: email exists yet lookup empty (likely soft-deleted)") } return existing, nil case isUniqueViolation(err, constraintAccountsUserNameUnique): continue default: return uuid.Nil, fmt.Errorf("ensure account by email: insert: %w", err) } } return uuid.Nil, fmt.Errorf("ensure account by email: user_name collisions exceeded %d retries", s.deps.UserNameMaxRetries) } // generatePlayerName produces a `Player-XXXXXXXX` placeholder where the // suffix is eight cryptographically-random alphanumeric characters. The // modulo-bias of `byte%62` is acceptable here: collision avoidance is // the only invariant — the placeholder never carries cryptographic // significance and a future stage may surface a separate "claim // user_name" flow. func generatePlayerName() (string, error) { suffix := make([]byte, userNameSuffixLen) if _, err := rand.Read(suffix); err != nil { return "", err } for i := range suffix { suffix[i] = userNameCharset[int(suffix[i])%len(userNameCharset)] } var sb strings.Builder sb.Grow(len("Player-") + userNameSuffixLen) sb.WriteString("Player-") sb.Write(suffix) return sb.String(), nil } func isUniqueViolation(err error, constraintName string) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } if pgErr.Code != pgErrCodeUniqueViolation { return false } if constraintName == "" { return true } return pgErr.ConstraintName == constraintName }