8881214213
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
610 lines
22 KiB
Go
610 lines
22 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/notify"
|
|
"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
|
|
pub notify.Publisher
|
|
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,
|
|
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
|
|
}
|
|
}
|
|
|
|
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
|
|
// itself so the client adds it to its lobby list without a refetch.
|
|
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
|
|
if len(inviteeIDs) == 0 {
|
|
return
|
|
}
|
|
summary := svc.invitationSummary(ctx, inv)
|
|
intents := make([]notify.Intent, 0, len(inviteeIDs))
|
|
for _, id := range inviteeIDs {
|
|
intents = append(intents, notify.NotificationInvitation(id, summary))
|
|
}
|
|
svc.pub.Publish(intents...)
|
|
}
|
|
|
|
// emitGameStarted publishes the game_started notification to each seated player, carrying their
|
|
// initial view of the started game so the client seeds its game cache without a refetch. A
|
|
// seat whose state cannot be read is skipped (it still sees the game on the next lobby load).
|
|
func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
|
|
intents := make([]notify.Intent, 0, len(seats))
|
|
for _, id := range seats {
|
|
state, err := svc.games.InitialState(ctx, g.ID, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
intents = append(intents, notify.NotificationGameStarted(id, state))
|
|
}
|
|
svc.pub.Publish(intents...)
|
|
}
|
|
|
|
// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
|
|
// resolving the inviter's and invitees' display names from the account store.
|
|
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
|
|
name := func(id uuid.UUID) string {
|
|
if acc, err := svc.accounts.GetByID(ctx, id); err == nil {
|
|
return acc.DisplayName
|
|
}
|
|
return ""
|
|
}
|
|
invitees := make([]notify.InvitationInvitee, 0, len(inv.Invitees))
|
|
for _, iv := range inv.Invitees {
|
|
invitees = append(invitees, notify.InvitationInvitee{
|
|
AccountID: iv.AccountID.String(),
|
|
DisplayName: name(iv.AccountID),
|
|
Seat: iv.Seat,
|
|
Response: iv.Response,
|
|
})
|
|
}
|
|
gameID := ""
|
|
if inv.GameID != nil {
|
|
gameID = inv.GameID.String()
|
|
}
|
|
return notify.InvitationSummary{
|
|
ID: inv.ID.String(),
|
|
Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)},
|
|
Invitees: invitees,
|
|
Variant: inv.Settings.Variant.String(),
|
|
TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second),
|
|
HintsAllowed: inv.Settings.HintsAllowed,
|
|
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
|
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
|
Status: inv.Status,
|
|
GameID: gameID,
|
|
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
inv, err := svc.store.loadInvitation(ctx, id)
|
|
if err != nil {
|
|
return Invitation{}, err
|
|
}
|
|
svc.emitInvitation(ctx, inv, inviteeIDs)
|
|
return inv, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
svc.emitGameStarted(ctx, g, seats)
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
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
|
|
}
|