Files
scrabble-game/backend/internal/account/suspension.go
T
Ilia Denisov e71e40eef5
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
feat(telegram): promo bot + channel-chat moderation gate
Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
2026-06-21 14:46:51 +02:00

376 lines
14 KiB
Go

package account
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Suspension is an account's currently-in-force manual block, the operator's hard counterpart
// to the soft, reversible FlaggedHighRateAt marker. It is named "suspension" to stay distinct
// from the peer-to-peer blocks in internal/social (one player muting another); the vocabulary
// the player sees is "blocked". BlockedUntil is nil for a permanent block and the expiry
// instant for a temporary one. ReasonEn and ReasonRu are the reason-text snapshot taken at
// block time (both empty when no reason was cited), so editing or deleting the picklist entry
// never changes what an already-blocked player is shown.
type Suspension struct {
AccountID uuid.UUID
BlockedAt time.Time
BlockedUntil *time.Time
ReasonEn string
ReasonRu string
}
// Permanent reports whether the suspension has no expiry.
func (s Suspension) Permanent() bool { return s.BlockedUntil == nil }
// LocalizedReason returns the reason text in the given language ("ru" selects Russian, anything
// else English), or empty when no reason was cited. The two snapshots are always both set or
// both empty, since a reason is chosen from the en+ru picklist.
func (s Suspension) LocalizedReason(language string) string {
if language == "ru" {
return s.ReasonRu
}
return s.ReasonEn
}
// Reason is one entry of the operator-editable suspension-reason picklist, carrying the English
// and Russian text shown to a blocked player in their language.
type Reason struct {
ID uuid.UUID
TextEn string
TextRu string
CreatedAt time.Time
UpdatedAt time.Time
}
// Suspend records a new manual block on the account: permanent when until is nil, otherwise in
// force until that instant. reasonEn and reasonRu are the optional reason-text snapshot (both
// empty for no reason) and reasonID is the loose picklist link (nil when none, nulled later if
// that entry is deleted). It returns the persisted Suspension. Suspending an already-blocked
// account simply appends another block; CurrentSuspension always reflects the strongest.
func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Time, reasonEn, reasonRu string, reasonID *uuid.UUID) (Suspension, error) {
suspensionID, err := uuid.NewV7()
if err != nil {
return Suspension{}, fmt.Errorf("account: new suspension id: %w", err)
}
stmt := table.AccountSuspensions.INSERT(
table.AccountSuspensions.SuspensionID,
table.AccountSuspensions.AccountID,
table.AccountSuspensions.BlockedUntil,
table.AccountSuspensions.ReasonEn,
table.AccountSuspensions.ReasonRu,
table.AccountSuspensions.ReasonID,
).VALUES(
suspensionID,
accountID,
nullableTimestamp(until),
nullableString(reasonEn),
nullableString(reasonRu),
nullableUUID(reasonID),
).RETURNING(table.AccountSuspensions.AllColumns)
var row model.AccountSuspensions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err)
}
s.invalidateSuspension(accountID)
return modelToSuspension(row), nil
}
// LiftSuspension lifts every in-force block on the account (the operator's manual unblock),
// stamping lifted_at on each. It is a no-op when the account is not currently blocked. Lifting
// does not un-resign games already forfeited at block time — those stay lost.
func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error {
now := time.Now().UTC()
stmt := table.AccountSuspensions.
UPDATE(table.AccountSuspensions.LiftedAt).
SET(postgres.TimestampzT(now)).
WHERE(
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
AND(activeSuspensionPredicate(now)),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: lift suspension %s: %w", accountID, err)
}
s.invalidateSuspension(accountID)
return nil
}
// CurrentSuspension returns the account's in-force block and true, or false when the account is
// not blocked. When several blocks overlap it returns the strongest — a permanent one first,
// otherwise the latest-expiring — which is what the player's blocked screen shows. The gate
// middleware calls it on every authenticated request, served by account_suspensions_account_idx.
func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Suspension, bool, error) {
// A store with no database (the zero-value store used by the routing unit tests) has no
// suspensions; report not-blocked rather than dereferencing a nil pool. A real pool that
// errors still surfaces the error to the gate, which fails closed.
if s.db == nil {
return Suspension{}, false, nil
}
now := time.Now().UTC()
if s.suspensions != nil {
if e, ok := s.suspensions.get(accountID); ok {
if !e.found {
return Suspension{}, false, nil // cached: not blocked
}
if suspensionActiveAt(e.susp, now) {
return e.susp, true, nil // cached block still in force
}
// The cached block has lapsed since it was cached; refresh from the database below.
}
}
susp, found, err := s.queryCurrentSuspension(ctx, accountID, now)
if err != nil {
return Suspension{}, false, err
}
if s.suspensions != nil {
s.suspensions.put(accountID, suspensionCacheEntry{susp: susp, found: found})
}
return susp, found, nil
}
// queryCurrentSuspension reads the account's strongest in-force block straight from the database
// (no cache), as of now. It backs CurrentSuspension on a cache miss or a lapsed entry.
func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, now time.Time) (Suspension, bool, error) {
stmt := postgres.SELECT(table.AccountSuspensions.AllColumns).
FROM(table.AccountSuspensions).
WHERE(
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
AND(activeSuspensionPredicate(now)),
).
ORDER_BY(table.AccountSuspensions.BlockedUntil.DESC().NULLS_FIRST()).
LIMIT(1)
var row model.AccountSuspensions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Suspension{}, false, nil
}
return Suspension{}, false, fmt.Errorf("account: current suspension %s: %w", accountID, err)
}
return modelToSuspension(row), true, nil
}
// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the
// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The
// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires,
// since no operator action fires then. An account that still has another active block may be
// included; the eligibility resolver returns the true state, so emitting for it is harmless.
func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT account_id FROM backend.account_suspensions
WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`,
since.UTC(), until.UTC())
if err != nil {
return nil, fmt.Errorf("account: suspensions expired between: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("account: scan expired suspension: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
// Called after Suspend and LiftSuspension.
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
if s.suspensions != nil {
s.suspensions.invalidate(accountID)
}
}
// suspensionActiveAt reports whether a suspension is in force at now: permanent, or not yet
// expired. The lifted check is implicit — only non-lifted blocks are ever cached or returned.
func suspensionActiveAt(susp Suspension, now time.Time) bool {
return susp.BlockedUntil == nil || susp.BlockedUntil.After(now)
}
// suspensionCache is the gate's write-through cache of each account's current block, keyed by
// account id. The suspension gate reads it on every authenticated request; Suspend and
// LiftSuspension invalidate the account's entry. An entry holds the strongest active block at
// query time (or a not-blocked marker), re-evaluated against the wall clock on read, so a
// temporary block lapses without an explicit invalidation. It is single-instance, matching the
// deployment (one shared Store); a multi-instance deployment would need a shared cache.
type suspensionCache struct {
mu sync.RWMutex
m map[uuid.UUID]suspensionCacheEntry
}
// suspensionCacheEntry is a cached lookup: the strongest active block when found, else a
// not-blocked marker (found=false).
type suspensionCacheEntry struct {
susp Suspension
found bool
}
func newSuspensionCache() *suspensionCache {
return &suspensionCache{m: make(map[uuid.UUID]suspensionCacheEntry)}
}
func (c *suspensionCache) get(id uuid.UUID) (suspensionCacheEntry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.m[id]
return e, ok
}
func (c *suspensionCache) put(id uuid.UUID, e suspensionCacheEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[id] = e
}
func (c *suspensionCache) invalidate(id uuid.UUID) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, id)
}
// activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and
// either permanent or not yet expired.
func activeSuspensionPredicate(now time.Time) postgres.BoolExpression {
return table.AccountSuspensions.LiftedAt.IS_NULL().
AND(
table.AccountSuspensions.BlockedUntil.IS_NULL().
OR(table.AccountSuspensions.BlockedUntil.GT(postgres.TimestampzT(now))),
)
}
// ListReasons returns the suspension-reason picklist, oldest first.
func (s *Store) ListReasons(ctx context.Context) ([]Reason, error) {
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
FROM(table.SuspensionReasons).
ORDER_BY(table.SuspensionReasons.CreatedAt.ASC())
var rows []model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: list reasons: %w", err)
}
out := make([]Reason, 0, len(rows))
for _, r := range rows {
out = append(out, modelToReason(r))
}
return out, nil
}
// GetReason loads one picklist entry, or ErrNotFound when it is absent.
func (s *Store) GetReason(ctx context.Context, id uuid.UUID) (Reason, error) {
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
FROM(table.SuspensionReasons).
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Reason{}, ErrNotFound
}
return Reason{}, fmt.Errorf("account: get reason %s: %w", id, err)
}
return modelToReason(row), nil
}
// CreateReason inserts a new picklist entry with the given English and Russian text.
func (s *Store) CreateReason(ctx context.Context, textEn, textRu string) (Reason, error) {
id, err := uuid.NewV7()
if err != nil {
return Reason{}, fmt.Errorf("account: new reason id: %w", err)
}
stmt := table.SuspensionReasons.INSERT(
table.SuspensionReasons.ReasonID,
table.SuspensionReasons.TextEn,
table.SuspensionReasons.TextRu,
).VALUES(id, textEn, textRu).
RETURNING(table.SuspensionReasons.AllColumns)
var row model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Reason{}, fmt.Errorf("account: create reason: %w", err)
}
return modelToReason(row), nil
}
// UpdateReason rewrites a picklist entry's English and Russian text, returning ErrNotFound when
// it is absent. Existing suspensions keep their text snapshot, so the change only affects future
// blocks.
func (s *Store) UpdateReason(ctx context.Context, id uuid.UUID, textEn, textRu string) (Reason, error) {
stmt := table.SuspensionReasons.
UPDATE(table.SuspensionReasons.TextEn, table.SuspensionReasons.TextRu, table.SuspensionReasons.UpdatedAt).
SET(postgres.String(textEn), postgres.String(textRu), postgres.TimestampzT(time.Now().UTC())).
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
RETURNING(table.SuspensionReasons.AllColumns)
var row model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Reason{}, ErrNotFound
}
return Reason{}, fmt.Errorf("account: update reason %s: %w", id, err)
}
return modelToReason(row), nil
}
// DeleteReason removes a picklist entry. It is a hard delete: the reason_id link on past
// suspensions is nulled by the foreign key, but their text snapshot is untouched.
func (s *Store) DeleteReason(ctx context.Context, id uuid.UUID) error {
stmt := table.SuspensionReasons.
DELETE().
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: delete reason %s: %w", id, err)
}
return nil
}
// modelToSuspension projects a generated row into the public Suspension struct.
func modelToSuspension(row model.AccountSuspensions) Suspension {
s := Suspension{AccountID: row.AccountID, BlockedAt: row.BlockedAt, BlockedUntil: row.BlockedUntil}
if row.ReasonEn != nil {
s.ReasonEn = *row.ReasonEn
}
if row.ReasonRu != nil {
s.ReasonRu = *row.ReasonRu
}
return s
}
// modelToReason projects a generated row into the public Reason struct.
func modelToReason(row model.SuspensionReasons) Reason {
return Reason{ID: row.ReasonID, TextEn: row.TextEn, TextRu: row.TextRu, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
// nullableString renders an empty string as SQL NULL, otherwise the string literal.
func nullableString(v string) postgres.Expression {
if v == "" {
return postgres.NULL
}
return postgres.String(v)
}
// nullableTimestamp renders a nil time as SQL NULL, otherwise the UTC timestamp literal.
func nullableTimestamp(v *time.Time) postgres.Expression {
if v == nil {
return postgres.NULL
}
return postgres.TimestampzT(v.UTC())
}
// nullableUUID renders a nil id as SQL NULL, otherwise the uuid literal.
func nullableUUID(v *uuid.UUID) postgres.Expression {
if v == nil {
return postgres.NULL
}
return postgres.UUID(*v)
}