// 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" "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 in a later stage. Robot is a synthetic kind: each pooled // robot opponent is a durable account bound to one robot identity (Stage 5). 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 // IsGuest marks an ephemeral guest account: a durable row with no identity, // excluded from statistics, friends and history. IsGuest bool CreatedAt time.Time UpdatedAt time.Time } // Store is the Postgres-backed query surface for accounts and identities. type Store struct { db *sql.DB } // NewStore constructs a Store wrapping db. func NewStore(db *sql.DB) *Store { return &Store{db: db} } // 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) { 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) 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 } // 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 } // 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 and its first identity inside one transaction // and returns the persisted account row. func (s *Store) create(ctx context.Context, kind, externalID string) (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 { insertAccount := table.Accounts. INSERT(table.Accounts.AccountID). VALUES(accountID). 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) } 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) } 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 } // modelToAccount projects a generated model row into the public Account struct. func modelToAccount(row model.Accounts) Account { return Account{ ID: row.AccountID, DisplayName: row.DisplayName, PreferredLanguage: row.PreferredLanguage, TimeZone: row.TimeZone, AwayStart: row.AwayStart, AwayEnd: row.AwayEnd, HintBalance: int(row.HintBalance), BlockChat: row.BlockChat, BlockFriendRequests: row.BlockFriendRequests, IsGuest: row.IsGuest, 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 }