Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s

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:
Ilia Denisov
2026-06-03 19:47:40 +02:00
parent 539e24fba1
commit d733ce3119
114 changed files with 8210 additions and 149 deletions
+50
View File
@@ -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
}
+16 -1
View File
@@ -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
+18
View File
@@ -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.
+3
View File
@@ -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.
+15
View File
@@ -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()
+10
View File
@@ -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)
}
}
+28
View File
@@ -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)
}
}
+112 -2
View File
@@ -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()
+97 -1
View File
@@ -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) {
+13
View File
@@ -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 {
+12
View File
@@ -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
+12
View File
@@ -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'));
+9 -1
View File
@@ -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))
+18
View File
@@ -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,
+61
View File
@@ -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"
}
+157
View File
@@ -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)})
}
+254
View File
@@ -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)})
}
+26
View File
@@ -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})
}
+209
View File
@@ -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[:])
}
+113 -33
View File
@@ -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 {
+17 -3
View File
@@ -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.