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
+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) {