9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.
The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
149 lines
8.1 KiB
Go
149 lines
8.1 KiB
Go
// Package social owns the player-facing social fabric around games: the friend
|
|
// graph (request/accept), per-user blocks, and per-game chat with nudges folded
|
|
// in as a message kind. It owns the friendships, blocks and chat_messages tables,
|
|
// reads the account-level block toggles through account.Store, and gates chat and
|
|
// nudge on game state through a GameReader so it never imports the engine. The
|
|
// live delivery of chat and nudges (push / in-app stream) belongs to the gateway;
|
|
// this package only persists and reads them.
|
|
package social
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/notify"
|
|
)
|
|
|
|
// tracerName scopes the social domain's OpenTelemetry spans.
|
|
const tracerName = "scrabble/backend/social"
|
|
|
|
// GameReader is the slice of the game domain the social package needs: the seated
|
|
// accounts in seat order, the seat index whose turn it is, and the game status, plus
|
|
// a shared-game test. game.Service satisfies it, so chat, nudge and the
|
|
// befriend-an-opponent gate work without a dependency on the engine or the game's
|
|
// private state.
|
|
type GameReader interface {
|
|
Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error)
|
|
// SeatName is the display name of the account seated in the game (its per-game snapshot,
|
|
// falling back to the account's current name), or "" when it holds no seat; it names a
|
|
// nudge's sender in the toast and the out-of-app push by the same identity the game uses.
|
|
SeatName(ctx context.Context, gameID, accountID uuid.UUID) (string, error)
|
|
// SharedGame reports whether two accounts are seated together in any game
|
|
// (active or finished); it gates the "befriend an opponent" request path.
|
|
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
|
|
// LastMoveAt is the time of an account's most recent move in a game (and whether it
|
|
// has moved); the nudge cooldown resets once the player has taken a turn.
|
|
LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error)
|
|
// TurnStartedAt is the start time of the game's current turn; it bounds the
|
|
// one-chat-message-per-turn limit (a message counts toward the current turn when it
|
|
// was posted at or after this time).
|
|
TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error)
|
|
// VsAI reports whether the game is an honest-AI game, in which chat and nudge are
|
|
// both disabled (the game still reports status 'active').
|
|
VsAI(ctx context.Context, gameID uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// Sentinel errors returned by the service.
|
|
var (
|
|
// ErrSelfRelation is returned when an account targets itself.
|
|
ErrSelfRelation = errors.New("social: cannot target yourself")
|
|
// ErrRequestExists is returned when a friend request or friendship already
|
|
// exists between the two accounts (in either direction).
|
|
ErrRequestExists = errors.New("social: a friend request or friendship already exists")
|
|
// ErrRequestBlocked is returned when the addressee does not accept friend
|
|
// requests (their global toggle) or a block stands between the two accounts.
|
|
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
|
|
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
|
|
// request, redeem a friend code); friends are a durable-account feature.
|
|
ErrGuestForbidden = errors.New("social: guests cannot use friends")
|
|
// ErrAccountDeleted is returned when a social action targets a deleted (tombstoned)
|
|
// account: nobody is behind it any more, so it can be neither befriended nor blocked.
|
|
// The client already hides those controls on a deleted seat; this is the server source
|
|
// of truth, and the guard an older client hits.
|
|
ErrAccountDeleted = errors.New("social: this account has been deleted")
|
|
// ErrRequestNotFound is returned when no pending friend request matches.
|
|
ErrRequestNotFound = errors.New("social: no pending friend request")
|
|
// ErrNoSharedGame is returned when a friend request targets someone the
|
|
// requester has never shared a game with (the befriend-an-opponent gate).
|
|
ErrNoSharedGame = errors.New("social: you can only request someone you have played with")
|
|
// ErrRequestDeclined is returned when the addressee has previously declined a
|
|
// request from this requester; a re-send is refused (a one-time friend code
|
|
// from the addressee bypasses this).
|
|
ErrRequestDeclined = errors.New("social: this person has declined your friend request")
|
|
// ErrFriendCodeInvalid is returned when a redeemed friend code is unknown,
|
|
// already used, or expired.
|
|
ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired")
|
|
// ErrNotParticipant is returned when an account is not seated in the game.
|
|
ErrNotParticipant = errors.New("social: account is not a player in this game")
|
|
// ErrMessageNotFound is returned when an admin message lookup finds no such message.
|
|
ErrMessageNotFound = errors.New("social: message not found")
|
|
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
|
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
|
// ErrRecipientBlocked is returned when a player tries to chat to, or nudge, someone
|
|
// they have blocked — the blocker-side guard behind the hidden composer (a blocked
|
|
// player's own sends are never refused, only silently suppressed, so they never notice).
|
|
ErrRecipientBlocked = errors.New("social: cannot message a blocked player")
|
|
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
|
|
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
|
|
// ErrEmptyMessage is returned when a chat message is blank after trimming.
|
|
ErrEmptyMessage = errors.New("social: message is empty")
|
|
// ErrNudgeOnOwnTurn is returned when a player tries to nudge while it is their
|
|
// own turn (there is no awaited opponent to nudge).
|
|
ErrNudgeOnOwnTurn = errors.New("social: cannot nudge while it is your turn")
|
|
// ErrNudgeTooSoon is returned when the per-game once-per-hour nudge limit is hit.
|
|
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
|
|
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
|
|
ErrGameNotActive = errors.New("social: game is not active")
|
|
// ErrGameVsAI is returned when chat or nudge is attempted in an honest-AI game,
|
|
// where both are disabled (the UI also disables the controls).
|
|
ErrGameVsAI = errors.New("social: chat and nudge are disabled in AI games")
|
|
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the
|
|
// sender's turn — chat is allowed only on your own turn (the opponent's-turn control
|
|
// is the nudge).
|
|
ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn")
|
|
// ErrChatAlreadySentThisTurn is returned when the sender has already posted a chat
|
|
// message during the current turn — at most one message is allowed per turn.
|
|
ErrChatAlreadySentThisTurn = errors.New("social: already sent a chat message this turn")
|
|
)
|
|
|
|
// Service is the social domain. It is the only writer of the friendships, blocks
|
|
// and chat_messages tables and is safe for concurrent use.
|
|
type Service struct {
|
|
store *Store
|
|
accounts *account.Store
|
|
games GameReader
|
|
pub notify.Publisher
|
|
metrics *socialMetrics
|
|
tracer trace.Tracer
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewService constructs a Service. store owns the social tables; accounts supplies
|
|
// the block toggles; games gates chat and nudge on game state.
|
|
func NewService(store *Store, accounts *account.Store, games GameReader) *Service {
|
|
return &Service{
|
|
store: store,
|
|
accounts: accounts,
|
|
games: games,
|
|
pub: notify.Nop{},
|
|
metrics: defaultSocialMetrics(),
|
|
tracer: otel.Tracer(tracerName),
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
// SetNotifier installs the live-event publisher used to push chat messages and
|
|
// nudges to their recipients. It must be called during startup wiring, before
|
|
// the service serves traffic; the default is notify.Nop (no live events).
|
|
func (svc *Service) SetNotifier(p notify.Publisher) {
|
|
if p != nil {
|
|
svc.pub = p
|
|
}
|
|
}
|