Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
New public ingress and the first network edge. Framework + a vertical slice of operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7. Contracts (new module scrabble/pkg): - push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen). Backend: - REST handlers on the /api/v1 groups: internal session endpoints (telegram/guest/email login -> mint, resolve, revoke) and the user slice (profile, submit_play, state, lobby enqueue/poll, chat). - internal/notify in-process Publisher hub + internal/pushgrpc gRPC server (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found; emission in game.commit, social, matchmaker. - migration 00005 accounts.is_guest; guests are durable rows excluded from stats; ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode). Gateway (new module scrabble/gateway): - Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON transcode registry, Telegram initData HMAC validator (seam), session cache, token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push gRPC client, admin Basic-Auth reverse proxy. go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/** path filters; unit build/vet/test span all three modules. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests + guest/email-login integration tests.
This commit is contained in:
+13
-1
@@ -56,6 +56,17 @@ state. The matchmaker now substitutes a pooled robot after a 10-second wait and
|
||||
exposes `Poll` so a waiting player can collect the started game (the live
|
||||
match-found notification arrives with the `gateway`).
|
||||
|
||||
Stage 6 opens the backend to the edge. The route groups gain their first
|
||||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||||
state, lobby enqueue/poll, chat). A new `internal/notify` hub feeds a second
|
||||
listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||
live events (your-turn, opponent-moved, chat, nudge, match-found) to the gateway.
|
||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
||||
with no identity, excluded from statistics. The shared wire contracts live in the
|
||||
sibling [`../pkg`](../pkg) module.
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
@@ -80,7 +91,8 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `BACKEND_HTTP_ADDR` | `:8080` | HTTP listen address. |
|
||||
| `BACKEND_HTTP_ADDR` | `:8080` | HTTP (REST) listen address. |
|
||||
| `BACKEND_GRPC_ADDR` | `:9090` | gRPC listen address for the live-event push stream to the gateway. |
|
||||
| `BACKEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. |
|
||||
| `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. |
|
||||
| `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. |
|
||||
|
||||
@@ -22,7 +22,9 @@ import (
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -58,6 +60,12 @@ func main() {
|
||||
// turn-timeout sweeper), the robot opponent (pool + move driver) and the
|
||||
// matchmaking reaper, HTTP server — and blocks until ctx is cancelled.
|
||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// A cancellable child context so the first server (or signal) to stop tears
|
||||
// the rest down — the HTTP and gRPC listeners and every background worker
|
||||
// share it.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
tel, err := telemetry.New(ctx, cfg.Telemetry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init telemetry: %w", err)
|
||||
@@ -99,8 +107,14 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
logger.Info("session cache warmed")
|
||||
|
||||
// The in-process live-event hub fans domain intents out to the gRPC push
|
||||
// stream. It is installed on every emitting service before any background
|
||||
// worker starts so robot moves and timeout sweeps also emit.
|
||||
hub := notify.NewHub(0)
|
||||
|
||||
accounts := account.NewStore(db)
|
||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||
games.SetNotifier(hub)
|
||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||
logger.Info("game turn-timeout sweeper started",
|
||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||
@@ -111,6 +125,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
mailer := newMailer(cfg.SMTP, logger)
|
||||
emails := account.NewEmailService(accounts, mailer)
|
||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||
socialSvc.SetNotifier(hub)
|
||||
|
||||
// Stage 5 robot opponent: provision its durable account pool (a hard startup
|
||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||
@@ -123,6 +138,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
|
||||
|
||||
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger)
|
||||
matchmaker.SetNotifier(hub)
|
||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
|
||||
@@ -132,12 +148,28 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
DB: db,
|
||||
PingTimeout: cfg.Postgres.OperationTimeout,
|
||||
SessionsReady: sessions.Ready,
|
||||
Sessions: sessions,
|
||||
Accounts: accounts,
|
||||
Games: games,
|
||||
Social: socialSvc,
|
||||
Matchmaker: matchmaker,
|
||||
Invitations: invitations,
|
||||
Emails: emails,
|
||||
})
|
||||
return srv.Run(ctx)
|
||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||
|
||||
// Run the HTTP and gRPC push listeners together; the first to stop (a listen
|
||||
// error, or ctx cancellation on signal) tears down the other through cancel.
|
||||
logger.Info("servers starting",
|
||||
zap.String("http_addr", cfg.HTTPAddr),
|
||||
zap.String("grpc_addr", cfg.GRPCAddr))
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- pushSrv.Run(ctx) }()
|
||||
go func() { errc <- srv.Run(ctx) }()
|
||||
err = <-errc
|
||||
cancel()
|
||||
<-errc
|
||||
return err
|
||||
}
|
||||
|
||||
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
|
||||
|
||||
@@ -54,6 +54,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/flatbuffers v23.5.26+incompatible
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.14.3 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
@@ -108,7 +109,9 @@ require (
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
scrabble/pkg v0.0.0
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package account owns durable internal accounts and their platform/email
|
||||
// identities. First contact from a platform auto-provisions an account bound to
|
||||
// that identity; guests are session-only and never reach this package.
|
||||
// that identity. An ephemeral guest is also a durable account row (the sessions
|
||||
// and game_players foreign keys both require one) but carries no identity and is
|
||||
// flagged is_guest, which excludes it from statistics, friends and history.
|
||||
package account
|
||||
|
||||
import (
|
||||
@@ -52,8 +54,11 @@ type Account struct {
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||
// excluded from statistics, friends and history.
|
||||
IsGuest bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the Postgres-backed query surface for accounts and identities.
|
||||
@@ -176,6 +181,30 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// guestDisplayName is the display name stamped on a freshly provisioned guest.
|
||||
const guestDisplayName = "Guest"
|
||||
|
||||
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
|
||||
// no identity, flagged is_guest, so it can hold a session and a game seat (both
|
||||
// foreign-key the accounts table) while being excluded from statistics, friends
|
||||
// and history. Guests are not reused — each bootstrap mints a new account.
|
||||
func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
|
||||
accountID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new guest id: %w", err)
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest).
|
||||
VALUES(accountID, guestDisplayName, true).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// SpendHint atomically decrements the account's hint wallet by one, returning
|
||||
// true when a hint was spent and false when the balance was already empty. The
|
||||
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
|
||||
@@ -210,6 +239,7 @@ func modelToAccount(row model.Accounts) Account {
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -126,6 +126,72 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
return s.store.GetByID(ctx, accountID)
|
||||
}
|
||||
|
||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
||||
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
|
||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||
// login. The code is mailed to the address, so only its real owner can complete
|
||||
// the login. It returns the target account id for the subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
subject := "Your Scrabble login code"
|
||||
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
return acc.ID, nil
|
||||
}
|
||||
|
||||
// LoginWithCode verifies a login code for email and returns the owning account,
|
||||
// marking the email identity confirmed on first success (idempotent for a
|
||||
// returning user). It mirrors ConfirmCode's checks but updates the existing
|
||||
// identity rather than inserting one, since RequestLoginCode already provisioned
|
||||
// it. It returns ErrNotFound when no account owns the email.
|
||||
func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (Account, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
acc, err := s.store.findByIdentity(ctx, KindEmail, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, acc.ID, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return Account{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return Account{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return Account{}, ErrCodeMismatch
|
||||
}
|
||||
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, acc.ID)
|
||||
}
|
||||
|
||||
// emailConfirmation is a pending confirm-code row in domain form.
|
||||
type emailConfirmation struct {
|
||||
id uuid.UUID
|
||||
@@ -252,6 +318,34 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmEmailLogin consumes the login code and marks the existing email
|
||||
// identity confirmed, inside one transaction. The identity already exists (a
|
||||
// login provisioned it), so this updates rather than inserts and is idempotent
|
||||
// for a returning user whose identity is already confirmed.
|
||||
func (s *Store) confirmEmailLogin(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
upd := table.EmailConfirmations.
|
||||
UPDATE(table.EmailConfirmations.ConsumedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("consume login code: %w", err)
|
||||
}
|
||||
confirm := table.Identities.
|
||||
UPDATE(table.Identities.Confirmed).
|
||||
SET(postgres.Bool(true)).
|
||||
WHERE(
|
||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
|
||||
AND(table.Identities.ExternalID.EQ(postgres.String(email))),
|
||||
)
|
||||
if _, err := confirm.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("confirm email identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
|
||||
func normalizeEmail(email string) (string, error) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(email))
|
||||
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
type Config struct {
|
||||
// HTTPAddr is the listen address of the HTTP listener (host:port).
|
||||
HTTPAddr string
|
||||
// GRPCAddr is the listen address of the gRPC push listener (host:port) that
|
||||
// streams live events to the gateway.
|
||||
GRPCAddr string
|
||||
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
|
||||
LogLevel string
|
||||
// Postgres configures the primary database pool.
|
||||
@@ -40,6 +43,7 @@ type Config struct {
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
const (
|
||||
defaultHTTPAddr = ":8080"
|
||||
defaultGRPCAddr = ":9090"
|
||||
defaultLogLevel = "info"
|
||||
)
|
||||
|
||||
@@ -100,6 +104,7 @@ func Load() (Config, error) {
|
||||
|
||||
c := Config{
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
||||
Postgres: pg,
|
||||
Telemetry: tel,
|
||||
@@ -124,6 +129,9 @@ func (c Config) validate() error {
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
|
||||
}
|
||||
if c.GRPCAddr == "" {
|
||||
return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty")
|
||||
}
|
||||
if err := c.Postgres.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// Service is the game domain: it drives the engine over a single match, persists
|
||||
@@ -31,6 +32,7 @@ type Service struct {
|
||||
version string
|
||||
clock func() time.Time
|
||||
rng func() int64
|
||||
pub notify.Publisher
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -48,10 +50,23 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
|
||||
version: cfg.DictVersion,
|
||||
clock: clock,
|
||||
rng: randomSeed,
|
||||
pub: notify.Nop{},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifier installs the live-event publisher. It must be called during
|
||||
// startup wiring, before the service serves traffic or the sweeper runs; the
|
||||
// default is notify.Nop (no live events). The game service emits your_turn and
|
||||
// opponent_moved after every committed move, whatever the source (a player's
|
||||
// request, the robot driver or the timeout sweeper, which all funnel through
|
||||
// commit).
|
||||
func (svc *Service) SetNotifier(p notify.Publisher) {
|
||||
if p != nil {
|
||||
svc.pub = p
|
||||
}
|
||||
}
|
||||
|
||||
// Create starts and persists a new game seating the given accounts in turn order
|
||||
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
|
||||
// the player count (2–4), the move clock, the hint allowance and that every seat
|
||||
@@ -239,7 +254,12 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
c.endReason = "timeout"
|
||||
}
|
||||
c.winner = g.Result().Winner
|
||||
c.stats = buildStats(g, seats)
|
||||
statSeats, err := svc.nonGuestSeats(ctx, seats)
|
||||
if err != nil {
|
||||
svc.cache.remove(gameID)
|
||||
return Game{}, err
|
||||
}
|
||||
c.stats = buildStats(g, statSeats)
|
||||
}
|
||||
if err := svc.store.CommitMove(ctx, c); err != nil {
|
||||
svc.cache.remove(gameID)
|
||||
@@ -248,7 +268,43 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
if c.finished {
|
||||
svc.cache.remove(gameID)
|
||||
}
|
||||
return svc.store.GetGame(ctx, gameID)
|
||||
post, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.emitMove(post, rec)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// emitMove publishes the live events for a just-committed move: opponent_moved to
|
||||
// every seat other than the actor, and your_turn to the next mover while the game
|
||||
// is still active. Delivery is best-effort (notify.Publisher never blocks).
|
||||
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
||||
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
||||
for _, s := range post.Seats {
|
||||
if s.Seat == rec.Player {
|
||||
continue
|
||||
}
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
||||
}
|
||||
if post.Status == StatusActive {
|
||||
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
|
||||
deadline := post.TurnStartedAt.Add(post.TurnTimeout)
|
||||
intents = append(intents, notify.YourTurn(next, post.ID, deadline))
|
||||
}
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// seatAccount returns the account seated at the given seat index, or false when
|
||||
// no seat matches (the slice is not assumed to be ordered by seat).
|
||||
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
|
||||
for _, s := range seats {
|
||||
if s.Seat == seat {
|
||||
return s.AccountID, true
|
||||
}
|
||||
}
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
|
||||
// timeoutGame auto-resigns the to-move player of an overdue game. It re-checks,
|
||||
@@ -633,6 +689,24 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||
return out
|
||||
}
|
||||
|
||||
// nonGuestSeats filters out guest seats so the finish-time statistics are
|
||||
// recomputed for durable non-guest accounts only — guests never accrue
|
||||
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
|
||||
func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, error) {
|
||||
out := make([]Seat, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
acc, err := svc.accounts.GetByID(ctx, s.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acc.IsGuest {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seatNames resolves each seat's display name for GCG export.
|
||||
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
|
||||
names := make([]string, g.Players)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||
func provisionGuest(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
if !acc.IsGuest {
|
||||
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||
// against a robot to a natural end and checks the guest holds a seat (the
|
||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||
// durable robot opponent does.
|
||||
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
robots := newRobotService(t, svc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick()
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
seed := openingSeed(t)
|
||||
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
||||
TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
const robotSeat = 1 // seats = [guest, robot]
|
||||
|
||||
finished := false
|
||||
for i := 0; i < 400 && !finished; i++ {
|
||||
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != game.StatusActive {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
if toMove == robotSeat {
|
||||
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
||||
robots.Drive(ctx, daytime)
|
||||
continue
|
||||
}
|
||||
playHuman(t, ctx, svc, g.ID, guest)
|
||||
}
|
||||
if !finished {
|
||||
t.Fatal("guest game did not finish within the move budget")
|
||||
}
|
||||
|
||||
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
||||
t.Error("a guest must not accrue a statistics row")
|
||||
}
|
||||
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
||||
t.Error("the durable robot opponent should have a statistics row")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailLoginFlow covers the Stage 6 email-as-login path: a code is mailed to
|
||||
// a new address, verifying it provisions and returns the owning account, and a
|
||||
// second login for the same address resolves to that same account (a returning
|
||||
// user), with the identity confirmed.
|
||||
func TestEmailLoginFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if code == "" {
|
||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
acc, err := svc.LoginWithCode(ctx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("login with code: %v", err)
|
||||
}
|
||||
if acc.ID != accountID {
|
||||
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
||||
}
|
||||
if acc.IsGuest {
|
||||
t.Error("an email account must be durable, not a guest")
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("the email identity must be confirmed after login")
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("second login: %v", err)
|
||||
}
|
||||
if acc2.ID != accountID {
|
||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
|
||||
@@ -30,6 +31,7 @@ type Matchmaker struct {
|
||||
robots RobotProvider
|
||||
waitDelay time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
log *zap.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -51,6 +53,7 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat
|
||||
robots: robots,
|
||||
waitDelay: waitDelay,
|
||||
clock: func() time.Time { return time.Now().UTC() },
|
||||
pub: notify.Nop{},
|
||||
log: log,
|
||||
queues: make(map[engine.Variant][]uuid.UUID),
|
||||
queued: make(map[uuid.UUID]engine.Variant),
|
||||
@@ -60,6 +63,26 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifier installs the live-event publisher used to push match_found to the
|
||||
// seated players when a pairing or robot substitution starts a game. It must be
|
||||
// called during startup wiring, before the reaper runs; the default is
|
||||
// notify.Nop (no live events; waiters still discover the game via Poll).
|
||||
func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
if p != nil {
|
||||
m.pub = p
|
||||
}
|
||||
}
|
||||
|
||||
// emitMatchFound pushes match_found to every seat of a freshly started game.
|
||||
// Emitting to a robot seat is harmless (no client subscription exists for it).
|
||||
func (m *Matchmaker) emitMatchFound(g game.Game) {
|
||||
intents := make([]notify.Intent, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
intents = append(intents, notify.MatchFound(s.AccountID, g.ID))
|
||||
}
|
||||
m.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// EnqueueResult reports the outcome of joining the pool: either a started game or a
|
||||
// queued ticket awaiting an opponent.
|
||||
type EnqueueResult struct {
|
||||
@@ -102,6 +125,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
|
||||
m.mu.Lock()
|
||||
m.results[opponent] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(g)
|
||||
return EnqueueResult{Matched: true, Game: g}, nil
|
||||
}
|
||||
|
||||
@@ -197,6 +221,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||
m.mu.Lock()
|
||||
m.results[s.human] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(g)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
"github.com/google/uuid"
|
||||
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// The constructors below build one Intent per live event, FlatBuffers-encoding
|
||||
// the payload with the shared scrabblefb schema. Keeping the encoding here lets
|
||||
// the game/social/lobby services emit events without importing the wire schema.
|
||||
|
||||
// YourTurn announces to userID that it is their turn in game gameID, with the
|
||||
// turn's nominal deadline.
|
||||
func YourTurn(userID, gameID uuid.UUID, deadline time.Time) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
gid := b.CreateString(gameID.String())
|
||||
fb.YourTurnEventStart(b)
|
||||
fb.YourTurnEventAddGameId(b, gid)
|
||||
fb.YourTurnEventAddDeadlineUnix(b, deadline.Unix())
|
||||
b.Finish(fb.YourTurnEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// OpponentMoved tells userID that seat just committed a move in game gameID,
|
||||
// summarising it (the client refetches the full state).
|
||||
func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
gid := b.CreateString(gameID.String())
|
||||
act := b.CreateString(action)
|
||||
fb.OpponentMovedEventStart(b)
|
||||
fb.OpponentMovedEventAddGameId(b, gid)
|
||||
fb.OpponentMovedEventAddSeat(b, int32(seat))
|
||||
fb.OpponentMovedEventAddAction(b, act)
|
||||
fb.OpponentMovedEventAddScore(b, int32(score))
|
||||
fb.OpponentMovedEventAddTotal(b, int32(total))
|
||||
b.Finish(fb.OpponentMovedEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// ChatMessage delivers a stored chat message (or nudge) to userID.
|
||||
func ChatMessage(userID, gameID, senderID uuid.UUID, id, kind, body string, createdAt time.Time) Intent {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
idOff := b.CreateString(id)
|
||||
gid := b.CreateString(gameID.String())
|
||||
sid := b.CreateString(senderID.String())
|
||||
kindOff := b.CreateString(kind)
|
||||
bodyOff := b.CreateString(body)
|
||||
fb.ChatMessageStart(b)
|
||||
fb.ChatMessageAddId(b, idOff)
|
||||
fb.ChatMessageAddGameId(b, gid)
|
||||
fb.ChatMessageAddSenderId(b, sid)
|
||||
fb.ChatMessageAddKind(b, kindOff)
|
||||
fb.ChatMessageAddBody(b, bodyOff)
|
||||
fb.ChatMessageAddCreatedAtUnix(b, createdAt.Unix())
|
||||
b.Finish(fb.ChatMessageEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindChatMessage, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// Nudge tells userID that fromUserID nudged them in game gameID.
|
||||
func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
gid := b.CreateString(gameID.String())
|
||||
from := b.CreateString(fromUserID.String())
|
||||
fb.NudgeEventStart(b)
|
||||
fb.NudgeEventAddGameId(b, gid)
|
||||
fb.NudgeEventAddFromUserId(b, from)
|
||||
b.Finish(fb.NudgeEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// MatchFound tells userID that game gameID, which they are seated in, has
|
||||
// started (an auto-match pairing or a robot substitution).
|
||||
func MatchFound(userID, gameID uuid.UUID) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
gid := b.CreateString(gameID.String())
|
||||
fb.MatchFoundEventStart(b)
|
||||
fb.MatchFoundEventAddGameId(b, gid)
|
||||
b.Finish(fb.MatchFoundEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// eventID returns a best-effort correlation id for one emitted event.
|
||||
func eventID() string {
|
||||
if id, err := uuid.NewV7(); err == nil {
|
||||
return id.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package notify is the backend's in-process live-event seam. Domain services
|
||||
// publish Intents after a successful commit; the gRPC push server (internal
|
||||
// /pushgrpc) subscribes to the hub and streams them to the gateway, which fans
|
||||
// them out to clients (docs/ARCHITECTURE.md §10). Event payloads are
|
||||
// FlatBuffers-encoded by the typed constructors in events.go, so the domain
|
||||
// services stay free of the wire schema and only depend on this package.
|
||||
//
|
||||
// Publishing is best-effort and non-blocking: a live event is a convenience, not
|
||||
// a correctness requirement, so a slow or absent subscriber never blocks a game
|
||||
// transition. The default Publisher is Nop, which keeps every domain service (and
|
||||
// its tests) runnable without a live channel.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Notification kinds — the catalog in docs/ARCHITECTURE.md §10.
|
||||
const (
|
||||
KindYourTurn = "your_turn"
|
||||
KindOpponentMoved = "opponent_moved"
|
||||
KindChatMessage = "chat_message"
|
||||
KindNudge = "nudge"
|
||||
KindMatchFound = "match_found"
|
||||
)
|
||||
|
||||
// Intent is one live event destined for a single user. Payload is the
|
||||
// FlatBuffers-encoded body (a scrabblefb.* table) that the gateway forwards
|
||||
// verbatim to the client; EventID is a correlation id carried through unchanged.
|
||||
type Intent struct {
|
||||
UserID uuid.UUID
|
||||
Kind string
|
||||
Payload []byte
|
||||
EventID string
|
||||
}
|
||||
|
||||
// Publisher accepts live-event intents. Implementations must be safe for
|
||||
// concurrent use and must not block the caller.
|
||||
type Publisher interface {
|
||||
Publish(intents ...Intent)
|
||||
}
|
||||
|
||||
// Nop is the default Publisher: it discards every intent.
|
||||
type Nop struct{}
|
||||
|
||||
// Publish discards the intents.
|
||||
func (Nop) Publish(...Intent) {}
|
||||
|
||||
// Hub is the in-process fan-in/fan-out between the domain publishers and the
|
||||
// push subscribers (the gRPC stream). It is safe for concurrent use.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
subs map[int]chan Intent
|
||||
nextID int
|
||||
bufSize int
|
||||
}
|
||||
|
||||
// defaultBuffer is the per-subscriber queue depth used when NewHub is given a
|
||||
// non-positive size.
|
||||
const defaultBuffer = 256
|
||||
|
||||
// NewHub returns a Hub whose per-subscriber buffer holds bufSize intents before
|
||||
// dropping (a slow subscriber never blocks a publisher).
|
||||
func NewHub(bufSize int) *Hub {
|
||||
if bufSize <= 0 {
|
||||
bufSize = defaultBuffer
|
||||
}
|
||||
return &Hub{subs: make(map[int]chan Intent), bufSize: bufSize}
|
||||
}
|
||||
|
||||
// Publish delivers each intent to every current subscriber, dropping it for any
|
||||
// subscriber whose buffer is full (best-effort live delivery).
|
||||
func (h *Hub) Publish(intents ...Intent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, in := range intents {
|
||||
for _, ch := range h.subs {
|
||||
select {
|
||||
case ch <- in:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new subscriber and returns its intent channel and an
|
||||
// unsubscribe func that closes the channel. The caller reads the channel until
|
||||
// it is closed or its own context ends, then calls unsubscribe.
|
||||
func (h *Hub) Subscribe() (<-chan Intent, func()) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
id := h.nextID
|
||||
h.nextID++
|
||||
ch := make(chan Intent, h.bufSize)
|
||||
h.subs[id] = ch
|
||||
return ch, func() { h.unsubscribe(id) }
|
||||
}
|
||||
|
||||
// unsubscribe removes and closes the subscriber's channel. It holds the same
|
||||
// lock as Publish, so it never closes a channel mid-send.
|
||||
func (h *Hub) unsubscribe(id int) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if ch, ok := h.subs[id]; ok {
|
||||
delete(h.subs, id)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package notify_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/notify"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
func TestHubDeliversToSubscriber(t *testing.T) {
|
||||
h := notify.NewHub(4)
|
||||
ch, cancel := h.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
want := notify.Intent{UserID: uuid.New(), Kind: notify.KindYourTurn, Payload: []byte{1, 2, 3}}
|
||||
h.Publish(want)
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.Kind != want.Kind || got.UserID != want.UserID {
|
||||
t.Fatalf("delivered %+v, want %+v", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no delivery within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubDropsWhenSubscriberBufferFull(t *testing.T) {
|
||||
h := notify.NewHub(1)
|
||||
ch, cancel := h.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
in := notify.Intent{UserID: uuid.New(), Kind: notify.KindNudge}
|
||||
// Buffer holds one; the second and third are dropped, and Publish must not block.
|
||||
h.Publish(in, in, in)
|
||||
|
||||
if got := len(ch); got != 1 {
|
||||
t.Fatalf("buffered %d intents, want 1 (rest dropped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubUnsubscribeClosesChannel(t *testing.T) {
|
||||
h := notify.NewHub(2)
|
||||
ch, cancel := h.Subscribe()
|
||||
cancel()
|
||||
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("channel should be closed after unsubscribe")
|
||||
}
|
||||
// Publishing after unsubscribe must be safe (no panic, no delivery).
|
||||
h.Publish(notify.Intent{Kind: notify.KindMatchFound})
|
||||
}
|
||||
|
||||
func TestNopPublisherDiscards(t *testing.T) {
|
||||
var p notify.Publisher = notify.Nop{}
|
||||
p.Publish(notify.Intent{Kind: notify.KindYourTurn}) // must not panic
|
||||
}
|
||||
|
||||
func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0))
|
||||
if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
ev := fb.GetRootAsYourTurnEvent(in.Payload, 0)
|
||||
if got := string(ev.GameId()); got != gid.String() {
|
||||
t.Fatalf("game id = %q, want %q", got, gid)
|
||||
}
|
||||
if got := ev.DeadlineUnix(); got != 1717000000 {
|
||||
t.Fatalf("deadline = %d, want 1717000000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130)
|
||||
if in.Kind != notify.KindOpponentMoved {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
}
|
||||
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
|
||||
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
|
||||
t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d",
|
||||
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePayloadRoundTrips(t *testing.T) {
|
||||
uid, gid, sid := uuid.New(), uuid.New(), uuid.New()
|
||||
in := notify.ChatMessage(uid, gid, sid, "msg-1", "message", "hi", time.Unix(1717000001, 0))
|
||||
if in.Kind != notify.KindChatMessage {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
}
|
||||
ev := fb.GetRootAsChatMessage(in.Payload, 0)
|
||||
if string(ev.Id()) != "msg-1" || string(ev.SenderId()) != sid.String() || string(ev.Body()) != "hi" || ev.CreatedAtUnix() != 1717000001 {
|
||||
t.Fatalf("decoded wrong chat message: %+v", ev)
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,5 @@ type Accounts struct {
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int32
|
||||
IsGuest bool
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type accountsTable struct {
|
||||
AwayStart postgres.ColumnTime
|
||||
AwayEnd postgres.ColumnTime
|
||||
HintBalance postgres.ColumnInteger
|
||||
IsGuest postgres.ColumnBool
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -80,9 +81,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
AwayStartColumn = postgres.TimeColumn("away_start")
|
||||
AwayEndColumn = postgres.TimeColumn("away_end")
|
||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
|
||||
IsGuestColumn = postgres.BoolColumn("is_guest")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
)
|
||||
|
||||
return accountsTable{
|
||||
@@ -100,6 +102,7 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
AwayStart: AwayStartColumn,
|
||||
AwayEnd: AwayEndColumn,
|
||||
HintBalance: HintBalanceColumn,
|
||||
IsGuest: IsGuestColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- +goose Up
|
||||
-- Stage 6 gateway edge: mark ephemeral guest accounts. A guest is a durable
|
||||
-- account row -- the sessions and game_players foreign keys both require one --
|
||||
-- that carries no identity and no profile, friends, stats or history; is_guest
|
||||
-- gates that exclusion (statistics recompute skips guest seats). This adds a
|
||||
-- column, so the generated jet code is regenerated (cmd/jetgen).
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN is_guest boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
ALTER TABLE accounts DROP COLUMN is_guest;
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package pushgrpc serves the backend -> gateway live-event stream: a gRPC
|
||||
// server exposing the scrabble.push.v1 Push service (docs/ARCHITECTURE.md §2).
|
||||
// It bridges the in-process notify.Hub to the wire — each Subscribe stream
|
||||
// drains a hub subscription and forwards every Intent as a push Event. The
|
||||
// gateway opens one long-lived Subscribe at startup and fans the events out to
|
||||
// its clients.
|
||||
package pushgrpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"scrabble/backend/internal/notify"
|
||||
pushv1 "scrabble/pkg/proto/push/v1"
|
||||
)
|
||||
|
||||
// Service implements pushv1.PushServer over a notify.Hub.
|
||||
type Service struct {
|
||||
pushv1.UnimplementedPushServer
|
||||
hub *notify.Hub
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewService constructs a Service that streams the hub's intents.
|
||||
func NewService(hub *notify.Hub, log *zap.Logger) *Service {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &Service{hub: hub, log: log}
|
||||
}
|
||||
|
||||
// Subscribe opens a hub subscription and forwards every intent to the gateway
|
||||
// until the stream's context ends (the gateway disconnected or the server is
|
||||
// shutting down). It returns nil on a clean disconnect.
|
||||
func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStreamingServer[pushv1.Event]) error {
|
||||
ch, cancel := s.hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
s.log.Info("gateway push subscription opened", zap.String("gateway_id", req.GetGatewayId()))
|
||||
defer s.log.Info("gateway push subscription closed", zap.String("gateway_id", req.GetGatewayId()))
|
||||
|
||||
ctx := stream.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case in, ok := <-ch:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
ev := &pushv1.Event{
|
||||
UserId: in.UserID.String(),
|
||||
Kind: in.Kind,
|
||||
Payload: in.Payload,
|
||||
EventId: in.EventID,
|
||||
}
|
||||
if err := stream.Send(ev); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server wraps the gRPC listener serving the Push service. Its Run mirrors the
|
||||
// HTTP server's: serve until the context is cancelled, then stop gracefully.
|
||||
type Server struct {
|
||||
grpc *grpc.Server
|
||||
addr string
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewServer builds a gRPC server bound to addr that streams hub events.
|
||||
func NewServer(addr string, hub *notify.Hub, log *zap.Logger) *Server {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
gs := grpc.NewServer()
|
||||
pushv1.RegisterPushServer(gs, NewService(hub, log))
|
||||
return &Server{grpc: gs, addr: addr, log: log}
|
||||
}
|
||||
|
||||
// Run starts the listener and blocks until ctx is cancelled, then stops the
|
||||
// server gracefully. It returns the first error that is not a clean shutdown.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
lis, err := net.Listen("tcp", s.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pushgrpc: listen %s: %w", s.addr, err)
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
s.log.Info("push grpc listener starting", zap.String("addr", s.addr))
|
||||
errc <- s.grpc.Serve(lis)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
s.log.Info("push grpc listener stopping")
|
||||
s.grpc.GracefulStop()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// The JSON DTOs below are the gateway<->backend REST contract. They are explicit
|
||||
// (the domain/engine structs are never serialised directly) and mirror the
|
||||
// FlatBuffers edge tables (pkg/fbs) the gateway transcodes to and from.
|
||||
|
||||
// sessionResponse is the credential returned by every auth endpoint.
|
||||
type sessionResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// okResponse is a simple success acknowledgement.
|
||||
type okResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// resolveResponse maps a session token to its account.
|
||||
type resolveResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// profileResponse is the authenticated account's own profile.
|
||||
type profileResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
}
|
||||
|
||||
// tileDTO is one placed (or to-place) tile.
|
||||
type tileDTO struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter string `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// moveRecordDTO is a decoded move (a committed play, or a hint preview).
|
||||
type moveRecordDTO struct {
|
||||
Player int `json:"player"`
|
||||
Action string `json:"action"`
|
||||
Dir string `json:"dir"`
|
||||
MainRow int `json:"main_row"`
|
||||
MainCol int `json:"main_col"`
|
||||
Tiles []tileDTO `json:"tiles"`
|
||||
Words []string `json:"words"`
|
||||
Count int `json:"count"`
|
||||
Score int `json:"score"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// seatDTO is one seat's public standing.
|
||||
type seatDTO struct {
|
||||
Seat int `json:"seat"`
|
||||
AccountID string `json:"account_id"`
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hints_used"`
|
||||
IsWinner bool `json:"is_winner"`
|
||||
}
|
||||
|
||||
// gameDTO is the shared game summary.
|
||||
type gameDTO struct {
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
DictVersion string `json:"dict_version"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
ToMove int `json:"to_move"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
Seats []seatDTO `json:"seats"`
|
||||
}
|
||||
|
||||
// moveResultDTO is the outcome of a committed move.
|
||||
type moveResultDTO struct {
|
||||
Move moveRecordDTO `json:"move"`
|
||||
Game gameDTO `json:"game"`
|
||||
}
|
||||
|
||||
// stateDTO is a player's view of a game.
|
||||
type stateDTO struct {
|
||||
Game gameDTO `json:"game"`
|
||||
Seat int `json:"seat"`
|
||||
Rack []string `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
}
|
||||
|
||||
// matchDTO reports whether the caller has been paired into a game.
|
||||
type matchDTO struct {
|
||||
Matched bool `json:"matched"`
|
||||
Game *gameDTO `json:"game,omitempty"`
|
||||
}
|
||||
|
||||
// chatDTO is one stored chat message or nudge.
|
||||
type chatDTO struct {
|
||||
ID string `json:"id"`
|
||||
GameID string `json:"game_id"`
|
||||
SenderID string `json:"sender_id"`
|
||||
Kind string `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
CreatedAtUnix int64 `json:"created_at_unix"`
|
||||
}
|
||||
|
||||
// errorResponse is the uniform error envelope.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// sessionResponseFor builds the credential payload for a minted session.
|
||||
func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
||||
return sessionResponse{
|
||||
Token: token,
|
||||
UserID: acc.ID.String(),
|
||||
IsGuest: acc.IsGuest,
|
||||
DisplayName: acc.DisplayName,
|
||||
}
|
||||
}
|
||||
|
||||
// profileResponseFor projects an account into its profile DTO.
|
||||
func profileResponseFor(acc account.Account) profileResponse {
|
||||
return profileResponse{
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
HintBalance: acc.HintBalance,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
}
|
||||
}
|
||||
|
||||
// gameDTOFromGame projects a game.Game into its DTO.
|
||||
func gameDTOFromGame(g game.Game) gameDTO {
|
||||
seats := make([]seatDTO, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
seats = append(seats, seatDTO{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID.String(),
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
})
|
||||
}
|
||||
return gameDTO{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
}
|
||||
}
|
||||
|
||||
// moveRecordDTOFrom projects an engine move record into its DTO.
|
||||
func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
|
||||
tiles := make([]tileDTO, 0, len(m.Tiles))
|
||||
for _, t := range m.Tiles {
|
||||
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||
}
|
||||
return moveRecordDTO{
|
||||
Player: m.Player,
|
||||
Action: m.Action.String(),
|
||||
Dir: m.Dir.String(),
|
||||
MainRow: m.MainRow,
|
||||
MainCol: m.MainCol,
|
||||
Tiles: tiles,
|
||||
Words: m.Words,
|
||||
Count: m.Count,
|
||||
Score: m.Score,
|
||||
Total: m.Total,
|
||||
}
|
||||
}
|
||||
|
||||
// moveResultDTOFrom projects a committed move result into its DTO.
|
||||
func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
|
||||
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
||||
}
|
||||
|
||||
// stateDTOFrom projects a player's state view into its DTO.
|
||||
func stateDTOFrom(v game.StateView) stateDTO {
|
||||
return stateDTO{
|
||||
Game: gameDTOFromGame(v.Game),
|
||||
Seat: v.Seat,
|
||||
Rack: v.Rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
// matchDTOFrom projects an enqueue/poll result into its DTO.
|
||||
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
|
||||
if !r.Matched {
|
||||
return matchDTO{Matched: false}
|
||||
}
|
||||
g := gameDTOFromGame(r.Game)
|
||||
return matchDTO{Matched: true, Game: &g}
|
||||
}
|
||||
|
||||
// chatDTOFrom projects a chat message into its DTO.
|
||||
func chatDTOFrom(m social.Message) chatDTO {
|
||||
return chatDTO{
|
||||
ID: m.ID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
SenderID: m.SenderID.String(),
|
||||
Kind: m.Kind,
|
||||
Body: m.Body,
|
||||
CreatedAtUnix: m.CreatedAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDirection maps the wire direction string to an engine.Direction.
|
||||
func parseDirection(s string) (engine.Direction, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "H":
|
||||
return engine.Horizontal, true
|
||||
case "V":
|
||||
return engine.Vertical, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
func TestParseDirection(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
in string
|
||||
want engine.Direction
|
||||
ok bool
|
||||
}{
|
||||
"horizontal": {"H", engine.Horizontal, true},
|
||||
"vertical": {"V", engine.Vertical, true},
|
||||
"lowercase": {"h", engine.Horizontal, true},
|
||||
"trimmed": {" V ", engine.Vertical, true},
|
||||
"invalid": {"X", 0, false},
|
||||
"empty": {"", 0, false},
|
||||
"diagonal-is-not": {"D", 0, false},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, ok := parseDirection(tc.in)
|
||||
if ok != tc.ok || (ok && got != tc.want) {
|
||||
t.Fatalf("parseDirection(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusForError(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
err error
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
|
||||
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
||||
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
||||
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
||||
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
status, code := statusForError(tc.err)
|
||||
if status != tc.wantStatus || code != tc.wantCode {
|
||||
t.Fatalf("statusForError(%v) = (%d, %q), want (%d, %q)", tc.err, status, code, tc.wantStatus, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// context_deadline is an arbitrary unmapped error standing in for "anything
|
||||
// unrecognised", which must fall through to 500/internal.
|
||||
var context_deadline = errNew("boom")
|
||||
|
||||
type simpleErr string
|
||||
|
||||
func (e simpleErr) Error() string { return string(e) }
|
||||
func errNew(s string) error { return simpleErr(s) }
|
||||
|
||||
func TestGameDTOFromGame(t *testing.T) {
|
||||
gid, aid := uuid.New(), uuid.New()
|
||||
g := game.Game{
|
||||
ID: gid,
|
||||
Variant: engine.VariantEnglish,
|
||||
DictVersion: "v1",
|
||||
Status: game.StatusActive,
|
||||
Players: 2,
|
||||
ToMove: 1,
|
||||
TurnTimeout: 24 * time.Hour,
|
||||
MoveCount: 3,
|
||||
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
|
||||
}
|
||||
dto := gameDTOFromGame(g)
|
||||
if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
|
||||
t.Fatalf("game dto mismatch: %+v", dto)
|
||||
}
|
||||
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
|
||||
t.Fatalf("seat dto mismatch: %+v", dto.Seats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRecordDTOFrom(t *testing.T) {
|
||||
rec := engine.MoveRecord{
|
||||
Player: 1,
|
||||
Action: engine.ActionPlay,
|
||||
Dir: engine.Vertical,
|
||||
MainRow: 7,
|
||||
MainCol: 7,
|
||||
Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "A", Blank: false}},
|
||||
Words: []string{"AB"},
|
||||
Score: 10,
|
||||
Total: 10,
|
||||
}
|
||||
dto := moveRecordDTOFrom(rec)
|
||||
if dto.Action != "play" || dto.Dir != "V" || dto.Score != 10 || len(dto.Tiles) != 1 || dto.Tiles[0].Letter != "A" {
|
||||
t.Fatalf("move dto mismatch: %+v", dto)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The
|
||||
// internal group is gateway-only (the gateway authenticates and forwards); the
|
||||
// user group requires X-User-ID; the admin group is reached through the gateway's
|
||||
// Basic-Auth proxy. This is the representative vertical slice — further domain
|
||||
// operations follow the same pattern (PLAN.md Stage 6).
|
||||
func (s *Server) registerRoutes() {
|
||||
if s.sessions != nil && s.accounts != nil {
|
||||
in := s.internal
|
||||
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||
}
|
||||
u := s.user
|
||||
if s.accounts != nil {
|
||||
u.GET("/profile", s.handleProfile)
|
||||
}
|
||||
if s.games != nil {
|
||||
u.POST("/games/:id/play", s.handleSubmitPlay)
|
||||
u.GET("/games/:id/state", s.handleGameState)
|
||||
}
|
||||
if s.matchmaker != nil {
|
||||
u.POST("/lobby/enqueue", s.handleEnqueue)
|
||||
u.GET("/lobby/poll", s.handlePoll)
|
||||
}
|
||||
if s.social != nil {
|
||||
u.POST("/games/:id/chat", s.handleChatPost)
|
||||
}
|
||||
s.admin.GET("/ping", s.handleAdminPing)
|
||||
}
|
||||
|
||||
// userID returns the authenticated account id stored by RequireUserID. The user
|
||||
// group always runs that middleware, so absence is a programming error.
|
||||
func userID(c *gin.Context) (uuid.UUID, bool) {
|
||||
return UserIDFromContext(c.Request.Context())
|
||||
}
|
||||
|
||||
// gameIDParam parses the :id path parameter as a game UUID.
|
||||
func gameIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// clientIP returns the originating client IP the gateway forwarded in
|
||||
// X-Forwarded-For (the first hop), falling back to the direct peer.
|
||||
func clientIP(c *gin.Context) string {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
// abortBadRequest rejects a malformed request body or parameter.
|
||||
func abortBadRequest(c *gin.Context, msg string) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "bad_request", Message: msg}})
|
||||
}
|
||||
|
||||
// abortErr maps a domain error to its HTTP status and a stable code. Server-side
|
||||
// (5xx) errors are logged with the real cause and reported generically.
|
||||
func (s *Server) abortErr(c *gin.Context, err error) {
|
||||
status, code := statusForError(err)
|
||||
msg := err.Error()
|
||||
if status >= http.StatusInternalServerError {
|
||||
s.log.Error("request failed", zap.String("path", c.FullPath()), zap.Error(err))
|
||||
msg = "internal error"
|
||||
}
|
||||
c.AbortWithStatusJSON(status, errorResponse{Error: errorBody{Code: code, Message: msg}})
|
||||
}
|
||||
|
||||
// statusForError maps a known domain sentinel to an HTTP status and code,
|
||||
// defaulting to 500/internal for anything unrecognised.
|
||||
func statusForError(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, game.ErrNotFound), errors.Is(err, account.ErrNotFound):
|
||||
return http.StatusNotFound, "not_found"
|
||||
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
|
||||
return http.StatusForbidden, "not_a_player"
|
||||
case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn):
|
||||
return http.StatusConflict, "not_your_turn"
|
||||
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
||||
return http.StatusConflict, "game_finished"
|
||||
case errors.Is(err, lobby.ErrAlreadyQueued):
|
||||
return http.StatusConflict, "already_queued"
|
||||
case errors.Is(err, game.ErrInvalidConfig):
|
||||
return http.StatusBadRequest, "invalid_config"
|
||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable):
|
||||
return http.StatusConflict, "hint_unavailable"
|
||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||
return http.StatusUnprocessableEntity, "illegal_play"
|
||||
case errors.Is(err, account.ErrEmailTaken):
|
||||
return http.StatusConflict, "email_taken"
|
||||
case errors.Is(err, account.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "invalid_email"
|
||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
|
||||
return http.StatusUnauthorized, "code_invalid"
|
||||
case errors.Is(err, session.ErrNotFound):
|
||||
return http.StatusUnauthorized, "session_invalid"
|
||||
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
||||
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
|
||||
errors.Is(err, social.ErrNudgeTooSoon):
|
||||
return http.StatusUnprocessableEntity, "chat_rejected"
|
||||
default:
|
||||
return http.StatusInternalServerError, "internal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth
|
||||
// reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to
|
||||
// have authenticated the operator; the admin surface itself (complaint review,
|
||||
// dictionary versions) lands in Stage 9. handleAdminPing is the proxy target that
|
||||
// proves the path end to end until then.
|
||||
func (s *Server) handleAdminPing(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
||||
// already validated the originating credential (Telegram initData, an email
|
||||
// code, or a guest bootstrap) and forwards the result here to provision the
|
||||
// account and mint the opaque session. The backend trusts the gateway on this
|
||||
// segment (docs/ARCHITECTURE.md §12).
|
||||
|
||||
// telegramAuthRequest carries the platform user id the gateway extracted from a
|
||||
// validated initData payload.
|
||||
type telegramAuthRequest struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||
// identity and mints a session for it.
|
||||
func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
var req telegramAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "external_id is required")
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
|
||||
func (s *Server) handleGuestAuth(c *gin.Context) {
|
||||
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// emailRequest is an email-login code request.
|
||||
type emailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
||||
// success once the address is well-formed, so the response does not reveal
|
||||
// whether an account already exists.
|
||||
func (s *Server) handleEmailRequest(c *gin.Context) {
|
||||
var req emailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" {
|
||||
abortBadRequest(c, "email is required")
|
||||
return
|
||||
}
|
||||
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// emailLoginRequest verifies an email login code.
|
||||
type emailLoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// handleEmailLogin verifies the code and mints a session for the owning account.
|
||||
func (s *Server) handleEmailLogin(c *gin.Context) {
|
||||
var req emailLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" || req.Code == "" {
|
||||
abortBadRequest(c, "email and code are required")
|
||||
return
|
||||
}
|
||||
acc, err := s.emails.LoginWithCode(c.Request.Context(), req.Email, req.Code)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// tokenRequest carries an opaque session token.
|
||||
type tokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// handleResolveSession resolves a token to its account id. The gateway calls it
|
||||
// on a session-cache miss.
|
||||
func (s *Server) handleResolveSession(c *gin.Context) {
|
||||
var req tokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||||
abortBadRequest(c, "token is required")
|
||||
return
|
||||
}
|
||||
sess, err := s.sessions.Resolve(c.Request.Context(), req.Token)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()})
|
||||
}
|
||||
|
||||
// handleRevokeSession revokes the session for a token (idempotent).
|
||||
func (s *Server) handleRevokeSession(c *gin.Context) {
|
||||
var req tokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||||
abortBadRequest(c, "token is required")
|
||||
return
|
||||
}
|
||||
if err := s.sessions.Revoke(c.Request.Context(), req.Token); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// mintSession creates a session for acc and writes the credential response.
|
||||
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
|
||||
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sessionResponseFor(token, acc))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// newRoutingServer builds a Server with non-nil (zero-value) services so the
|
||||
// routes register. The tests below exercise only the request-validation and
|
||||
// routing layers, which return before any service method is called; full
|
||||
// endpoint behaviour against real services is covered by the integration suite.
|
||||
func newRoutingServer() *Server {
|
||||
return New(":0", Deps{
|
||||
Sessions: &session.Service{},
|
||||
Accounts: &account.Store{},
|
||||
Games: &game.Service{},
|
||||
})
|
||||
}
|
||||
|
||||
func do(t *testing.T, s *Server, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var rdr *strings.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
} else {
|
||||
rdr = strings.NewReader("")
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAdminPingOK(t *testing.T) {
|
||||
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/admin/ping", "", nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"status":"ok"`) {
|
||||
t.Fatalf("admin ping = %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRequiresUserID(t *testing.T) {
|
||||
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("profile without X-User-ID = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionRejectsEmptyToken(t *testing.T) {
|
||||
rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/internal/sessions/resolve", `{}`, nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("resolve with empty token = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPlayRejectsBadDirection(t *testing.T) {
|
||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||
path := "/api/v1/user/games/" + uuid.New().String() + "/play"
|
||||
rec := do(t, newRoutingServer(), http.MethodPost, path, `{"dir":"X","tiles":[]}`, headers)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("submit play bad dir = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPlayRejectsBadGameID(t *testing.T) {
|
||||
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||
rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/user/games/not-a-uuid/play", `{"dir":"H"}`, headers)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
|
||||
// backend treats that header as the sole identity input.
|
||||
|
||||
// handleProfile returns the authenticated account's own profile.
|
||||
func (s *Server) handleProfile(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||
}
|
||||
|
||||
// submitPlayRequest places tiles in a direction on the player's turn.
|
||||
type submitPlayRequest struct {
|
||||
Dir string `json:"dir"`
|
||||
Tiles []struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter string `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
} `json:"tiles"`
|
||||
}
|
||||
|
||||
// handleSubmitPlay validates, scores and commits a placement.
|
||||
func (s *Server) handleSubmitPlay(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
gameID, ok := gameIDParam(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid game id")
|
||||
return
|
||||
}
|
||||
var req submitPlayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
dir, ok := parseDirection(req.Dir)
|
||||
if !ok {
|
||||
abortBadRequest(c, "dir must be H or V")
|
||||
return
|
||||
}
|
||||
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
|
||||
for _, t := range req.Tiles {
|
||||
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||
}
|
||||
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, moveResultDTOFrom(res))
|
||||
}
|
||||
|
||||
// handleGameState returns the player's view of a game.
|
||||
func (s *Server) handleGameState(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
gameID, ok := gameIDParam(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid game id")
|
||||
return
|
||||
}
|
||||
view, err := s.games.GameState(c.Request.Context(), gameID, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stateDTOFrom(view))
|
||||
}
|
||||
|
||||
// enqueueRequest joins the per-variant auto-match pool.
|
||||
type enqueueRequest struct {
|
||||
Variant string `json:"variant"`
|
||||
}
|
||||
|
||||
// handleEnqueue joins the auto-match pool for a variant.
|
||||
func (s *Server) handleEnqueue(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req enqueueRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
variant, err := engine.ParseVariant(req.Variant)
|
||||
if err != nil {
|
||||
abortBadRequest(c, "unknown variant")
|
||||
return
|
||||
}
|
||||
res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, matchDTOFrom(res))
|
||||
}
|
||||
|
||||
// handlePoll reports whether the caller has been paired since queueing.
|
||||
func (s *Server) handlePoll(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
res, err := s.matchmaker.Poll(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, matchDTOFrom(res))
|
||||
}
|
||||
|
||||
// chatPostRequest posts a per-game chat message.
|
||||
type chatPostRequest struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// handleChatPost stores a chat message from the authenticated player. The sender
|
||||
// IP is taken from the gateway-forwarded X-Forwarded-For header.
|
||||
func (s *Server) handleChatPost(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
gameID, ok := gameIDParam(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "invalid game id")
|
||||
return
|
||||
}
|
||||
var req chatPostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
msg, err := s.social.PostMessage(c.Request.Context(), gameID, uid, req.Body, clientIP(c))
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, chatDTOFrom(msg))
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
@@ -42,10 +44,13 @@ type Deps struct {
|
||||
// SessionsReady reports whether the session cache has been warmed. A nil
|
||||
// func skips the session-readiness check.
|
||||
SessionsReady func() bool
|
||||
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services.
|
||||
// They are held for the REST/stream handlers the gateway adds in Stage 6 (like
|
||||
// the route groups, this is scaffolding exposed via accessors); the server
|
||||
// itself does not route to them yet.
|
||||
// Sessions, Accounts and Games are the identity, account and game-domain
|
||||
// services the Stage 6 REST handlers route to.
|
||||
Sessions *session.Service
|
||||
Accounts *account.Store
|
||||
Games *game.Service
|
||||
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services
|
||||
// the Stage 6 REST handlers route to.
|
||||
Social *social.Service
|
||||
Matchmaker *lobby.Matchmaker
|
||||
Invitations *lobby.InvitationService
|
||||
@@ -61,6 +66,9 @@ type Server struct {
|
||||
pingTimeout time.Duration
|
||||
sessionsReady func() bool
|
||||
|
||||
sessions *session.Service
|
||||
accounts *account.Store
|
||||
games *game.Service
|
||||
social *social.Service
|
||||
matchmaker *lobby.Matchmaker
|
||||
invitations *lobby.InvitationService
|
||||
@@ -94,6 +102,9 @@ func New(addr string, deps Deps) *Server {
|
||||
db: deps.DB,
|
||||
pingTimeout: pingTimeout,
|
||||
sessionsReady: deps.SessionsReady,
|
||||
sessions: deps.Sessions,
|
||||
accounts: deps.Accounts,
|
||||
games: deps.Games,
|
||||
social: deps.Social,
|
||||
matchmaker: deps.Matchmaker,
|
||||
invitations: deps.Invitations,
|
||||
@@ -102,6 +113,7 @@ func New(addr string, deps Deps) *Server {
|
||||
}
|
||||
s.registerProbes(engine)
|
||||
s.registerAPIGroups(engine)
|
||||
s.registerRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
@@ -72,7 +73,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
if err := Clean(body); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
svc.emitChat(seats, senderID, msg)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Nudge records a nudge from senderID toward the player whose turn is awaited. The
|
||||
@@ -100,7 +106,27 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
if ok && svc.now().Sub(last) < nudgeInterval {
|
||||
return Message{}, ErrNudgeTooSoon
|
||||
}
|
||||
return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
if toMove >= 0 && toMove < len(seats) {
|
||||
svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID))
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// emitChat pushes a chat message to every seated player except the sender
|
||||
// (best-effort live delivery; the recipients still read it via Messages).
|
||||
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
|
||||
intents := make([]notify.Intent, 0, len(seats))
|
||||
for _, id := range seats {
|
||||
if id == senderID {
|
||||
continue
|
||||
}
|
||||
intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// LastNudgeAt returns the time of the most recent nudge senderID sent in the game
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// GameReader is the slice of the game domain the social package needs: the seated
|
||||
@@ -60,6 +61,7 @@ type Service struct {
|
||||
store *Store
|
||||
accounts *account.Store
|
||||
games GameReader
|
||||
pub notify.Publisher
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -70,6 +72,16 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic
|
||||
store: store,
|
||||
accounts: accounts,
|
||||
games: games,
|
||||
pub: notify.Nop{},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifier installs the live-event publisher used to push chat messages and
|
||||
// nudges to their recipients. It must be called during startup wiring, before
|
||||
// the service serves traffic; the default is notify.Nop (no live events).
|
||||
func (svc *Service) SetNotifier(p notify.Publisher) {
|
||||
if p != nil {
|
||||
svc.pub = p
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user