// 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. Telegram and VK are platform identities, // auto-confirmed on first contact. Email is modelled as an identity alongside them; 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" KindVK = "vk" 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 // VariantPreferences is the set of game variants (engine.Variant stable labels: // "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched // into. It gates the New Game picker, the matchmaker and the friend-invite the // player creates; an invited friend may still accept any variant. A new account // defaults to Erudit only. Never empty — enforced on update and by a DB check. VariantPreferences []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 // suspensions caches each account's current manual block, read by the suspension gate on // every authenticated request and invalidated on Suspend/LiftSuspension. See suspension.go. suspensions *suspensionCache } // 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(), suspensions: newSuspensionCache()} } // 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{}) } // ProvisionEmail returns the account owning the email identity externalID, creating // it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset — // seeded into its time zone, language seeded from the client's UI language, and its // display name seeded from the email's local part (so it is not left nameless). Like // ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a // returning user's saved zone, language and name are never overwritten. The email account is // created here (the code-request step), not at the later login, so this is where its // zone and language are seeded. It is created flagged is_guest with an unconfirmed // email identity: an abandoned, never-confirmed login is then reaped like any guest, // freeing the reserved address, and confirming the code clears the guest flag. func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) { return s.provision(ctx, KindEmail, externalID, provisionSeed{ displayName: emailDisplayName(externalID), timeZone: seedZone(browserTZ), preferredLanguage: supportedLanguage(language), isGuest: true, }) } // emailDisplayName derives a display name from an email address — the local part // before '@', trimmed and capped to the column width — so a new email account is not // left nameless. It is only the first-contact seed; the user can rename it later. func emailDisplayName(email string) string { local, _, _ := strings.Cut(email, "@") local = strings.TrimSpace(local) if r := []rune(local); len(r) > maxDisplayName { local = strings.TrimRight(string(r[:maxDisplayName]), " ") } return local } // supportedLanguage returns code normalised to a supported UI language ("en" or // "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It // accepts region-tagged codes ("ru-RU"). func supportedLanguage(code string) string { if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" { return lang } return "" } // 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, // reporting whether this call created it (first contact). 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. The created flag lets the auth handler re-evaluate moderated- // chat write access on first registration — the path of a user who joined the chat // before registering, whom no chat_member event covers. func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) { // Pre-check whether the identity already exists so the caller can act on first // contact. A race with a concurrent create only over- or under-reports created for // that one call, which the idempotent chat-access re-evaluation tolerates. _, err := s.findByIdentity(ctx, KindTelegram, externalID) created := errors.Is(err, ErrNotFound) if err != nil && !created { return Account{}, false, err } seed := telegramSeed(languageCode, username, firstName) seed.timeZone = seedZone(browserTZ) acc, err := s.provision(ctx, KindTelegram, externalID, seed) return acc, created, err } // ProvisionVK provisions (or finds) the account bound to a VK identity, reporting // whether this call created it (first contact). On first contact only, it seeds the new // account's preferred language from the VK languageCode (vk_language, when it maps to a // supported language) and its display name sanitized from displayName — the name read // client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params — // falling back to a generated placeholder when it yields no letters; an already-existing // account is returned unchanged, so a later profile edit is never overwritten. func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) { // Pre-check whether the identity already exists so the caller can act on first // contact (mirrors ProvisionTelegram); a create race only mis-reports created for // that one call. _, err := s.findByIdentity(ctx, KindVK, externalID) created := errors.Is(err, ErrNotFound) if err != nil && !created { return Account{}, false, err } seed := vkSeed(languageCode, displayName) seed.timeZone = seedZone(browserTZ) acc, err := s.provision(ctx, KindVK, externalID, seed) return acc, created, err } // 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 (first contact). Empty fields fall back to the accounts table defaults, // so an unknown language keeps the 'en' default, an empty name keeps the ” default // and an empty time zone keeps the 'UTC' default. type provisionSeed struct { preferredLanguage string displayName string timeZone string // isGuest creates the account flagged is_guest. It is set for an email-login // account, which stays a guest until the address is confirmed (so an abandoned, // never-confirmed login is reaped and its address freed); confirming clears the // flag. Platform identities (telegram/vk) are durable from creation. isGuest bool } // seedZone returns browserTZ when it is a well-formed zone to persist at account // creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account // falls back to the accounts table's 'UTC' default. The client reports the device's // detected offset deterministically; a bad value is dropped rather than guessed at. func seedZone(browserTZ string) string { if validZone(browserTZ) { return browserTZ } return "" } // 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. The name precedence is the real // name (firstName, sanitized to the editable format) → the @username taken verbatim // (already a valid handle, only trimmed and length-capped, never character-stripped) // → a generated placeholder in the seeded language (placeholderDisplayName), reached // only when firstName has no usable letters and no username is set. 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 == "" { // The real name yielded nothing usable: fall back to the @username verbatim // (Telegram guarantees a valid handle), only trimmed and capped to the column // width — never character-stripped like the real name. name = strings.TrimSpace(username) if r := []rune(name); len(r) > maxDisplayName { name = strings.TrimRight(string(r[:maxDisplayName]), " ") } } if name == "" { name = placeholderDisplayName(seed.preferredLanguage) } seed.displayName = name return seed } // vkSeed derives the create-time seed from VK launch fields: a supported preferred // language from languageCode (vk_language, normally a 2-letter code) and a display name // from displayName (sanitized to the editable format), falling back to a generated // placeholder in the seeded language when the name yields no usable letters. Unlike // telegramSeed there is no @username fallback — VK provides only the name. func vkSeed(languageCode, displayName string) provisionSeed { var seed provisionSeed if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { seed.preferredLanguage = lang } name := sanitizeDisplayName(displayName) 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 } // HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail // recovery anchor a first purchase requires (D36). func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) { ids, err := s.Identities(ctx, accountID) if err != nil { return false, err } for _, id := range ids { if id.Kind == "email" && id.Confirmed { return true, nil } } return false, 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 } // AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound // when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access // resolver uses it to tell a registered Telegram user (eligible to be granted chat // write access) from an unregistered one (left muted). func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) { return s.findByIdentity(ctx, kind, externalID) } // 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, language and time zone (first contact); an // empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers // relied on, so their behaviour is unchanged. time_zone is written explicitly (the // detected offset, or 'UTC' equal to the column default) so a seeded zone lands at // creation while an unseeded one stays UTC. lang := seed.preferredLanguage if lang == "" { lang = "en" } tz := seed.timeZone if tz == "" { tz = "UTC" } insertAccount := table.Accounts. INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest). VALUES(accountID, seed.displayName, lang, tz, seed.isGuest). 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 || kind == KindVK) 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. browserTZ // (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling // back to the 'UTC' default when empty or malformed. func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) { accountID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new guest id: %w", err) } tz := seedZone(browserTZ) if tz == "" { tz = "UTC" } stmt := table.Accounts. INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone). VALUES(accountID, guestDisplayName, true, tz). 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 } // GrantHints adds n hints to the account's wallet and returns the new balance. n must be // positive: the additive update can only raise the balance, never lower it, so it enforces the // admin console's raise-only rule by construction and stays correct under a concurrent SpendHint. // It returns ErrNotFound when no account matches. func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) { if n <= 0 { return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n) } stmt := table.Accounts. UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt). SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())). WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). RETURNING(table.Accounts.HintBalance) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return 0, ErrNotFound } return 0, fmt.Errorf("account: grant hints %s: %w", id, err) } return int(row.HintBalance), 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 } // 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 flaggedHighRateAt time.Time if row.FlaggedHighRateAt != nil { flaggedHighRateAt = *row.FlaggedHighRateAt } return Account{ ID: row.AccountID, DisplayName: row.DisplayName, PreferredLanguage: row.PreferredLanguage, VariantPreferences: []string(row.VariantPreferences), 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 }