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,50 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Stats is a durable account's lifetime record, written by the game domain on each
|
||||
// finish and read for the player's statistics screen. MaxGamePoints is the best
|
||||
// single game's total; MaxWordPoints is the best single move's score (which already
|
||||
// includes every word it formed plus the all-tiles bonus).
|
||||
type Stats struct {
|
||||
Wins int
|
||||
Losses int
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
}
|
||||
|
||||
// GetStats returns the lifetime statistics for id. An account with no account_stats
|
||||
// row yet — a guest, or a player who has not finished a game — yields the zero
|
||||
// Stats (all counters zero) rather than an error.
|
||||
func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
|
||||
stmt := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
var row model.AccountStats
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Stats{}, nil
|
||||
}
|
||||
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
|
||||
}
|
||||
return Stats{
|
||||
Wins: int(row.Wins),
|
||||
Losses: int(row.Losses),
|
||||
Draws: int(row.Draws),
|
||||
MaxGamePoints: int(row.MaxGamePoints),
|
||||
MaxWordPoints: int(row.MaxWordPoints),
|
||||
}, nil
|
||||
}
|
||||
@@ -566,6 +566,16 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.
|
||||
return seats, g.ToMove, g.Status, nil
|
||||
}
|
||||
|
||||
// SharedGame reports whether accounts a and b are seated together in any game
|
||||
// (active or finished). It backs the social package's "befriend an opponent"
|
||||
// request gate without exposing the games tables; a self-pair is never shared.
|
||||
func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
if a == b {
|
||||
return false, nil
|
||||
}
|
||||
return svc.store.SharedGameExists(ctx, a, b)
|
||||
}
|
||||
|
||||
// ListForAccount returns every game the account is seated in, newest first, for the
|
||||
// lobby's active/finished lists. The live position is not loaded — the summaries come
|
||||
// straight from the durable rows.
|
||||
@@ -586,12 +596,17 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView,
|
||||
return HistoryView{Game: g, Moves: moves}, nil
|
||||
}
|
||||
|
||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
|
||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
|
||||
// is allowed only on a finished game: exporting an in-progress game would leak the
|
||||
// full move journal mid-play, so an active game yields ErrGameActive.
|
||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if g.Status != StatusFinished {
|
||||
return "", ErrGameActive
|
||||
}
|
||||
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -135,6 +135,24 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||
return projectGame(grow, srows)
|
||||
}
|
||||
|
||||
// SharedGameExists reports whether accounts a and b are both seated in at least
|
||||
// one game (active or finished). It backs the social package's "befriend an
|
||||
// opponent" gate via a self-join on game_players.
|
||||
func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
other := table.GamePlayers.AS("other")
|
||||
stmt := postgres.SELECT(table.GamePlayers.GameID).
|
||||
FROM(table.GamePlayers.INNER_JOIN(other, other.GameID.EQ(table.GamePlayers.GameID))).
|
||||
WHERE(
|
||||
table.GamePlayers.AccountID.EQ(postgres.UUID(a)).
|
||||
AND(other.AccountID.EQ(postgres.UUID(b))),
|
||||
).LIMIT(1)
|
||||
var rows []model.GamePlayers
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return false, fmt.Errorf("game: shared game exists: %w", err)
|
||||
}
|
||||
return len(rows) > 0, nil
|
||||
}
|
||||
|
||||
// ListGamesForAccount loads every game the account is seated in (active and
|
||||
// finished), newest first, each joined with its ordered seats. It backs the lobby's
|
||||
// "my games" lists.
|
||||
|
||||
@@ -29,6 +29,9 @@ var (
|
||||
ErrNotYourTurn = errors.New("game: not the player's turn")
|
||||
// ErrFinished is returned when a transition is attempted on a finished game.
|
||||
ErrFinished = errors.New("game: game is finished")
|
||||
// ErrGameActive is returned when an operation allowed only on a finished game
|
||||
// (such as a GCG export) is attempted while the game is still active.
|
||||
ErrGameActive = errors.New("game: game is still active")
|
||||
// ErrNotAPlayer is returned when an account is not seated in the game.
|
||||
ErrNotAPlayer = errors.New("game: account is not a player in this game")
|
||||
// ErrInvalidConfig is returned when CreateParams are not acceptable.
|
||||
|
||||
@@ -76,6 +76,21 @@ func TestAccountProvisionByIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
|
||||
// reads back the zero statistics rather than an error (the Stage 8 stats screen).
|
||||
func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
st, err := store.GetStats(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
if (st != account.Stats{}) {
|
||||
t.Fatalf("fresh stats = %+v, want zero", st)
|
||||
}
|
||||
}
|
||||
|
||||
// identityConfirmed reads the confirmed flag for one identity directly.
|
||||
func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
t.Helper()
|
||||
|
||||
@@ -555,3 +555,13 @@ func equalStrings(a, b []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
|
||||
// is allowed only once the game is over, so an active game leaks nothing mid-play.
|
||||
func TestExportGCGRefusesActiveGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gameID, _ := newGameWithSeats(t, 2)
|
||||
if _, err := newGameService().ExportGCG(ctx, gameID); !errors.Is(err, game.ErrGameActive) {
|
||||
t.Fatalf("export of active game = %v, want ErrGameActive", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,3 +165,31 @@ func TestInvitationCancelByInviter(t *testing.T) {
|
||||
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListInvitations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitee := provisionAccount(t)
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
// An open invitation appears for both the inviter and the invitee.
|
||||
for _, who := range []uuid.UUID{inviter, invitee} {
|
||||
list, err := svc.ListInvitations(ctx, who)
|
||||
if err != nil {
|
||||
t.Fatalf("list for %s: %v", who, err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != inv.ID {
|
||||
t.Fatalf("invitations for %s = %+v, want [%s]", who, list, inv.ID)
|
||||
}
|
||||
}
|
||||
// Once accepted (the game starts), it is no longer an open invitation.
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitee, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if list, _ := svc.ListInvitations(ctx, inviter); len(list) != 0 {
|
||||
t.Fatalf("started invitation still listed: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||
func TestFriendRequestLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
// A request is only allowed between players who share a game.
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
a, b := seats[0], seats[1]
|
||||
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
@@ -102,7 +104,8 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
|
||||
func TestBlockSeversFriendship(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
a, b := seats[0], seats[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
@@ -117,6 +120,113 @@ func TestBlockSeversFriendship(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendRequestRequiresSharedGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
a, b := provisionAccount(t), provisionAccount(t) // never played together
|
||||
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrNoSharedGame) {
|
||||
t.Fatalf("send without shared game = %v, want ErrNoSharedGame", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendDeclineIsPermanentUntilCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
a, b := seats[0], seats[1]
|
||||
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, false); err != nil { // b declines a
|
||||
t.Fatalf("decline: %v", err)
|
||||
}
|
||||
// An explicit decline is remembered: a cannot re-send.
|
||||
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestDeclined) {
|
||||
t.Fatalf("resend after decline = %v, want ErrRequestDeclined", err)
|
||||
}
|
||||
// But a one-time code from b bypasses the decline.
|
||||
code, err := svc.IssueFriendCode(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("issue code: %v", err)
|
||||
}
|
||||
issuer, err := svc.RedeemFriendCode(ctx, a, code.Code)
|
||||
if err != nil {
|
||||
t.Fatalf("redeem: %v", err)
|
||||
}
|
||||
if issuer != b {
|
||||
t.Fatalf("redeem issuer = %s, want b", issuer)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
|
||||
t.Fatalf("friends of a after code = %v, want [b]", friends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendRequestResendAfterExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
a, b := seats[0], seats[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
// A request older than the 30-day window lazily expires: it leaves the incoming
|
||||
// list and may be re-sent.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, a, b); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 0 {
|
||||
t.Fatalf("expired request still incoming: %v", got)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("resend after expiry: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a {
|
||||
t.Fatalf("re-sent request not incoming: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendCodeSelfAndSingleUse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
a := provisionAccount(t)
|
||||
code, err := svc.IssueFriendCode(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if _, err := svc.RedeemFriendCode(ctx, a, code.Code); !errors.Is(err, social.ErrSelfRelation) {
|
||||
t.Fatalf("self redeem = %v, want ErrSelfRelation", err)
|
||||
}
|
||||
b := provisionAccount(t)
|
||||
if _, err := svc.RedeemFriendCode(ctx, b, code.Code); err != nil {
|
||||
t.Fatalf("redeem: %v", err)
|
||||
}
|
||||
// Single-use: redeeming the same code again fails.
|
||||
if _, err := svc.RedeemFriendCode(ctx, provisionAccount(t), code.Code); !errors.Is(err, social.ErrFriendCodeInvalid) {
|
||||
t.Fatalf("reused code = %v, want ErrFriendCodeInvalid", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
|
||||
t.Fatalf("friends of b = %v, want [a]", friends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
if err := svc.Block(ctx, a, b); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
blocked, err := svc.ListBlocks(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("list blocks: %v", err)
|
||||
}
|
||||
if len(blocked) != 1 || blocked[0] != b {
|
||||
t.Fatalf("blocks = %v, want [b]", blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatPostListAndBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
@@ -76,6 +77,7 @@ type InvitationService struct {
|
||||
games GameCreator
|
||||
accounts *account.Store
|
||||
blocker Blocker
|
||||
pub notify.Publisher
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -88,10 +90,33 @@ func NewInvitationService(store *Store, games GameCreator, accounts *account.Sto
|
||||
games: games,
|
||||
accounts: accounts,
|
||||
blocker: blocker,
|
||||
pub: notify.Nop{},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifier installs the live-event publisher used to nudge invitees' lobby
|
||||
// badges when an invitation arrives and to tell all seats when the game starts. It
|
||||
// must be called during startup wiring; the default is notify.Nop (no live events,
|
||||
// invitees still see the invitation on the next lobby poll).
|
||||
func (svc *InvitationService) SetNotifier(p notify.Publisher) {
|
||||
if p != nil {
|
||||
svc.pub = p
|
||||
}
|
||||
}
|
||||
|
||||
// notify publishes a re-poll Notification of the given sub-kind to each user.
|
||||
func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) {
|
||||
if len(userIDs) == 0 {
|
||||
return
|
||||
}
|
||||
intents := make([]notify.Intent, 0, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
intents = append(intents, notify.Notification(id, kind))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
|
||||
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
|
||||
// invitees distinct and not the inviter, every invitee an existing account with no
|
||||
@@ -147,7 +172,12 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
return svc.store.loadInvitation(ctx, id)
|
||||
inv, err := svc.store.loadInvitation(ctx, id)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
svc.notify(notify.NotifyInvitation, inviteeIDs...)
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// RespondInvitation records accountID's accept or decline of an invitation. A
|
||||
@@ -194,6 +224,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
||||
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.notify(notify.NotifyGameStarted, seats...)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -207,6 +238,26 @@ func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uu
|
||||
return svc.store.loadInvitation(ctx, invitationID)
|
||||
}
|
||||
|
||||
// ListInvitations returns the open (pending, not yet expired) invitations that
|
||||
// touch accountID, whether as the inviter or an invitee, newest first. Expired
|
||||
// invitations are hidden here (lazy expiry); the row's transition to 'expired'
|
||||
// happens on the next response or cancel.
|
||||
func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uuid.UUID) ([]Invitation, error) {
|
||||
ids, err := svc.store.listInvitationIDs(ctx, accountID, svc.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Invitation, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
inv, err := svc.store.loadInvitation(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// invitationInsert carries the immutable fields of a new invitation.
|
||||
type invitationInsert struct {
|
||||
id uuid.UUID
|
||||
@@ -297,6 +348,51 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// listInvitationIDs returns the ids of every pending, still-live invitation that
|
||||
// accountID is part of (as inviter or invitee), newest first. It runs two queries
|
||||
// (one per role) and merges them, avoiding a correlated subquery.
|
||||
func (s *Store) listInvitationIDs(ctx context.Context, accountID uuid.UUID, now time.Time) ([]uuid.UUID, error) {
|
||||
live := table.GameInvitations.Status.EQ(postgres.String(invitationPending)).
|
||||
AND(table.GameInvitations.ExpiresAt.GT(postgres.TimestampzT(now)))
|
||||
|
||||
var asInviter []model.GameInvitations
|
||||
q1 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt).
|
||||
FROM(table.GameInvitations).
|
||||
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(accountID)).AND(live))
|
||||
if err := q1.QueryContext(ctx, s.db, &asInviter); err != nil {
|
||||
return nil, fmt.Errorf("lobby: list invitations as inviter: %w", err)
|
||||
}
|
||||
|
||||
var asInvitee []model.GameInvitations
|
||||
q2 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt).
|
||||
FROM(table.GameInvitations.INNER_JOIN(
|
||||
table.GameInvitationInvitees,
|
||||
table.GameInvitationInvitees.InvitationID.EQ(table.GameInvitations.InvitationID),
|
||||
)).
|
||||
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID)).AND(live))
|
||||
if err := q2.QueryContext(ctx, s.db, &asInvitee); err != nil {
|
||||
return nil, fmt.Errorf("lobby: list invitations as invitee: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[uuid.UUID]bool, len(asInviter)+len(asInvitee))
|
||||
merged := make([]model.GameInvitations, 0, len(asInviter)+len(asInvitee))
|
||||
for _, r := range append(asInviter, asInvitee...) {
|
||||
if seen[r.InvitationID] {
|
||||
continue
|
||||
}
|
||||
seen[r.InvitationID] = true
|
||||
merged = append(merged, r)
|
||||
}
|
||||
slices.SortFunc(merged, func(a, b model.GameInvitations) int {
|
||||
return b.CreatedAt.Compare(a.CreatedAt)
|
||||
})
|
||||
out := make([]uuid.UUID, len(merged))
|
||||
for i, r := range merged {
|
||||
out[i] = r.InvitationID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// respondTx applies an invitee's response inside a row-locked transaction so
|
||||
// concurrent responses serialise and exactly one accept can complete the set.
|
||||
func (s *Store) respondTx(ctx context.Context, invitationID, accountID uuid.UUID, accept bool, now time.Time) (respondResult, error) {
|
||||
|
||||
@@ -83,6 +83,19 @@ func MatchFound(userID, gameID uuid.UUID) Intent {
|
||||
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
|
||||
// scope its refresh.
|
||||
func Notification(userID uuid.UUID, kind string) Intent {
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
k := b.CreateString(kind)
|
||||
fb.NotificationEventStart(b)
|
||||
fb.NotificationEventAddKind(b, k)
|
||||
b.Finish(fb.NotificationEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// eventID returns a best-effort correlation id for one emitted event.
|
||||
func eventID() string {
|
||||
if id, err := uuid.NewV7(); err == nil {
|
||||
|
||||
@@ -24,6 +24,18 @@ const (
|
||||
KindChatMessage = "chat_message"
|
||||
KindNudge = "nudge"
|
||||
KindMatchFound = "match_found"
|
||||
// KindNotification is a lightweight "re-poll your lobby counters" signal
|
||||
// (incoming friend requests, invitations) that drives the lobby badge.
|
||||
KindNotification = "notify"
|
||||
)
|
||||
|
||||
// Notification sub-kinds carried in a KindNotification event payload; the client
|
||||
// re-fetches its lobby counters on any of them.
|
||||
const (
|
||||
NotifyFriendRequest = "friend_request"
|
||||
NotifyFriendAdded = "friend_added"
|
||||
NotifyInvitation = "invitation"
|
||||
NotifyGameStarted = "game_started"
|
||||
)
|
||||
|
||||
// Intent is one live event destined for a single user. Payload is the
|
||||
|
||||
@@ -98,3 +98,15 @@ func TestChatMessagePayloadRoundTrips(t *testing.T) {
|
||||
t.Fatalf("decoded wrong chat message: %+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationPayloadRoundTrips(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
in := notify.Notification(uid, notify.NotifyFriendRequest)
|
||||
if in.UserID != uid || in.Kind != notify.KindNotification || in.EventID == "" {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
|
||||
if got := string(ev.Kind()); got != notify.NotifyFriendRequest {
|
||||
t.Fatalf("notification sub-kind = %q, want %q", got, notify.NotifyFriendRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FriendCodes struct {
|
||||
CodeID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
CodeHash string
|
||||
ExpiresAt time.Time
|
||||
ConsumedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var FriendCodes = newFriendCodesTable("backend", "friend_codes", "")
|
||||
|
||||
type friendCodesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
CodeID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
CodeHash postgres.ColumnString
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
ConsumedAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type FriendCodesTable struct {
|
||||
friendCodesTable
|
||||
|
||||
EXCLUDED friendCodesTable
|
||||
}
|
||||
|
||||
// AS creates new FriendCodesTable with assigned alias
|
||||
func (a FriendCodesTable) AS(alias string) *FriendCodesTable {
|
||||
return newFriendCodesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new FriendCodesTable with assigned schema name
|
||||
func (a FriendCodesTable) FromSchema(schemaName string) *FriendCodesTable {
|
||||
return newFriendCodesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new FriendCodesTable with assigned table prefix
|
||||
func (a FriendCodesTable) WithPrefix(prefix string) *FriendCodesTable {
|
||||
return newFriendCodesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new FriendCodesTable with assigned table suffix
|
||||
func (a FriendCodesTable) WithSuffix(suffix string) *FriendCodesTable {
|
||||
return newFriendCodesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newFriendCodesTable(schemaName, tableName, alias string) *FriendCodesTable {
|
||||
return &FriendCodesTable{
|
||||
friendCodesTable: newFriendCodesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newFriendCodesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newFriendCodesTableImpl(schemaName, tableName, alias string) friendCodesTable {
|
||||
var (
|
||||
CodeIDColumn = postgres.StringColumn("code_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
CodeHashColumn = postgres.StringColumn("code_hash")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{CodeIDColumn, AccountIDColumn, CodeHashColumn, ExpiresAtColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, CodeHashColumn, ExpiresAtColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
return friendCodesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
CodeID: CodeIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
CodeHash: CodeHashColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
ConsumedAt: ConsumedAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ func UseSchema(schema string) {
|
||||
ChatMessages = ChatMessages.FromSchema(schema)
|
||||
Complaints = Complaints.FromSchema(schema)
|
||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||
FriendCodes = FriendCodes.FromSchema(schema)
|
||||
Friendships = Friendships.FromSchema(schema)
|
||||
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
|
||||
GameInvitations = GameInvitations.FromSchema(schema)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- +goose Up
|
||||
-- Stage 8 social UI: two changes to the friend graph.
|
||||
--
|
||||
-- 1. A declined friend request is now remembered permanently (status 'declined')
|
||||
-- instead of deleting the row, so a recipient's explicit "no" blocks the same
|
||||
-- requester from re-sending (anti-spam). An ignored request still lazily
|
||||
-- expires (30 days, computed from created_at in Go) and can then be re-sent; a
|
||||
-- one-time friend code from the same person bypasses a prior decline. This
|
||||
-- widens friendships_status_chk; the Stage 4 "declining deletes the row" rule
|
||||
-- is superseded (cancelling by the requester still deletes).
|
||||
--
|
||||
-- 2. friend_codes backs the code-redeem add-a-friend path: the player who wants to
|
||||
-- be added issues a one-time 6-digit numeric code; whoever enters it becomes
|
||||
-- their friend immediately. Only the hex-encoded SHA-256 of the code is stored
|
||||
-- (the plaintext is never persisted, matching the session and email-code
|
||||
-- models); expires_at bounds the 12h TTL and consumed_at marks single use. At
|
||||
-- most one live code exists per issuer (issuing a new one clears the prior
|
||||
-- unconsumed code, enforced in Go). This adds a table, so the generated jet code
|
||||
-- is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE friendships
|
||||
DROP CONSTRAINT friendships_status_chk,
|
||||
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined'));
|
||||
|
||||
CREATE TABLE friend_codes (
|
||||
code_id uuid PRIMARY KEY,
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
code_hash text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Backs "clear the issuer's prior live code" on issue.
|
||||
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
|
||||
-- Backs the redeem lookup by code hash.
|
||||
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
DROP TABLE friend_codes;
|
||||
ALTER TABLE friendships
|
||||
DROP CONSTRAINT friendships_status_chk,
|
||||
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted'));
|
||||
@@ -32,12 +32,15 @@ type resolveResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// profileResponse is the authenticated account's own profile.
|
||||
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
||||
// are the daily away window's "HH:MM" local-time bounds (in TimeZone).
|
||||
type profileResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
@@ -149,6 +152,8 @@ func profileResponseFor(acc account.Account) profileResponse {
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
HintBalance: acc.HintBalance,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
@@ -156,6 +161,9 @@ func profileResponseFor(acc account.Account) profileResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// awayTimeLayout is the "HH:MM" wire form of the daily away-window bounds.
|
||||
const awayTimeLayout = "15:04"
|
||||
|
||||
// gameDTOFromGame projects a game.Game into its DTO.
|
||||
func gameDTOFromGame(g game.Game) gameDTO {
|
||||
seats := make([]seatDTO, 0, len(g.Seats))
|
||||
|
||||
@@ -96,6 +96,24 @@ func TestGameDTOFromGame(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileResponseForAwayWindow(t *testing.T) {
|
||||
acc := account.Account{
|
||||
ID: uuid.New(),
|
||||
DisplayName: "Kaya",
|
||||
PreferredLanguage: "ru",
|
||||
TimeZone: "Europe/Moscow",
|
||||
AwayStart: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
AwayEnd: time.Date(0, 1, 1, 7, 30, 0, 0, time.UTC),
|
||||
}
|
||||
dto := profileResponseFor(acc)
|
||||
if dto.AwayStart != "00:00" || dto.AwayEnd != "07:30" {
|
||||
t.Fatalf("away window = (%q, %q), want (00:00, 07:30)", dto.AwayStart, dto.AwayEnd)
|
||||
}
|
||||
if dto.PreferredLanguage != "ru" || dto.TimeZone != "Europe/Moscow" {
|
||||
t.Fatalf("profile dto mismatch: %+v", dto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRecordDTOFrom(t *testing.T) {
|
||||
rec := engine.MoveRecord{
|
||||
Player: 1,
|
||||
|
||||
@@ -35,6 +35,12 @@ func (s *Server) registerRoutes() {
|
||||
u := s.user
|
||||
if s.accounts != nil {
|
||||
u.GET("/profile", s.handleProfile)
|
||||
u.PUT("/profile", s.handleUpdateProfile)
|
||||
u.GET("/stats", s.handleStats)
|
||||
}
|
||||
if s.emails != nil {
|
||||
u.POST("/email/request", s.handleEmailBindRequest)
|
||||
u.POST("/email/confirm", s.handleEmailBindConfirm)
|
||||
}
|
||||
if s.games != nil {
|
||||
u.GET("/games", s.handleListGames)
|
||||
@@ -48,15 +54,34 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/games/:id/check_word", s.handleCheckWord)
|
||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||
u.GET("/games/:id/history", s.handleHistory)
|
||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||
}
|
||||
if s.matchmaker != nil {
|
||||
u.POST("/lobby/enqueue", s.handleEnqueue)
|
||||
u.GET("/lobby/poll", s.handlePoll)
|
||||
}
|
||||
if s.invitations != nil {
|
||||
u.GET("/invitations", s.handleListInvitations)
|
||||
u.POST("/invitations", s.handleCreateInvitation)
|
||||
u.POST("/invitations/:id/accept", s.handleAcceptInvitation)
|
||||
u.POST("/invitations/:id/decline", s.handleDeclineInvitation)
|
||||
u.DELETE("/invitations/:id", s.handleCancelInvitation)
|
||||
}
|
||||
if s.social != nil {
|
||||
u.POST("/games/:id/chat", s.handleChatPost)
|
||||
u.GET("/games/:id/chat", s.handleChatList)
|
||||
u.POST("/games/:id/nudge", s.handleNudge)
|
||||
u.GET("/friends", s.handleListFriends)
|
||||
u.GET("/friends/incoming", s.handleIncomingRequests)
|
||||
u.POST("/friends/request", s.handleFriendRequest)
|
||||
u.POST("/friends/respond", s.handleFriendRespond)
|
||||
u.POST("/friends/cancel", s.handleFriendCancel)
|
||||
u.DELETE("/friends/:id", s.handleUnfriend)
|
||||
u.POST("/friends/code", s.handleIssueFriendCode)
|
||||
u.POST("/friends/code/redeem", s.handleRedeemFriendCode)
|
||||
u.GET("/blocks", s.handleListBlocks)
|
||||
u.POST("/blocks", s.handleBlock)
|
||||
u.DELETE("/blocks/:id", s.handleUnblock)
|
||||
}
|
||||
s.admin.GET("/ping", s.handleAdminPing)
|
||||
}
|
||||
@@ -117,8 +142,30 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "not_your_turn"
|
||||
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
||||
return http.StatusConflict, "game_finished"
|
||||
case errors.Is(err, game.ErrGameActive):
|
||||
return http.StatusConflict, "game_active"
|
||||
case errors.Is(err, account.ErrInvalidProfile):
|
||||
return http.StatusBadRequest, "invalid_profile"
|
||||
case errors.Is(err, account.ErrAlreadyConfirmed):
|
||||
return http.StatusConflict, "already_confirmed"
|
||||
case errors.Is(err, lobby.ErrAlreadyQueued):
|
||||
return http.StatusConflict, "already_queued"
|
||||
case errors.Is(err, lobby.ErrInvalidInvitation):
|
||||
return http.StatusBadRequest, "invalid_invitation"
|
||||
case errors.Is(err, lobby.ErrInvitationBlocked):
|
||||
return http.StatusForbidden, "invitation_blocked"
|
||||
case errors.Is(err, lobby.ErrInvitationNotFound):
|
||||
return http.StatusNotFound, "invitation_not_found"
|
||||
case errors.Is(err, lobby.ErrInvitationNotPending):
|
||||
return http.StatusConflict, "invitation_not_pending"
|
||||
case errors.Is(err, lobby.ErrInvitationExpired):
|
||||
return http.StatusConflict, "invitation_expired"
|
||||
case errors.Is(err, lobby.ErrNotInvited):
|
||||
return http.StatusForbidden, "not_invited"
|
||||
case errors.Is(err, lobby.ErrAlreadyResponded):
|
||||
return http.StatusConflict, "already_responded"
|
||||
case errors.Is(err, lobby.ErrNotInviter):
|
||||
return http.StatusForbidden, "not_inviter"
|
||||
case errors.Is(err, game.ErrInvalidConfig):
|
||||
return http.StatusBadRequest, "invalid_config"
|
||||
case errors.Is(err, game.ErrNoHintAvailable):
|
||||
@@ -142,6 +189,20 @@ func statusForError(err error) (int, string) {
|
||||
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
|
||||
errors.Is(err, social.ErrNudgeTooSoon):
|
||||
return http.StatusUnprocessableEntity, "chat_rejected"
|
||||
case errors.Is(err, social.ErrSelfRelation):
|
||||
return http.StatusBadRequest, "self_relation"
|
||||
case errors.Is(err, social.ErrRequestExists):
|
||||
return http.StatusConflict, "request_exists"
|
||||
case errors.Is(err, social.ErrRequestBlocked):
|
||||
return http.StatusForbidden, "request_blocked"
|
||||
case errors.Is(err, social.ErrRequestNotFound):
|
||||
return http.StatusNotFound, "request_not_found"
|
||||
case errors.Is(err, social.ErrNoSharedGame):
|
||||
return http.StatusForbidden, "no_shared_game"
|
||||
case errors.Is(err, social.ErrRequestDeclined):
|
||||
return http.StatusConflict, "request_declined"
|
||||
case errors.Is(err, social.ErrFriendCodeInvalid):
|
||||
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
||||
default:
|
||||
return http.StatusInternalServerError, "internal"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// The /api/v1/user account handlers wire profile editing, email binding and the
|
||||
// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a
|
||||
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
|
||||
// client sends the complete desired profile.
|
||||
|
||||
// updateProfileRequest is the full editable profile. away_start/away_end are
|
||||
// "HH:MM" local-time bounds of the daily away window.
|
||||
type updateProfileRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
}
|
||||
|
||||
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
||||
// win-rate are computed client-side).
|
||||
type statsDTO struct {
|
||||
Wins int `json:"wins"`
|
||||
Losses int `json:"losses"`
|
||||
Draws int `json:"draws"`
|
||||
MaxGamePoints int `json:"max_game_points"`
|
||||
MaxWordPoints int `json:"max_word_points"`
|
||||
}
|
||||
|
||||
// emailBindRequestBody starts binding an email to the caller's account.
|
||||
type emailBindRequestBody struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// emailBindConfirmBody completes binding an email with its confirm code.
|
||||
type emailBindConfirmBody struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// parseAwayTime parses an "HH:MM" away-window bound.
|
||||
func parseAwayTime(s string) (time.Time, bool) {
|
||||
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// handleUpdateProfile overwrites the caller's editable profile fields.
|
||||
func (s *Server) handleUpdateProfile(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req updateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
awayStart, ok := parseAwayTime(req.AwayStart)
|
||||
if !ok {
|
||||
abortBadRequest(c, "away_start must be HH:MM")
|
||||
return
|
||||
}
|
||||
awayEnd, ok := parseAwayTime(req.AwayEnd)
|
||||
if !ok {
|
||||
abortBadRequest(c, "away_end must be HH:MM")
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
|
||||
DisplayName: req.DisplayName,
|
||||
PreferredLanguage: req.PreferredLanguage,
|
||||
TimeZone: req.TimeZone,
|
||||
AwayStart: awayStart,
|
||||
AwayEnd: awayEnd,
|
||||
BlockChat: req.BlockChat,
|
||||
BlockFriendRequests: req.BlockFriendRequests,
|
||||
})
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||
}
|
||||
|
||||
// handleStats returns the caller's lifetime statistics.
|
||||
func (s *Server) handleStats(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
st, err := s.accounts.GetStats(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, statsDTO{
|
||||
Wins: st.Wins,
|
||||
Losses: st.Losses,
|
||||
Draws: st.Draws,
|
||||
MaxGamePoints: st.MaxGamePoints,
|
||||
MaxWordPoints: st.MaxWordPoints,
|
||||
})
|
||||
}
|
||||
|
||||
// handleEmailBindRequest issues a confirm code to bind an email to the caller.
|
||||
func (s *Server) handleEmailBindRequest(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req emailBindRequestBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleEmailBindConfirm verifies the code and binds the email, returning the
|
||||
// updated profile.
|
||||
func (s *Server) handleEmailBindConfirm(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req emailBindConfirmBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block
|
||||
// is mutual in effect (the social checks apply it both ways) and severs any
|
||||
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
|
||||
// account-ref resolution.
|
||||
|
||||
// blockListDTO is the accounts the caller has blocked.
|
||||
type blockListDTO struct {
|
||||
Blocked []accountRefDTO `json:"blocked"`
|
||||
}
|
||||
|
||||
// handleBlock blocks the body-supplied account.
|
||||
func (s *Server) handleBlock(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req targetIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
target, ok := parseUUIDField(req.AccountID)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid account id")
|
||||
return
|
||||
}
|
||||
if err := s.social.Block(c.Request.Context(), uid, target); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleUnblock removes the caller's block on the :id account.
|
||||
func (s *Server) handleUnblock(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
target, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
abortBadRequest(c, "invalid account id")
|
||||
return
|
||||
}
|
||||
if err := s.social.Unblock(c.Request.Context(), uid, target); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleListBlocks returns the accounts the caller has blocked.
|
||||
func (s *Server) handleListBlocks(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
ids, err := s.social.ListBlocks(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, blockListDTO{Blocked: s.accountRefs(c.Request.Context(), ids)})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// The /api/v1/user/friends/* handlers wire the social friend graph (Stage 8): the
|
||||
// befriend-an-opponent request flow, the one-time friend-code path, and the
|
||||
// friends/incoming lists. They follow handlers_user.go: X-User-ID identity, a domain
|
||||
// call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved
|
||||
// from the account store, mirroring fillSeatNames.
|
||||
|
||||
// accountRefDTO is a referenced account with its display name resolved for the UI.
|
||||
type accountRefDTO struct {
|
||||
AccountID string `json:"account_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// friendListDTO is the caller's accepted friends.
|
||||
type friendListDTO struct {
|
||||
Friends []accountRefDTO `json:"friends"`
|
||||
}
|
||||
|
||||
// incomingListDTO is the friend requests awaiting the caller's response.
|
||||
type incomingListDTO struct {
|
||||
Requests []accountRefDTO `json:"requests"`
|
||||
}
|
||||
|
||||
// friendCodeDTO is a freshly issued one-time friend code (returned once).
|
||||
type friendCodeDTO struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresAtUnix int64 `json:"expires_at_unix"`
|
||||
}
|
||||
|
||||
// redeemResultDTO reports the new friend gained by redeeming a code.
|
||||
type redeemResultDTO struct {
|
||||
Friend accountRefDTO `json:"friend"`
|
||||
}
|
||||
|
||||
// targetIDRequest carries a single counterpart account id.
|
||||
type targetIDRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
}
|
||||
|
||||
// friendRespondRequest accepts or declines a pending request from a requester.
|
||||
type friendRespondRequest struct {
|
||||
RequesterID string `json:"requester_id"`
|
||||
Accept bool `json:"accept"`
|
||||
}
|
||||
|
||||
// redeemCodeRequest carries a friend code to redeem.
|
||||
type redeemCodeRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// namedRef resolves a single account id into its display-name ref, caching the
|
||||
// lookup in memo so a caller can share it across many refs in one response.
|
||||
func (s *Server) namedRef(ctx context.Context, id uuid.UUID, memo map[string]string) accountRefDTO {
|
||||
key := id.String()
|
||||
name, ok := memo[key]
|
||||
if !ok {
|
||||
if acc, err := s.accounts.GetByID(ctx, id); err == nil {
|
||||
name = acc.DisplayName
|
||||
}
|
||||
memo[key] = name
|
||||
}
|
||||
return accountRefDTO{AccountID: key, DisplayName: name}
|
||||
}
|
||||
|
||||
// accountRefs resolves a list of account ids into display-name refs, memoising
|
||||
// lookups within the call.
|
||||
func (s *Server) accountRefs(ctx context.Context, ids []uuid.UUID) []accountRefDTO {
|
||||
memo := map[string]string{}
|
||||
out := make([]accountRefDTO, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, s.namedRef(ctx, id, memo))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountRef resolves a single account id into its display-name ref.
|
||||
func (s *Server) accountRef(ctx context.Context, id uuid.UUID) accountRefDTO {
|
||||
return s.namedRef(ctx, id, map[string]string{})
|
||||
}
|
||||
|
||||
// parseUUIDField parses a body-supplied account id, trimming whitespace.
|
||||
func parseUUIDField(s string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// handleFriendRequest sends a friend request to an opponent the caller has played.
|
||||
func (s *Server) handleFriendRequest(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req targetIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
target, ok := parseUUIDField(req.AccountID)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid account id")
|
||||
return
|
||||
}
|
||||
if err := s.social.SendFriendRequest(c.Request.Context(), uid, target); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleFriendRespond accepts or declines a pending incoming request.
|
||||
func (s *Server) handleFriendRespond(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req friendRespondRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
requester, ok := parseUUIDField(req.RequesterID)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid requester id")
|
||||
return
|
||||
}
|
||||
if err := s.social.RespondFriendRequest(c.Request.Context(), uid, requester, req.Accept); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleFriendCancel withdraws the caller's own pending request.
|
||||
func (s *Server) handleFriendCancel(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req targetIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
target, ok := parseUUIDField(req.AccountID)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid account id")
|
||||
return
|
||||
}
|
||||
if err := s.social.CancelFriendRequest(c.Request.Context(), uid, target); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleUnfriend removes a friendship with the :id account.
|
||||
func (s *Server) handleUnfriend(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
other, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
abortBadRequest(c, "invalid account id")
|
||||
return
|
||||
}
|
||||
if err := s.social.Unfriend(c.Request.Context(), uid, other); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleListFriends returns the caller's accepted friends.
|
||||
func (s *Server) handleListFriends(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
ids, err := s.social.ListFriends(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, friendListDTO{Friends: s.accountRefs(c.Request.Context(), ids)})
|
||||
}
|
||||
|
||||
// handleIncomingRequests returns the friend requests awaiting the caller.
|
||||
func (s *Server) handleIncomingRequests(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
ids, err := s.social.ListIncomingRequests(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
||||
}
|
||||
|
||||
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
|
||||
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
code, err := s.social.IssueFriendCode(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, friendCodeDTO{Code: code.Code, ExpiresAtUnix: code.ExpiresAt.Unix()})
|
||||
}
|
||||
|
||||
// handleRedeemFriendCode redeems a friend code, befriending its issuer.
|
||||
func (s *Server) handleRedeemFriendCode(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req redeemCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
issuer, err := s.social.RedeemFriendCode(c.Request.Context(), uid, strings.TrimSpace(req.Code))
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, redeemResultDTO{Friend: s.accountRef(c.Request.Context(), issuer)})
|
||||
}
|
||||
@@ -243,6 +243,32 @@ func (s *Server) handleHistory(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
|
||||
}
|
||||
|
||||
// gcgDTO is a game's GCG export: a suggested filename plus the GCG text.
|
||||
type gcgDTO struct {
|
||||
GameID string `json:"game_id"`
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// handleExportGCG returns a finished game's GCG transcript for download/share. The
|
||||
// service refuses an active game (ErrGameActive) to avoid leaking the live journal.
|
||||
func (s *Server) handleExportGCG(c *gin.Context) {
|
||||
_, gameID, ok := s.userGame(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gcgDTO{
|
||||
GameID: gameID.String(),
|
||||
Filename: "game-" + gameID.String() + ".gcg",
|
||||
Content: gcg,
|
||||
})
|
||||
}
|
||||
|
||||
// handleListGames returns the caller's active and finished games for the lobby.
|
||||
func (s *Server) handleListGames(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/lobby"
|
||||
)
|
||||
|
||||
// The /api/v1/user/invitations/* handlers wire friend-game invitations (Stage 8):
|
||||
// create a 2-4 player invitation, accept/decline as an invitee, cancel as the
|
||||
// inviter, and list the open invitations touching the caller. Display names for the
|
||||
// inviter and invitees are resolved from the account store.
|
||||
|
||||
// invitationInviteeDTO is one invitee's seat and response with their name resolved.
|
||||
type invitationInviteeDTO struct {
|
||||
AccountID string `json:"account_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Seat int `json:"seat"`
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// invitationDTO is a friend-game invitation with its settings and invitees.
|
||||
type invitationDTO struct {
|
||||
ID string `json:"id"`
|
||||
Inviter accountRefDTO `json:"inviter"`
|
||||
Invitees []invitationInviteeDTO `json:"invitees"`
|
||||
Variant string `json:"variant"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
HintsAllowed bool `json:"hints_allowed"`
|
||||
HintsPerPlayer int `json:"hints_per_player"`
|
||||
DropoutTiles string `json:"dropout_tiles"`
|
||||
Status string `json:"status"`
|
||||
GameID string `json:"game_id,omitempty"`
|
||||
ExpiresAtUnix int64 `json:"expires_at_unix"`
|
||||
}
|
||||
|
||||
// invitationListDTO is the caller's open invitations.
|
||||
type invitationListDTO struct {
|
||||
Invitations []invitationDTO `json:"invitations"`
|
||||
}
|
||||
|
||||
// createInvitationRequest proposes a friend game to the named invitees.
|
||||
type createInvitationRequest struct {
|
||||
InviteeIDs []string `json:"invitee_ids"`
|
||||
Variant string `json:"variant"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
HintsAllowed bool `json:"hints_allowed"`
|
||||
HintsPerPlayer int `json:"hints_per_player"`
|
||||
DropoutTiles string `json:"dropout_tiles"`
|
||||
}
|
||||
|
||||
// invitationDTOFrom projects a lobby invitation, resolving names through memo.
|
||||
func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO {
|
||||
dto := invitationDTO{
|
||||
ID: inv.ID.String(),
|
||||
Inviter: s.namedRef(ctx, inv.InviterID, memo),
|
||||
Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)),
|
||||
Variant: inv.Settings.Variant.String(),
|
||||
TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()),
|
||||
HintsAllowed: inv.Settings.HintsAllowed,
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
||||
Status: inv.Status,
|
||||
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
||||
}
|
||||
if inv.GameID != nil {
|
||||
dto.GameID = inv.GameID.String()
|
||||
}
|
||||
for _, iv := range inv.Invitees {
|
||||
ref := s.namedRef(ctx, iv.AccountID, memo)
|
||||
dto.Invitees = append(dto.Invitees, invitationInviteeDTO{
|
||||
AccountID: ref.AccountID,
|
||||
DisplayName: ref.DisplayName,
|
||||
Seat: iv.Seat,
|
||||
Response: iv.Response,
|
||||
})
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// handleCreateInvitation records a new friend-game invitation from the caller.
|
||||
func (s *Server) handleCreateInvitation(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req createInvitationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
variant, err := engine.ParseVariant(req.Variant)
|
||||
if err != nil {
|
||||
abortBadRequest(c, "unknown variant")
|
||||
return
|
||||
}
|
||||
settings := lobby.InvitationSettings{
|
||||
Variant: variant,
|
||||
HintsAllowed: req.HintsAllowed,
|
||||
HintsPerPlayer: req.HintsPerPlayer,
|
||||
}
|
||||
if req.TurnTimeoutSecs > 0 {
|
||||
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
|
||||
}
|
||||
if req.DropoutTiles != "" {
|
||||
dropout, err := engine.ParseDropoutTiles(req.DropoutTiles)
|
||||
if err != nil {
|
||||
abortBadRequest(c, "unknown dropout_tiles")
|
||||
return
|
||||
}
|
||||
settings.DropoutTiles = dropout
|
||||
}
|
||||
inviteeIDs := make([]uuid.UUID, 0, len(req.InviteeIDs))
|
||||
for _, raw := range req.InviteeIDs {
|
||||
id, ok := parseUUIDField(raw)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid invitee id")
|
||||
return
|
||||
}
|
||||
inviteeIDs = append(inviteeIDs, id)
|
||||
}
|
||||
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{}))
|
||||
}
|
||||
|
||||
// handleAcceptInvitation records the caller's acceptance, starting the game when it
|
||||
// completes the set.
|
||||
func (s *Server) handleAcceptInvitation(c *gin.Context) {
|
||||
s.respondInvitation(c, true)
|
||||
}
|
||||
|
||||
// handleDeclineInvitation records the caller's decline, cancelling the invitation.
|
||||
func (s *Server) handleDeclineInvitation(c *gin.Context) {
|
||||
s.respondInvitation(c, false)
|
||||
}
|
||||
|
||||
// respondInvitation applies the caller's accept/decline to the :id invitation.
|
||||
func (s *Server) respondInvitation(c *gin.Context, accept bool) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
abortBadRequest(c, "invalid invitation id")
|
||||
return
|
||||
}
|
||||
inv, err := s.invitations.RespondInvitation(c.Request.Context(), id, uid, accept)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{}))
|
||||
}
|
||||
|
||||
// handleCancelInvitation withdraws the caller's own pending invitation.
|
||||
func (s *Server) handleCancelInvitation(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
abortBadRequest(c, "invalid invitation id")
|
||||
return
|
||||
}
|
||||
if err := s.invitations.CancelInvitation(c.Request.Context(), id, uid); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// handleListInvitations returns the open invitations touching the caller.
|
||||
func (s *Server) handleListInvitations(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
invs, err := s.invitations.ListInvitations(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
memo := map[string]string{}
|
||||
out := make([]invitationDTO, 0, len(invs))
|
||||
for _, inv := range invs {
|
||||
out = append(out, s.invitationDTOFrom(c.Request.Context(), inv, memo))
|
||||
}
|
||||
c.JSON(http.StatusOK, invitationListDTO{Invitations: out})
|
||||
}
|
||||
@@ -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