bfa8797f8c
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.
235 lines
8.2 KiB
Go
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
|
|
}
|