Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s

Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 19:29:30 +02:00
parent 571bc8c9f2
commit bfa8797f8c
54 changed files with 4270 additions and 81 deletions
+106
View File
@@ -0,0 +1,106 @@
package social
import (
"context"
"database/sql"
"errors"
"fmt"
"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"
)
// Block records that blockerID has blocked blockedID. Blocking severs any
// friendship or pending request between the two and, through the mutual block
// checks, suppresses chat visibility and new requests/invitations in both
// directions. It is idempotent.
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
if blockerID == blockedID {
return ErrSelfRelation
}
return svc.store.insertBlock(ctx, blockerID, blockedID)
}
// Unblock removes blockerID's block on blockedID. It is idempotent.
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
return svc.store.deleteBlock(ctx, blockerID, blockedID)
}
// ListBlocks returns the account IDs blockerID has blocked.
func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listBlocks(ctx, blockerID)
}
// IsBlocked reports whether a block stands between a and b in either direction.
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
return svc.store.isBlocked(ctx, a, b)
}
// isBlocked reports whether a block row exists between a and b in either direction.
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
FROM(table.Blocks).
WHERE(
table.Blocks.BlockerID.EQ(postgres.UUID(a)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(b))).
OR(table.Blocks.BlockerID.EQ(postgres.UUID(b)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(a)))),
).LIMIT(1)
var row model.Blocks
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("social: is blocked: %w", err)
}
return true, nil
}
// insertBlock severs any friendship between the pair and inserts the block, in one
// transaction; a duplicate block is ignored.
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("clear friendship on block: %w", err)
}
ins := table.Blocks.
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
VALUES(blocker, blocked).
ON_CONFLICT(table.Blocks.BlockerID, table.Blocks.BlockedID).DO_NOTHING()
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert block: %w", err)
}
return nil
})
}
// deleteBlock removes a block. It is idempotent.
func (s *Store) deleteBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
stmt := table.Blocks.DELETE().WHERE(
table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: delete block: %w", err)
}
return nil
}
// listBlocks returns the accounts blocker has blocked.
func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Blocks.BlockedID).
FROM(table.Blocks).
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)))
var rows []model.Blocks
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list blocks: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.BlockedID)
}
return out, nil
}
+234
View File
@@ -0,0 +1,234 @@
package social
import (
"context"
"errors"
"fmt"
"net/netip"
"slices"
"strings"
"time"
"unicode/utf8"
"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"
)
const (
// maxChatRunes caps a chat message's length, keeping it to a quick reaction.
maxChatRunes = 60
// nudgeInterval is the minimum gap between two nudges by the same player in a game.
nudgeInterval = time.Hour
// kindMessage and kindNudge are the chat_messages.kind values.
kindMessage = "message"
kindNudge = "nudge"
// statusActive mirrors game.StatusActive: the status string a live game reports.
statusActive = "active"
)
// Message is one persisted per-game chat entry. A nudge is a Message with Kind
// nudge and an empty Body. SenderIP is the gateway-forwarded client IP (empty when
// unknown), kept for moderation.
type Message struct {
ID uuid.UUID
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP string
CreatedAt time.Time
}
// PostMessage stores a chat message from senderID in gameID. The sender must be a
// seated player who has not disabled chat; the body must be non-empty, within the
// rune limit, and free of links/emails/phone numbers (the content filter). The
// gateway-forwarded senderIP is validated and stored for moderation.
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
if !slices.Contains(seats, senderID) {
return Message{}, ErrNotParticipant
}
sender, err := svc.accounts.GetByID(ctx, senderID)
if err != nil {
return Message{}, err
}
if sender.BlockChat {
return Message{}, ErrChatBlocked
}
body = strings.TrimSpace(body)
if body == "" {
return Message{}, ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxChatRunes {
return Message{}, ErrMessageTooLong
}
if err := Clean(body); err != nil {
return Message{}, err
}
return svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
}
// Nudge records a nudge from senderID toward the player whose turn is awaited. The
// game must be active, the sender a seated player whose turn it is not, and the
// once-per-hour-per-game limit not yet hit.
func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Message, error) {
seats, toMove, status, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
if status != statusActive {
return Message{}, ErrGameNotActive
}
idx := slices.Index(seats, senderID)
if idx < 0 {
return Message{}, ErrNotParticipant
}
if idx == toMove {
return Message{}, ErrNudgeOnOwnTurn
}
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
if err != nil {
return Message{}, err
}
if ok && svc.now().Sub(last) < nudgeInterval {
return Message{}, ErrNudgeTooSoon
}
return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
}
// Messages returns the per-game chat visible to viewerID: the viewer must be a
// seated player. Messages from a sender the viewer has a block with (either
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
return nil, err
}
if !slices.Contains(seats, viewerID) {
return nil, ErrNotParticipant
}
viewer, err := svc.accounts.GetByID(ctx, viewerID)
if err != nil {
return nil, err
}
blocked := make(map[uuid.UUID]bool)
for _, seat := range seats {
if seat == viewerID {
continue
}
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
if err != nil {
return nil, err
}
if yes {
blocked[seat] = true
}
}
all, err := svc.store.listChatMessages(ctx, gameID)
if err != nil {
return nil, err
}
out := make([]Message, 0, len(all))
for _, m := range all {
if blocked[m.SenderID] {
continue
}
if m.Kind == kindMessage && viewer.BlockChat {
continue
}
out = append(out, m)
}
return out, nil
}
// parseIP returns a validated canonical IP string, or nil when raw is empty or
// not a valid address.
func parseIP(raw string) *string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return nil
}
canon := addr.String()
return &canon
}
// insertChatMessage stores one chat row and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string) (Message, error) {
id, err := uuid.NewV7()
if err != nil {
return Message{}, fmt.Errorf("social: new message id: %w", err)
}
var ipVal any = postgres.NULL
if ip != nil {
ipVal = postgres.String(*ip)
}
stmt := table.ChatMessages.INSERT(
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
).VALUES(id, gameID, senderID, kind, body, ipVal).
RETURNING(table.ChatMessages.AllColumns)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Message{}, fmt.Errorf("social: insert chat message: %w", err)
}
return messageFromRow(row), nil
}
// listChatMessages returns a game's chat in chronological order.
func (s *Store) listChatMessages(ctx context.Context, gameID uuid.UUID) ([]Message, error) {
stmt := postgres.SELECT(table.ChatMessages.AllColumns).
FROM(table.ChatMessages).
WHERE(table.ChatMessages.GameID.EQ(postgres.UUID(gameID))).
ORDER_BY(table.ChatMessages.CreatedAt.ASC(), table.ChatMessages.MessageID.ASC())
var rows []model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list chat: %w", err)
}
out := make([]Message, 0, len(rows))
for _, r := range rows {
out = append(out, messageFromRow(r))
}
return out, nil
}
// lastNudgeAt returns the time of senderID's most recent nudge in gameID, if any.
func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
FROM(table.ChatMessages).
WHERE(
table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
AND(table.ChatMessages.Kind.EQ(postgres.String(kindNudge))),
).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return time.Time{}, false, nil
}
return time.Time{}, false, fmt.Errorf("social: last nudge: %w", err)
}
return row.CreatedAt, true, nil
}
// messageFromRow projects a generated row into the public Message.
func messageFromRow(r model.ChatMessages) Message {
m := Message{
ID: r.MessageID,
GameID: r.GameID,
SenderID: r.SenderID,
Kind: r.Kind,
Body: r.Body,
CreatedAt: r.CreatedAt,
}
if r.SenderIP != nil {
m.SenderIP = *r.SenderIP
}
return m
}
+88
View File
@@ -0,0 +1,88 @@
package social
import (
"errors"
"fmt"
"regexp"
"strings"
"mvdan.cc/xurls/v2"
)
// ErrForbiddenContent is returned when a chat message contains a web link, email
// address or phone number — including lightly obfuscated forms. The chat is for
// quick in-game reactions, not for exchanging contact details.
var ErrForbiddenContent = errors.New("social: message contains a link, email or phone number")
// phoneDigits is the minimum run of consecutive digits treated as a phone number.
const phoneDigits = 7
// relaxedURL matches URLs, bare domains and email addresses (xurls relaxed mode).
var relaxedURL = xurls.Relaxed()
// spelledSeparators rewrites the words people use to dodge a detector back into
// their symbols, so "gmail dot com" and "user at host" are still caught.
var spelledSeparators = strings.NewReplacer(
" dot ", ".", "(dot)", ".", "[dot]", ".", " punto ", ".", " точка ", ".",
" at ", "@", "(at)", "@", "[at]", "@", " собака ", "@", " собачка ", "@",
)
// leet folds digits and symbols commonly substituted for letters, so "g00gl3.com"
// is recognised. It leaves '.' and '@' untouched, since those carry the link and
// email structure the matcher relies on.
var leet = strings.NewReplacer(
"0", "o", "1", "i", "3", "e", "4", "a", "5", "s", "7", "t", "8", "b",
"$", "s", "!", "i", "|", "l",
)
// spaceAroundPunct collapses whitespace around '.' and '@' so "gmail . com" and
// "user @ host" close up into a detectable address.
var spaceAroundPunct = regexp.MustCompile(`\s*([.@])\s*`)
// phoneSeparators are the characters stripped before counting a digit run, so a
// grouped number like "8 (900) 123-45-67" collapses to a single run.
var phoneSeparators = regexp.MustCompile(`[\s\-.()+]`)
// Clean reports whether body is free of links, emails and phone numbers, returning
// ErrForbiddenContent (naming the category) otherwise. Detection is best-effort
// over a short, rune-limited message: it folds common letter/digit obfuscation
// and spelled-out separators before matching, but does not claim to defeat every
// evasion.
func Clean(body string) error {
lower := strings.ToLower(body)
if hasLinkOrEmail(lower) {
return fmt.Errorf("%w: link or email", ErrForbiddenContent)
}
if hasPhone(lower) {
return fmt.Errorf("%w: phone number", ErrForbiddenContent)
}
return nil
}
// hasLinkOrEmail matches the lower-cased text both as written and after folding
// spelled separators, spacing and leet substitutions.
func hasLinkOrEmail(lower string) bool {
if relaxedURL.MatchString(lower) {
return true
}
deobfuscated := leet.Replace(spaceAroundPunct.ReplaceAllString(spelledSeparators.Replace(lower), "$1"))
return relaxedURL.MatchString(deobfuscated)
}
// hasPhone reports a run of phoneDigits or more digits once phone-style separators
// are removed.
func hasPhone(lower string) bool {
stripped := phoneSeparators.ReplaceAllString(lower, "")
run := 0
for _, r := range stripped {
if r >= '0' && r <= '9' {
run++
if run >= phoneDigits {
return true
}
continue
}
run = 0
}
return false
}
@@ -0,0 +1,53 @@
package social
import (
"errors"
"testing"
)
func TestCleanAllowsOrdinaryChat(t *testing.T) {
clean := []string{
"",
"nice move!",
"gg wp",
"хороший ход, поздравляю",
"unlucky draw this round",
"I scored 42 points",
"only 6 digits here: 123456",
"see you at 5",
"three vowels in my rack",
"well played :)",
}
for _, body := range clean {
if err := Clean(body); err != nil {
t.Errorf("Clean(%q) = %v, want nil", body, err)
}
}
}
func TestCleanRejectsLinksEmailsPhones(t *testing.T) {
forbidden := []string{
// Plain links and bare domains.
"http://evil.example.com",
"visit https://x.io/abc now",
"join discord.gg/xyzqwer",
"check scrabblecheat.com",
// Emails, plain and obfuscated.
"mail me a@b.com",
"john at gmail dot com",
"j0hn at gma1l dot c0m",
// Obfuscated domains.
"g00gle dot com",
"site . com please",
// Phone numbers, plain and grouped.
"call 89001234567",
"+7 900 123 45 67",
"8 (900) 123-45-67",
"my number is 1234567",
}
for _, body := range forbidden {
if err := Clean(body); !errors.Is(err, ErrForbiddenContent) {
t.Errorf("Clean(%q) = %v, want ErrForbiddenContent", body, err)
}
}
}
+234
View File
@@ -0,0 +1,234 @@
package social
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Friendship statuses persisted in friendships.status.
const (
friendPending = "pending"
friendAccepted = "accepted"
)
// SendFriendRequest records a pending friend request from requesterID to
// addresseeID. It refuses a self-request, a request blocked by either a per-user
// block or the addressee's block_friend_requests toggle, and a duplicate of an
// existing request or friendship in either direction.
func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
if requesterID == addresseeID {
return ErrSelfRelation
}
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
if err != nil {
return err
}
addressee, err := svc.accounts.GetByID(ctx, addresseeID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
return account.ErrNotFound
}
return err
}
if blocked || addressee.BlockFriendRequests {
return ErrRequestBlocked
}
exists, err := svc.store.friendshipExists(ctx, requesterID, addresseeID)
if err != nil {
return err
}
if exists {
return ErrRequestExists
}
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID); err != nil {
if isUniqueViolation(err) {
return ErrRequestExists
}
return err
}
return nil
}
// RespondFriendRequest lets addresseeID accept or decline the pending request
// from requesterID. Accepting flips it to a friendship; declining deletes it.
// Either way ErrRequestNotFound is returned when no pending request matches.
func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, requesterID uuid.UUID, accept bool) error {
var ok bool
var err error
if accept {
ok, err = svc.store.acceptFriendRequest(ctx, requesterID, addresseeID, svc.now())
} else {
ok, err = svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
}
if err != nil {
return err
}
if !ok {
return ErrRequestNotFound
}
return nil
}
// CancelFriendRequest withdraws requesterID's own pending request to addresseeID.
func (svc *Service) CancelFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
ok, err := svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
if err != nil {
return err
}
if !ok {
return ErrRequestNotFound
}
return nil
}
// Unfriend removes the friendship between the two accounts, in either direction.
// It is idempotent: removing a non-existent friendship is not an error.
func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID) error {
return svc.store.deleteFriendship(ctx, accountID, otherID)
}
// ListFriends returns the account IDs that are accepted friends of accountID.
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listFriends(ctx, accountID)
}
// ListIncomingRequests returns the account IDs that have a pending friend request
// awaiting accountID's response.
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listIncomingRequests(ctx, accountID)
}
// friendshipExists reports whether any friendship row (pending or accepted) exists
// between a and b in either direction.
func (s *Store) friendshipExists(ctx context.Context, a, b uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Friendships.Status).
FROM(table.Friendships).
WHERE(edgeEither(a, b)).
LIMIT(1)
var row model.Friendships
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("social: friendship exists: %w", err)
}
return true, nil
}
// insertFriendRequest inserts a pending request from requester to addressee.
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID) error {
stmt := table.Friendships.INSERT(
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status,
).VALUES(requester, addressee, friendPending)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: insert friend request: %w", err)
}
return nil
}
// acceptFriendRequest flips a pending request to accepted and reports whether a
// row matched.
func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
stmt := table.Friendships.
UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
SET(postgres.String(friendAccepted), postgres.TimestampzT(now)).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: accept friend request")
}
// deletePendingRequest removes a pending request and reports whether a row matched.
func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee uuid.UUID) (bool, error) {
stmt := table.Friendships.DELETE().WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: delete friend request")
}
// deleteFriendship removes an accepted friendship in either direction.
func (s *Store) deleteFriendship(ctx context.Context, a, b uuid.UUID) error {
stmt := table.Friendships.DELETE().WHERE(
edgeEither(a, b).AND(table.Friendships.Status.EQ(postgres.String(friendAccepted))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: delete friendship: %w", err)
}
return nil
}
// listFriends returns the other side of every accepted edge touching accountID.
func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.RequesterID, table.Friendships.AddresseeID).
FROM(table.Friendships).
WHERE(
table.Friendships.Status.EQ(postgres.String(friendAccepted)).
AND(table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
OR(table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list friends: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
if r.RequesterID == accountID {
out = append(out, r.AddresseeID)
} else {
out = append(out, r.RequesterID)
}
}
return out, nil
}
// listIncomingRequests returns the requesters of every pending request to accountID.
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.RequesterID).
FROM(table.Friendships).
WHERE(
table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list incoming requests: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.RequesterID)
}
return out, nil
}
// edgeEither matches a friendship row between a and b in either direction.
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
OR(table.Friendships.RequesterID.EQ(postgres.UUID(b)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(a))))
}
// execAffected runs a mutating statement and reports whether it changed a row.
func execAffected(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) (bool, error) {
res, err := stmt.ExecContext(ctx, db)
if err != nil {
return false, fmt.Errorf("%s: %w", what, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("%s rows: %w", what, err)
}
return n > 0, nil
}
+75
View File
@@ -0,0 +1,75 @@
// 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"
)
// 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
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,
now: func() time.Time { return time.Now().UTC() },
}
}
+47
View File
@@ -0,0 +1,47 @@
package social
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
)
// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
const uniqueViolation = "23505"
// Store is the Postgres-backed query surface for the friend graph, per-user
// blocks and per-game chat.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint
// violation, used to collapse a request/insert race into a friendly error.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}