// Package account owns durable internal accounts and their platform/email // identities. First contact from a platform auto-provisions an account bound to // that identity. An ephemeral guest is also a durable account row (the sessions // and game_players foreign keys both require one) but carries no identity and is // flagged is_guest, which excludes it from statistics, friends and history. package account import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) // Identity kinds recognised by the backend. Email is modelled as an identity // alongside platform identities; its confirmed flag is driven by the email // confirm-code flow. Robot is a synthetic kind: each pooled // robot opponent is a durable account bound to one robot identity. const ( KindTelegram = "telegram" KindEmail = "email" KindRobot = "robot" ) // uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation. const uniqueViolation = "23505" // ErrNotFound is returned when no account matches the lookup. var ErrNotFound = errors.New("account: not found") // Account is a durable internal account. AwayStart and AwayEnd bound the daily // local-time window (in TimeZone) during which the player is asleep, so the // turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's // own sleep is anchored to its human opponent's timezone with a per-game drift, // computed in internal/robot, not from a robot account's away window.) HintBalance // is the player's wallet of purchasable hints, spent after a game's per-seat // allowance. type Account struct { ID uuid.UUID DisplayName string PreferredLanguage string TimeZone string AwayStart time.Time AwayEnd time.Time HintBalance int BlockChat bool BlockFriendRequests bool // ServiceLanguage is the language tag (en/ru) of the bot the account last // authenticated through (its last Telegram ValidateInitData); it routes the // account's out-of-app push back through the right bot. Empty when the account // has never signed in through a tagged bot. Distinct from PreferredLanguage (the // interface language) and from a game's variant language. ServiceLanguage string // IsGuest marks an ephemeral guest account: a durable row with no identity, // excluded from statistics, friends and history. IsGuest bool // NotificationsInAppOnly confines notifications to the in-app live stream when // true (the default): the platform side-service skips out-of-app push for the // account. NotificationsInAppOnly bool // PaidAccount marks a lifetime one-time-payment account. It is a service field // (no purchase flow yet); an account linking & merge ORs it so a paid status is // never lost when accounts are consolidated. PaidAccount bool // MergedInto is the primary account a retired (merged) secondary points at, or // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade // foreign keys of a shared finished game stay valid. MergedInto uuid.UUID // FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the // zero time for an unflagged account, otherwise when the gateway-reported // rate-limiter rejections first crossed the sustained threshold. An // operator clears it in the admin console; it never gates any request. FlaggedHighRateAt time.Time CreatedAt time.Time UpdatedAt time.Time } // Identity is one of an account's platform/email identities, surfaced on the // admin account-detail view. ExternalID is the platform user id (or the email // address for an email identity); Confirmed tracks the email confirm-code flow. type Identity struct { Kind string ExternalID string Confirmed bool CreatedAt time.Time } // Store is the Postgres-backed query surface for accounts and identities. type Store struct { db *sql.DB metrics *accountMetrics } // NewStore constructs a Store wrapping db. Metrics default to a no-op meter until // SetMetrics installs the real one during startup wiring. func NewStore(db *sql.DB) *Store { return &Store{db: db, metrics: defaultAccountMetrics()} } // ProvisionByIdentity returns the account bound to (kind, externalID), creating // a fresh durable account and identity when none exists yet. It is safe under // concurrent callers: a losing race on the identity's unique constraint is // resolved by re-reading the winner's account. A platform identity is recorded // as confirmed; an email identity starts unconfirmed. func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) { return s.provision(ctx, kind, externalID, provisionSeed{}) } // ProvisionRobot provisions (or finds) the durable account backing a robot pool // member: a KindRobot identity carrying displayName, with chat blocked but friend // requests NOT blocked — a request to a robot is accepted as pending and, since the // robot never responds, simply expires (friendRequestTTL), exactly mirroring a human // who ignores the request. Robot names are system-generated, not player-edited, so they // bypass the editable display-name validation and may carry forms the editor rejects (an // abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the // display name and both block flags. func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) { acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName}) if err != nil { return Account{}, err } if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests { return acc, nil } stmt := table.Accounts.UPDATE( table.Accounts.DisplayName, table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt, ).SET( postgres.String(displayName), postgres.Bool(true), postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))). RETURNING(table.Accounts.AllColumns) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err) } return modelToAccount(row), nil } // ProvisionTelegram provisions (or finds) the account bound to a Telegram // identity. On first contact only, it seeds the new account's preferred language // from the Telegram client languageCode (when it maps to a supported language) and // its display name sanitized from firstName (falling back to username, then to a // generated placeholder when neither yields any letters); an already-existing // account is returned unchanged, so a later profile edit is never overwritten. func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) { return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) } // provision finds the account for (kind, externalID) or creates it with seed, // collapsing a concurrent-create race on the identity unique constraint into a // re-read of the winner's account. func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) { acc, err := s.findByIdentity(ctx, kind, externalID) if err == nil { return acc, nil } if !errors.Is(err, ErrNotFound) { return Account{}, err } acc, err = s.create(ctx, kind, externalID, seed) if err != nil { if isUniqueViolation(err) { // A concurrent caller created the identity first; return theirs. return s.findByIdentity(ctx, kind, externalID) } return Account{}, err } return acc, nil } // provisionSeed carries the optional create-time profile seed for a brand-new // account (Telegram first contact). Empty fields fall back to the accounts table // defaults, so an unknown language keeps the 'en' default and an empty name keeps // the ” default. type provisionSeed struct { preferredLanguage string displayName string } // telegramSeed derives the create-time seed from Telegram launch fields: a // supported preferred language from languageCode (an ISO-639 code, possibly // region-tagged like "ru-RU"), and a display name sanitized from firstName or, // failing that, username (sanitizeDisplayName strips disallowed characters to the // editable format). When neither yields any letters, it falls back to a generated // placeholder in the seeded language (placeholderDisplayName). func telegramSeed(languageCode, username, firstName string) provisionSeed { var seed provisionSeed if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { seed.preferredLanguage = lang } name := sanitizeDisplayName(firstName) if name == "" { name = sanitizeDisplayName(username) } if name == "" { name = placeholderDisplayName(seed.preferredLanguage) } seed.displayName = name return seed } // GetByID loads the account identified by id, or ErrNotFound when it is absent. func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) { stmt := postgres.SELECT(table.Accounts.AllColumns). FROM(table.Accounts). WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). LIMIT(1) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Account{}, ErrNotFound } return Account{}, fmt.Errorf("account: get by id %s: %w", id, err) } return modelToAccount(row), nil } // IdentityExternalID returns the external_id of the account's identity of the // given kind, or ErrNotFound when the account has no such identity. The Telegram // side-service uses it (through the gateway push-target lookup) to address an // out-of-app notification to a recipient's Telegram chat. func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) { stmt := postgres.SELECT(table.Identities.ExternalID). FROM(table.Identities). WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(kind))), ). LIMIT(1) var row model.Identities if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return "", ErrNotFound } return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err) } return row.ExternalID, nil } // Identities returns the account's platform/email identities, oldest first, for // the admin account-detail view. func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity, error) { stmt := postgres.SELECT(table.Identities.AllColumns). FROM(table.Identities). WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))). ORDER_BY(table.Identities.CreatedAt.ASC()) var rows []model.Identities if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("account: list identities %s: %w", accountID, err) } out := make([]Identity, 0, len(rows)) for _, r := range rows { out = append(out, Identity{Kind: r.Kind, ExternalID: r.ExternalID, Confirmed: r.Confirmed, CreatedAt: r.CreatedAt}) } return out, nil } // ListAccounts returns accounts for the admin user list, newest first, paginated // by limit and offset. func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { stmt := postgres.SELECT(table.Accounts.AllColumns). FROM(table.Accounts). ORDER_BY(table.Accounts.CreatedAt.DESC()). LIMIT(int64(limit)). OFFSET(int64(offset)) var rows []model.Accounts if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("account: list accounts: %w", err) } out := make([]Account, 0, len(rows)) for _, r := range rows { out = append(out, modelToAccount(r)) } return out, nil } // CountAccounts returns the total number of accounts, for admin-list pagination. func (s *Store) CountAccounts(ctx context.Context) (int, error) { stmt := postgres.SELECT(postgres.COUNT(table.Accounts.AccountID).AS("count")). FROM(table.Accounts) var dest struct{ Count int64 } if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { return 0, fmt.Errorf("account: count accounts: %w", err) } return int(dest.Count), nil } // findByIdentity joins identities to accounts and returns the matching account, // or ErrNotFound. func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) { stmt := postgres.SELECT(table.Accounts.AllColumns). FROM(table.Accounts.INNER_JOIN( table.Identities, table.Identities.AccountID.EQ(table.Accounts.AccountID), )). WHERE( table.Identities.Kind.EQ(postgres.String(kind)). AND(table.Identities.ExternalID.EQ(postgres.String(externalID))), ). LIMIT(1) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Account{}, ErrNotFound } return Account{}, fmt.Errorf("account: find by identity (%s, %s): %w", kind, externalID, err) } return modelToAccount(row), nil } // create inserts a new account (seeded from seed) and its first identity inside // one transaction and returns the persisted account row. func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) { accountID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new account id: %w", err) } identityID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new identity id: %w", err) } var created Account err = withTx(ctx, s.db, func(tx *sql.Tx) error { // Seed the new row's display name and language (Telegram first contact); an // empty seed reproduces the table defaults ('' and 'en') the other callers // relied on, so their behaviour is unchanged. lang := seed.preferredLanguage if lang == "" { lang = "en" } insertAccount := table.Accounts. INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage). VALUES(accountID, seed.displayName, lang). RETURNING(table.Accounts.AllColumns) var row model.Accounts if err := insertAccount.QueryContext(ctx, tx, &row); err != nil { return err } insertIdentity := table.Identities.INSERT( table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind, table.Identities.ExternalID, table.Identities.Confirmed, ).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram) if _, err := insertIdentity.ExecContext(ctx, tx); err != nil { return err } created = modelToAccount(row) return nil }) if err != nil { return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err) } // Count genuinely new durable accounts; robots are a fixed provisioned pool, // not users, so they are excluded. if kind != KindRobot { s.metrics.recordCreated(ctx, kind) } return created, nil } // guestDisplayName is the display name stamped on a freshly provisioned guest. const guestDisplayName = "Guest" // ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying // no identity, flagged is_guest, so it can hold a session and a game seat (both // foreign-key the accounts table) while being excluded from statistics, friends // and history. Guests are not reused — each bootstrap mints a new account. func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) { accountID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new guest id: %w", err) } stmt := table.Accounts. INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest). VALUES(accountID, guestDisplayName, true). RETURNING(table.Accounts.AllColumns) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return Account{}, fmt.Errorf("account: provision guest: %w", err) } s.metrics.recordCreated(ctx, kindGuest) return modelToAccount(row), nil } // SpendHint atomically decrements the account's hint wallet by one, returning // true when a hint was spent and false when the balance was already empty. The // guarded UPDATE keeps it safe under concurrent spends across the player's games. func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { stmt := table.Accounts. UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt). SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())). WHERE( table.Accounts.AccountID.EQ(postgres.UUID(id)). AND(table.Accounts.HintBalance.GT(postgres.Int(0))), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return false, fmt.Errorf("account: spend hint %s: %w", id, err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("account: spend hint rows %s: %w", id, err) } return n > 0, nil } // FlagHighRate stamps the soft "suspected high-rate" marker with at, only when // the account is not already flagged — the first sustained episode wins, and a // re-flag after an operator clear starts a fresh timestamp. An infra marker, not // a profile edit, so updated_at is untouched; it never gates any request. // It reports whether the flag was newly set. func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) { stmt := table.Accounts. UPDATE(table.Accounts.FlaggedHighRateAt). SET(postgres.TimestampzT(at.UTC())). WHERE( table.Accounts.AccountID.EQ(postgres.UUID(id)). AND(table.Accounts.FlaggedHighRateAt.IS_NULL()), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return false, fmt.Errorf("account: flag high rate %s: %w", id, err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("account: flag high rate rows %s: %w", id, err) } return n > 0, nil } // ClearHighRateFlag removes the high-rate marker — the operator's reversible // action in the admin console. Clearing an unflagged account is a no-op. func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error { stmt := table.Accounts. UPDATE(table.Accounts.FlaggedHighRateAt). SET(postgres.NULL). WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("account: clear high-rate flag %s: %w", id, err) } return nil } // SetServiceLanguage records the service language (en/ru) of the bot a Telegram // user authenticated through. It is called on every Telegram login — new and // existing accounts — so it tracks the bot the user last came through (last-login- // wins), and the out-of-app push routes by it. It is a no-op for an empty language // (a non-Telegram login carries none) and does not bump updated_at (an infra // routing field, not a user profile edit). func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error { if language == "" { return nil } stmt := table.Accounts. UPDATE(table.Accounts.ServiceLanguage). SET(postgres.String(language)). WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("account: set service language %s: %w", id, err) } return nil } // modelToAccount projects a generated model row into the public Account struct. func modelToAccount(row model.Accounts) Account { var mergedInto uuid.UUID if row.MergedInto != nil { mergedInto = *row.MergedInto } var serviceLanguage string if row.ServiceLanguage != nil { serviceLanguage = *row.ServiceLanguage } var flaggedHighRateAt time.Time if row.FlaggedHighRateAt != nil { flaggedHighRateAt = *row.FlaggedHighRateAt } return Account{ ID: row.AccountID, DisplayName: row.DisplayName, PreferredLanguage: row.PreferredLanguage, ServiceLanguage: serviceLanguage, TimeZone: row.TimeZone, AwayStart: row.AwayStart, AwayEnd: row.AwayEnd, HintBalance: int(row.HintBalance), BlockChat: row.BlockChat, BlockFriendRequests: row.BlockFriendRequests, IsGuest: row.IsGuest, NotificationsInAppOnly: row.NotificationsInAppOnly, PaidAccount: row.PaidAccount, MergedInto: mergedInto, FlaggedHighRateAt: flaggedHighRateAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } // isUniqueViolation reports whether err is a PostgreSQL unique-constraint // violation, used to collapse a concurrent-provision race into a re-read. func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation } // withTx wraps fn in a transaction, committing on nil and rolling back on error. func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } if err := fn(tx); err != nil { _ = tx.Rollback() return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit tx: %w", err) } return nil }