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) }