6b6baf5710
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Lobby: group the my-games list into your-turn / opponent-turn / finished (empty sections hidden), ordered by last activity (your-turn oldest-first, the other two newest-first), as a compact line-separated list. gameDTO and FB GameView gain last_activity_unix (turn start while active, finish time once finished); a pure lib/lobbysort.ts holds the grouping/ordering. Friends: the in-game 'add to friends' item is now server-derived via a new GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with a pending OR declined request (both read as 'request sent'), so it is correct across reloads; it shows a disabled '✓ in friends' once accepted. It live-updates when the opponent answers: RespondFriendRequest now publishes friend_added (accept) / friend_declined (new notify sub-kind, decline) to the original requester, whose open game re-derives its friend state. Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests; backend integration ListOutgoingRequests + respond-publishes-to-requester; e2e updated for the new lobby section labels + a non-friend active opponent. Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
354 lines
14 KiB
Go
354 lines
14 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
|
|
}
|
|
// Tell the original requester their request was answered, so a game screen watching
|
|
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
|
|
// -> stays "request sent").
|
|
if accept {
|
|
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded))
|
|
} else {
|
|
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined))
|
|
}
|
|
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))
|
|
}
|
|
|
|
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
|
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
|
|
// permanently declined. The game's "add to friends" item reads it to stay disabled
|
|
// across reloads (a declined request reads identically to a still-pending one).
|
|
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
|
return svc.store.listOutgoingRequests(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
|
|
}
|
|
|
|
// listOutgoingRequests returns the addressees of the caller's requests that block a
|
|
// re-send: a live (created after cutoff) pending request, or a permanently declined
|
|
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
|
|
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
|
|
stmt := postgres.SELECT(table.Friendships.AddresseeID).
|
|
FROM(table.Friendships).
|
|
WHERE(
|
|
table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
|
|
OR(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 outgoing requests: %w", err)
|
|
}
|
|
out := make([]uuid.UUID, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, r.AddresseeID)
|
|
}
|
|
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
|
|
}
|