feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run

Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.

Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-26 23:53:53 +02:00
parent 98d1fe6cae
commit 009ea560f9
44 changed files with 2486 additions and 1118 deletions
+11
View File
@@ -485,6 +485,17 @@ func (a *userEntitlementAdapter) GetMaxRegisteredRaceNames(ctx context.Context,
return snap.MaxRegisteredRaceNames, nil
}
func (a *userEntitlementAdapter) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if a == nil || a.svc == nil {
return false, nil
}
snap, err := a.svc.GetEntitlementSnapshot(ctx, userID)
if err != nil {
return false, err
}
return snap.IsPaid, nil
}
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
// delegating to `*runtime.Service`. The svc pointer is patched after
// the services are constructed — runtime depends on lobby
+12 -5
View File
@@ -9,14 +9,21 @@ import (
// EntitlementProvider is the read-only view the lobby needs over the
// user-domain entitlement snapshot. The canonical implementation is
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute
// a fake.
// `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
// substitute a fake.
//
// `MaxRegisteredRaceNames` is the only field consumed by when
// the caller attempts to register a `pending_registration` row the lobby
// counts already-`registered` rows for that user against this limit.
// `GetMaxRegisteredRaceNames` is consumed at race-name registration time
// — when the caller attempts to register a `pending_registration` row the
// lobby counts already-`registered` rows for that user against this limit.
//
// `IsPaid` is consumed by the user-facing private-game creation gate at
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
// callers are rejected with `403 forbidden` before the lobby Service is
// invoked. Admin-driven public-game creation
// (`POST /api/v1/admin/games`) bypasses the gate.
type EntitlementProvider interface {
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
}
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
+13
View File
@@ -20,6 +20,7 @@
package lobby
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
@@ -28,6 +29,7 @@ import (
"galaxy/backend/internal/config"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
)
@@ -207,6 +209,17 @@ func (s *Service) Config() config.LobbyConfig {
return s.deps.Config
}
// IsPaid reports whether userID currently sits on a paid tier. Thin
// pass-through over EntitlementProvider used by the HTTP handler that
// fronts user-driven private-game creation; admin-driven public-game
// creation does not consult this gate.
func (s *Service) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if s == nil || s.deps.Entitlement == nil {
return false, fmt.Errorf("lobby: entitlement provider not configured")
}
return s.deps.Entitlement.IsPaid(ctx, userID)
}
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
// for code-based invites. The function uses `crypto/rand`; a failure to
// read entropy is propagated to the caller.
+4
View File
@@ -103,6 +103,10 @@ func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUI
return s.max, nil
}
func (s stubEntitlement) IsPaid(_ context.Context, _ uuid.UUID) (bool, error) {
return true, nil
}
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
t.Helper()
store := lobby.NewStore(db)
@@ -86,6 +86,15 @@ func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc {
return
}
ctx := c.Request.Context()
paid, err := h.svc.IsPaid(ctx, userID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby games create", ctx, err)
return
}
if !paid {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "creating private games requires a paid subscription")
return
}
owner := userID
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: &owner,
+8 -1
View File
@@ -265,7 +265,12 @@ paths:
summary: Create a new private lobby game owned by the caller
description: |
Always emits a `private` game owned by `X-User-ID`. Public games
are created via `POST /api/v1/admin/games`.
are created via `POST /api/v1/admin/games`. The endpoint is
gated by the caller's paid tier: free-tier accounts receive
`403 forbidden` (code `forbidden`) and no `draft` row is
created. The tier is read through
`EntitlementProvider.IsPaid(userID)` from the user-domain
service.
security:
- UserHeader: []
parameters:
@@ -285,6 +290,8 @@ paths:
$ref: "#/components/schemas/LobbyGameDetail"
"400":
$ref: "#/components/responses/InvalidRequestError"
"403":
$ref: "#/components/responses/ForbiddenError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":