// 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 // in a later stage; this package only persists and reads them. package social import ( "context" "errors" "time" "github.com/google/uuid" "scrabble/backend/internal/account" "scrabble/backend/internal/notify" ) // 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. // game.Service satisfies it, so chat and nudge gate on game state 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) } // 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") // ErrNotParticipant is returned when an account is not seated in the game. ErrNotParticipant = errors.New("social: account is not a player in this game") // ErrChatBlocked is returned when the sender has disabled chat for themselves. ErrChatBlocked = errors.New("social: chat is disabled for this account") // 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") ) // 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 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{}, 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 } }