Files
Ilia Denisov d733ce3119
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s
Stage 8: UI social/account/history surfaces
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
2026-06-03 19:47:40 +02:00

315 lines
12 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/notify"
"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"
friendDeclined = "declined"
)
// friendRequestTTL is how long an unanswered (ignored) friend request stays
// pending before it lazily expires and may be re-sent. An explicit decline is
// remembered permanently (status 'declined') instead and is not subject to this
// window; a one-time friend code from the addressee bypasses a decline.
const friendRequestTTL = 30 * 24 * time.Hour
// SendFriendRequest records a pending friend request from requesterID to
// addresseeID — the "befriend an opponent" path. It requires the two to share a
// game (active or finished) and refuses a self-request, a request across a block or
// the addressee's block_friend_requests toggle, a duplicate of a live request or an
// existing friendship, and a re-send after an explicit decline (ErrRequestDeclined).
// An ignored request that has lazily expired (friendRequestTTL) may be re-sent and
// reopens the existing row with a fresh clock.
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
}
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
if err != nil {
return err
}
if !shared {
return ErrNoSharedGame
}
edges, err := svc.store.loadEdges(ctx, requesterID, addresseeID)
if err != nil {
return err
}
cutoff := svc.now().Add(-friendRequestTTL)
for _, e := range edges {
// Already friends, or the addressee already has a live request awaiting the
// requester — in both cases there is nothing to (re-)send.
if e.Status == friendAccepted {
return ErrRequestExists
}
if e.RequesterID == addresseeID && e.Status == friendPending && e.CreatedAt.After(cutoff) {
return ErrRequestExists
}
}
for _, e := range edges {
if e.RequesterID != requesterID {
continue
}
switch e.Status {
case friendDeclined:
return ErrRequestDeclined
case friendPending:
if e.CreatedAt.After(cutoff) {
return ErrRequestExists
}
// An ignored request that has expired — reopen it with a fresh clock.
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
return err
}
svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest))
return nil
}
}
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
if isUniqueViolation(err) {
return ErrRequestExists
}
return err
}
svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest))
return nil
}
// RespondFriendRequest lets addresseeID accept or decline the pending request
// from requesterID. Accepting flips it to a friendship; declining records a
// permanent 'declined' status (so the same requester cannot re-send), rather than
// deleting the row. 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.declineFriendRequest(ctx, requesterID, addresseeID, svc.now())
}
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 live (not yet expired)
// 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, svc.now().Add(-friendRequestTTL))
}
// loadEdges returns every friendship row between a and b in either direction (at
// most one per direction). It feeds SendFriendRequest's re-send classification.
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
stmt := postgres.SELECT(table.Friendships.AllColumns).
FROM(table.Friendships).
WHERE(edgeEither(a, b))
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: load friendship edges: %w", err)
}
return rows, nil
}
// insertFriendRequest inserts a pending request from requester to addressee,
// stamping created_at so the lazy-expiry clock is deterministic under a fake now.
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
stmt := table.Friendships.INSERT(
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, table.Friendships.CreatedAt,
).VALUES(requester, addressee, friendPending, now)
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.
// It backs the requester's own cancel (which leaves no trace, unlike a decline).
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")
}
// declineFriendRequest marks a pending request from requester to addressee as
// permanently declined (so the requester cannot re-send) and reports whether a row
// matched.
func (s *Store) declineFriendRequest(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(friendDeclined), 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: decline friend request")
}
// refreshFriendRequest resets an expired pending request's created_at so it counts
// as freshly sent again.
func (s *Store) refreshFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
stmt := table.Friendships.
UPDATE(table.Friendships.CreatedAt).
SET(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))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: refresh friend request: %w", err)
}
return nil
}
// 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 live (created after cutoff)
// pending request to accountID; lazily expired requests are hidden.
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]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))).
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))),
)
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
}