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