Files
scrabble-game/backend/internal/lobby/invitations.go
T
Ilia Denisov bfa8797f8c
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s
Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
2026-06-02 19:29:30 +02:00

460 lines
16 KiB
Go

package lobby
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// invitationTTL is how long an unanswered invitation stays open before it lazily
// expires.
const invitationTTL = 7 * 24 * time.Hour
// Invitation statuses.
const (
invitationPending = "pending"
invitationDeclined = "declined"
invitationCancelled = "cancelled"
invitationExpired = "expired"
invitationStarted = "started"
)
// Invitee responses.
const (
inviteePending = "pending"
inviteeAccepted = "accepted"
inviteeDeclined = "declined"
)
// InvitationSettings are the game settings an inviter chooses. A zero TurnTimeout
// defaults to game.DefaultTurnTimeout; the zero DropoutTiles removes a leaver's
// tiles from play.
type InvitationSettings struct {
Variant engine.Variant
TurnTimeout time.Duration
HintsAllowed bool
HintsPerPlayer int
DropoutTiles engine.DropoutTiles
}
// Invitee is one invited player's seat and response.
type Invitee struct {
AccountID uuid.UUID
Seat int
Response string
}
// Invitation is a friend-game invitation with its invitees.
type Invitation struct {
ID uuid.UUID
InviterID uuid.UUID
Settings InvitationSettings
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
Invitees []Invitee
}
// InvitationService creates and resolves friend-game invitations, starting the
// game through a GameCreator once every invitee has accepted.
type InvitationService struct {
store *Store
games GameCreator
accounts *account.Store
blocker Blocker
now func() time.Time
}
// NewInvitationService constructs an InvitationService. store owns the invitation
// tables; games starts the accepted game; accounts validates invitees; blocker
// refuses invitations across a block.
func NewInvitationService(store *Store, games GameCreator, accounts *account.Store, blocker Blocker) *InvitationService {
return &InvitationService{
store: store,
games: games,
accounts: accounts,
blocker: blocker,
now: func() time.Time { return time.Now().UTC() },
}
}
// 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
// block standing between them, and the settings acceptable.
func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uuid.UUID, inviteeIDs []uuid.UUID, settings InvitationSettings) (Invitation, error) {
if n := len(inviteeIDs) + 1; n < 2 || n > 4 {
return Invitation{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidInvitation, n)
}
if settings.HintsPerPlayer < 0 {
return Invitation{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidInvitation)
}
if settings.TurnTimeout == 0 {
settings.TurnTimeout = game.DefaultTurnTimeout
}
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
}
seen := map[uuid.UUID]bool{inviterID: true}
for _, id := range inviteeIDs {
if seen[id] {
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Invitation{}, fmt.Errorf("%w: invitee %s not found", ErrInvalidInvitation, id)
}
return Invitation{}, err
}
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
if err != nil {
return Invitation{}, err
}
if blocked {
return Invitation{}, ErrInvitationBlocked
}
}
id, err := uuid.NewV7()
if err != nil {
return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err)
}
ins := invitationInsert{
id: id,
inviterID: inviterID,
variant: settings.Variant.String(),
turnTimeoutSecs: int(settings.TurnTimeout / time.Second),
hintsAllowed: settings.HintsAllowed,
hintsPerPlayer: settings.HintsPerPlayer,
dropoutTiles: settings.DropoutTiles.String(),
expiresAt: svc.now().Add(invitationTTL),
}
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
return Invitation{}, err
}
return svc.store.loadInvitation(ctx, id)
}
// RespondInvitation records accountID's accept or decline of an invitation. A
// decline cancels the whole invitation; the accept that completes the set starts
// the game and marks the invitation started.
func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationID, accountID uuid.UUID, accept bool) (Invitation, error) {
res, err := svc.store.respondTx(ctx, invitationID, accountID, accept, svc.now())
if err != nil {
return Invitation{}, err
}
if accept && res.allAccepted {
if err := svc.startGame(ctx, invitationID); err != nil {
return Invitation{}, err
}
}
return svc.store.loadInvitation(ctx, invitationID)
}
// startGame creates the game for a fully-accepted invitation and marks it started.
func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.UUID) error {
inv, err := svc.store.loadInvitation(ctx, invitationID)
if err != nil {
return err
}
seats := make([]uuid.UUID, len(inv.Invitees)+1)
seats[0] = inv.InviterID
for _, iv := range inv.Invitees {
if iv.Seat < 1 || iv.Seat >= len(seats) {
return fmt.Errorf("lobby: invitation %s has out-of-range seat %d", invitationID, iv.Seat)
}
seats[iv.Seat] = iv.AccountID
}
g, err := svc.games.Create(ctx, game.CreateParams{
Variant: inv.Settings.Variant,
Seats: seats,
TurnTimeout: inv.Settings.TurnTimeout,
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles,
})
if err != nil {
return err
}
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
return err
}
return nil
}
// CancelInvitation lets the inviter withdraw a pending invitation.
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now())
}
// GetInvitation loads an invitation with its invitees.
func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uuid.UUID) (Invitation, error) {
return svc.store.loadInvitation(ctx, invitationID)
}
// invitationInsert carries the immutable fields of a new invitation.
type invitationInsert struct {
id uuid.UUID
inviterID uuid.UUID
variant string
turnTimeoutSecs int
hintsAllowed bool
hintsPerPlayer int
dropoutTiles string
expiresAt time.Time
}
// respondResult reports the state after an invitee response.
type respondResult struct {
allAccepted bool
}
// insertInvitation inserts the invitation and one invitee row per id (seats 1..N).
func (s *Store) insertInvitation(ctx context.Context, ins invitationInsert, inviteeIDs []uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
ii := table.GameInvitations.INSERT(
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
table.GameInvitations.DropoutTiles, table.GameInvitations.ExpiresAt,
).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.expiresAt)
if _, err := ii.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert invitation: %w", err)
}
for i, id := range inviteeIDs {
pi := table.GameInvitationInvitees.INSERT(
table.GameInvitationInvitees.InvitationID, table.GameInvitationInvitees.AccountID, table.GameInvitationInvitees.Seat,
).VALUES(ins.id, id, i+1)
if _, err := pi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert invitee %d: %w", i+1, err)
}
}
return nil
})
}
// loadInvitation reads an invitation and its invitees ordered by seat.
func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, error) {
isel := postgres.SELECT(table.GameInvitations.AllColumns).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.GameInvitations
if err := isel.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Invitation{}, ErrInvitationNotFound
}
return Invitation{}, fmt.Errorf("lobby: load invitation %s: %w", id, err)
}
variant, err := engine.ParseVariant(row.Variant)
if err != nil {
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
}
dropout, err := engine.ParseDropoutTiles(row.DropoutTiles)
if err != nil {
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
}
inv := Invitation{
ID: row.InvitationID,
InviterID: row.InviterID,
Settings: InvitationSettings{
Variant: variant,
TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second,
HintsAllowed: row.HintsAllowed,
HintsPerPlayer: int(row.HintsPerPlayer),
DropoutTiles: dropout,
},
Status: row.Status,
GameID: row.GameID,
ExpiresAt: row.ExpiresAt,
CreatedAt: row.CreatedAt,
}
psel := postgres.SELECT(table.GameInvitationInvitees.AllColumns).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(id))).
ORDER_BY(table.GameInvitationInvitees.Seat.ASC())
var prows []model.GameInvitationInvitees
if err := psel.QueryContext(ctx, s.db, &prows); err != nil {
return Invitation{}, fmt.Errorf("lobby: load invitees %s: %w", id, err)
}
for _, p := range prows {
inv.Invitees = append(inv.Invitees, Invitee{AccountID: p.AccountID, Seat: int(p.Seat), Response: p.Response})
}
return inv, 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) {
var res respondResult
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
isel := postgres.SELECT(table.GameInvitations.Status, table.GameInvitations.ExpiresAt).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID))).
FOR(postgres.UPDATE())
var inv model.GameInvitations
if err := isel.QueryContext(ctx, tx, &inv); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return ErrInvitationNotFound
}
return fmt.Errorf("lock invitation: %w", err)
}
if inv.Status == invitationPending && now.After(inv.ExpiresAt) {
if err := setInvitationStatus(ctx, tx, invitationID, invitationExpired, now); err != nil {
return err
}
return ErrInvitationExpired
}
if inv.Status != invitationPending {
return ErrInvitationNotPending
}
psel := postgres.SELECT(table.GameInvitationInvitees.Response).
FROM(table.GameInvitationInvitees).
WHERE(
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
).LIMIT(1)
var invitee model.GameInvitationInvitees
if err := psel.QueryContext(ctx, tx, &invitee); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return ErrNotInvited
}
return fmt.Errorf("load invitee: %w", err)
}
if invitee.Response != inviteePending {
return ErrAlreadyResponded
}
if !accept {
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeDeclined, now); err != nil {
return err
}
return setInvitationStatus(ctx, tx, invitationID, invitationDeclined, now)
}
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeAccepted, now); err != nil {
return err
}
remaining, err := unacceptedInvitees(ctx, tx, invitationID)
if err != nil {
return err
}
res.allAccepted = remaining == 0
return nil
})
return res, err
}
// markStarted stamps a fully-accepted invitation as started, only while it is
// still pending, and reports whether it did.
func (s *Store) markStarted(ctx context.Context, invitationID, gameID uuid.UUID, now time.Time) (bool, error) {
stmt := table.GameInvitations.
UPDATE(table.GameInvitations.Status, table.GameInvitations.GameID, table.GameInvitations.UpdatedAt).
SET(postgres.String(invitationStarted), postgres.UUID(gameID), postgres.TimestampzT(now)).
WHERE(
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("lobby: mark started: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("lobby: mark started rows: %w", err)
}
return n > 0, nil
}
// cancelInvitation withdraws a pending invitation on behalf of its inviter.
func (s *Store) cancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID, now time.Time) error {
stmt := table.GameInvitations.
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
SET(postgres.String(invitationCancelled), postgres.TimestampzT(now)).
WHERE(
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitations.InviterID.EQ(postgres.UUID(inviterID))).
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return fmt.Errorf("lobby: cancel invitation: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("lobby: cancel invitation rows: %w", err)
}
if n == 0 {
// Either the invitation is gone, not the caller's, or no longer pending.
inv, err := s.loadInvitation(ctx, invitationID)
if err != nil {
return err
}
if inv.InviterID != inviterID {
return ErrNotInviter
}
return ErrInvitationNotPending
}
return nil
}
// unacceptedInvitees counts the invitees of an invitation not yet accepted.
func unacceptedInvitees(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID) (int, error) {
stmt := postgres.SELECT(table.GameInvitationInvitees.Response).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)))
var rows []model.GameInvitationInvitees
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
return 0, fmt.Errorf("count invitees: %w", err)
}
remaining := 0
for _, r := range rows {
if r.Response != inviteeAccepted {
remaining++
}
}
return remaining, nil
}
// setInvitationStatus updates an invitation's status and updated_at.
func setInvitationStatus(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID, status string, now time.Time) error {
stmt := table.GameInvitations.
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
SET(postgres.String(status), postgres.TimestampzT(now)).
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)))
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("set invitation status: %w", err)
}
return nil
}
// setInviteeResponse updates one invitee's response and responded_at.
func setInviteeResponse(ctx context.Context, tx *sql.Tx, invitationID, accountID uuid.UUID, response string, now time.Time) error {
stmt := table.GameInvitationInvitees.
UPDATE(table.GameInvitationInvitees.Response, table.GameInvitationInvitees.RespondedAt).
SET(postgres.String(response), postgres.TimestampzT(now)).
WHERE(
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("set invitee response: %w", err)
}
return nil
}