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 (R4). 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 (R4). 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 }