Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() },
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user