Files
scrabble-game/backend/internal/social/friends.go
T
Ilia Denisov bfa8797f8c
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
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.
2026-06-02 19:29:30 +02:00

235 lines
8.2 KiB
Go

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
}