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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user