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.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
const (
|
||||
// friendCodeTTL bounds how long an issued friend code stays redeemable.
|
||||
friendCodeTTL = 12 * time.Hour
|
||||
// friendCodeIssueRetries caps regeneration attempts when a freshly generated
|
||||
// code collides (by hash) with another account's still-live code.
|
||||
friendCodeIssueRetries = 5
|
||||
)
|
||||
|
||||
// FriendCode is a freshly issued one-time add-a-friend code. The plaintext Code is
|
||||
// returned exactly once (only its hash is persisted); the issuer shares it out of
|
||||
// band and whoever redeems it becomes their friend immediately.
|
||||
type FriendCode struct {
|
||||
Code string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// IssueFriendCode issues a fresh one-time friend code for accountID, replacing the
|
||||
// account's prior live code (at most one is redeemable per issuer at a time). Only
|
||||
// the hash is stored; the returned plaintext is the only copy. A collision with
|
||||
// another account's live code triggers a regeneration so the redeem lookup stays
|
||||
// unambiguous.
|
||||
func (svc *Service) IssueFriendCode(ctx context.Context, accountID uuid.UUID) (FriendCode, error) {
|
||||
expiresAt := svc.now().Add(friendCodeTTL)
|
||||
for range friendCodeIssueRetries {
|
||||
code, hash, err := generateFriendCode()
|
||||
if err != nil {
|
||||
return FriendCode{}, err
|
||||
}
|
||||
inserted, err := svc.store.replaceFriendCode(ctx, accountID, hash, expiresAt, svc.now())
|
||||
if err != nil {
|
||||
return FriendCode{}, err
|
||||
}
|
||||
if inserted {
|
||||
return FriendCode{Code: code, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
}
|
||||
return FriendCode{}, fmt.Errorf("social: could not issue a unique friend code after %d tries", friendCodeIssueRetries)
|
||||
}
|
||||
|
||||
// RedeemFriendCode makes redeemerID a friend of the account that issued code,
|
||||
// consuming the code. It returns the issuer's account id on success, or
|
||||
// ErrFriendCodeInvalid (unknown/used/expired), ErrSelfRelation (own code), or
|
||||
// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior
|
||||
// decline between the two: it clears the old row and writes a fresh friendship.
|
||||
func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) {
|
||||
issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now())
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
if issuerID == redeemerID {
|
||||
return uuid.UUID{}, ErrSelfRelation
|
||||
}
|
||||
blocked, err := svc.store.isBlocked(ctx, redeemerID, issuerID)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
if blocked {
|
||||
return uuid.UUID{}, ErrRequestBlocked
|
||||
}
|
||||
if err := svc.store.redeemFriendCode(ctx, codeID, issuerID, redeemerID, svc.now()); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
svc.pub.Publish(notify.Notification(issuerID, notify.NotifyFriendAdded))
|
||||
return issuerID, nil
|
||||
}
|
||||
|
||||
// replaceFriendCode clears accountID's prior live code and inserts a fresh one,
|
||||
// inside one transaction. It reports false (without inserting) when codeHash
|
||||
// collides with another still-live code, so the caller regenerates.
|
||||
func (s *Store) replaceFriendCode(ctx context.Context, accountID uuid.UUID, codeHash string, expiresAt, now time.Time) (bool, error) {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("social: new friend code id: %w", err)
|
||||
}
|
||||
inserted := false
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
del := table.FriendCodes.DELETE().WHERE(
|
||||
table.FriendCodes.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.FriendCodes.ConsumedAt.IS_NULL()),
|
||||
)
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear prior friend codes: %w", err)
|
||||
}
|
||||
var live []model.FriendCodes
|
||||
sel := postgres.SELECT(table.FriendCodes.CodeID).
|
||||
FROM(table.FriendCodes).
|
||||
WHERE(
|
||||
table.FriendCodes.CodeHash.EQ(postgres.String(codeHash)).
|
||||
AND(table.FriendCodes.ConsumedAt.IS_NULL()).
|
||||
AND(table.FriendCodes.ExpiresAt.GT(postgres.TimestampzT(now))),
|
||||
).LIMIT(1)
|
||||
if err := sel.QueryContext(ctx, tx, &live); err != nil {
|
||||
return fmt.Errorf("check friend code collision: %w", err)
|
||||
}
|
||||
if len(live) > 0 {
|
||||
return nil // collision: leave inserted false so the caller retries
|
||||
}
|
||||
ins := table.FriendCodes.INSERT(
|
||||
table.FriendCodes.CodeID, table.FriendCodes.AccountID, table.FriendCodes.CodeHash, table.FriendCodes.ExpiresAt,
|
||||
).VALUES(id, accountID, codeHash, expiresAt)
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert friend code: %w", err)
|
||||
}
|
||||
inserted = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
// liveFriendCodeByHash returns the issuer and code id of the live (unconsumed,
|
||||
// unexpired) code with codeHash, or ErrFriendCodeInvalid when none matches.
|
||||
func (s *Store) liveFriendCodeByHash(ctx context.Context, codeHash string, now time.Time) (issuerID, codeID uuid.UUID, err error) {
|
||||
stmt := postgres.SELECT(table.FriendCodes.CodeID, table.FriendCodes.AccountID).
|
||||
FROM(table.FriendCodes).
|
||||
WHERE(
|
||||
table.FriendCodes.CodeHash.EQ(postgres.String(codeHash)).
|
||||
AND(table.FriendCodes.ConsumedAt.IS_NULL()).
|
||||
AND(table.FriendCodes.ExpiresAt.GT(postgres.TimestampzT(now))),
|
||||
).LIMIT(1)
|
||||
var row model.FriendCodes
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return uuid.UUID{}, uuid.UUID{}, ErrFriendCodeInvalid
|
||||
}
|
||||
return uuid.UUID{}, uuid.UUID{}, fmt.Errorf("social: load friend code: %w", err)
|
||||
}
|
||||
return row.AccountID, row.CodeID, nil
|
||||
}
|
||||
|
||||
// redeemFriendCode consumes the code and writes an accepted friendship between
|
||||
// issuer and redeemer, inside one transaction. It clears any prior pending/declined
|
||||
// row between the pair first, so a code overrides an earlier decline. A code already
|
||||
// consumed by a concurrent redeem yields ErrFriendCodeInvalid (rolling back).
|
||||
func (s *Store) redeemFriendCode(ctx context.Context, codeID, issuer, redeemer uuid.UUID, now time.Time) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
upd := table.FriendCodes.
|
||||
UPDATE(table.FriendCodes.ConsumedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(
|
||||
table.FriendCodes.CodeID.EQ(postgres.UUID(codeID)).
|
||||
AND(table.FriendCodes.ConsumedAt.IS_NULL()),
|
||||
)
|
||||
res, err := upd.ExecContext(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume friend code: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume friend code rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrFriendCodeInvalid
|
||||
}
|
||||
del := table.Friendships.DELETE().WHERE(edgeEither(issuer, redeemer))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear friendship before code accept: %w", err)
|
||||
}
|
||||
ins := table.Friendships.INSERT(
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status,
|
||||
table.Friendships.CreatedAt, table.Friendships.RespondedAt,
|
||||
).VALUES(issuer, redeemer, friendAccepted, now, now)
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert friendship from code: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// generateFriendCode returns a random 6-digit numeric code and its hex SHA-256 hash.
|
||||
func generateFriendCode() (code, hash string, err error) {
|
||||
n, err := crand.Int(crand.Reader, big.NewInt(1_000_000))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("social: generate friend code: %w", err)
|
||||
}
|
||||
code = fmt.Sprintf("%06d", n.Int64())
|
||||
return code, hashFriendCode(code), nil
|
||||
}
|
||||
|
||||
// hashFriendCode returns the hex-encoded SHA-256 of a friend code; the plaintext is
|
||||
// never persisted, matching the session and email-code models.
|
||||
func hashFriendCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -19,12 +20,22 @@ import (
|
||||
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. 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.
|
||||
// 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
|
||||
@@ -43,32 +54,69 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if blocked || addressee.BlockFriendRequests {
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
exists, err := svc.store.friendshipExists(ctx, requesterID, addresseeID)
|
||||
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return ErrRequestExists
|
||||
if !shared {
|
||||
return ErrNoSharedGame
|
||||
}
|
||||
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID); err != nil {
|
||||
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 deletes it.
|
||||
// Either way ErrRequestNotFound is returned when no pending request matches.
|
||||
// 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.deletePendingRequest(ctx, requesterID, addresseeID)
|
||||
ok, err = svc.store.declineFriendRequest(ctx, requesterID, addresseeID, svc.now())
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -102,34 +150,31 @@ func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uui
|
||||
return svc.store.listFriends(ctx, accountID)
|
||||
}
|
||||
|
||||
// ListIncomingRequests returns the account IDs that have a pending friend request
|
||||
// awaiting accountID's response.
|
||||
// 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)
|
||||
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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)).
|
||||
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)
|
||||
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 true, nil
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// insertFriendRequest inserts a pending request from requester to addressee.
|
||||
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID) error {
|
||||
// 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,
|
||||
).VALUES(requester, addressee, friendPending)
|
||||
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)
|
||||
}
|
||||
@@ -151,6 +196,7 @@ func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uu
|
||||
}
|
||||
|
||||
// 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)).
|
||||
@@ -160,6 +206,38 @@ func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee u
|
||||
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(
|
||||
@@ -195,13 +273,15 @@ func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UU
|
||||
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) {
|
||||
// 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.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 {
|
||||
|
||||
@@ -19,11 +19,15 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// accounts in seat order, the seat index whose turn it is, and the game status, plus
|
||||
// a shared-game test. game.Service satisfies it, so chat, nudge and the
|
||||
// befriend-an-opponent gate work 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)
|
||||
// SharedGame reports whether two accounts are seated together in any game
|
||||
// (active or finished); it gates the "befriend an opponent" request path.
|
||||
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// Sentinel errors returned by the service.
|
||||
@@ -38,6 +42,16 @@ var (
|
||||
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")
|
||||
// ErrNoSharedGame is returned when a friend request targets someone the
|
||||
// requester has never shared a game with (the befriend-an-opponent gate).
|
||||
ErrNoSharedGame = errors.New("social: you can only request someone you have played with")
|
||||
// ErrRequestDeclined is returned when the addressee has previously declined a
|
||||
// request from this requester; a re-send is refused (a one-time friend code
|
||||
// from the addressee bypasses this).
|
||||
ErrRequestDeclined = errors.New("social: this person has declined your friend request")
|
||||
// ErrFriendCodeInvalid is returned when a redeemed friend code is unknown,
|
||||
// already used, or expired.
|
||||
ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired")
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user