Make the platform list shared across users; keep games private

Platforms no longer have a user_id: there's one global list, unique by
name, that every authenticated user reads from and writes to. Games are
unchanged - still private per user, still referencing platform_id.

- Migration: dropped platform.user_id and its FK, UNIQUE(user_id, name) ->
  UNIQUE(name), index on change_seq alone instead of (user_id, change_seq).
  Amended the one init migration in place rather than stacking a second one,
  since nothing is deployed yet.
- db/query.sql: platform queries no longer take a user_id parameter.
  DeletePlatform's NOT EXISTS guard already checked games from any user (it
  never filtered the subquery by user_id), so deleting a shared platform is
  correctly blocked by any user's games on it, not just the deleter's.
- routes.go: platform handlers still require auth (the list isn't public to
  anonymous requests) but no longer scope by user.
- Tests: updated the mocks for the new signatures, added
  TestListPlatformsIsSharedAcrossUsers.

Verified against a real Postgres: user A creates "PC", user B sees it
immediately, both can add games against it, each only sees their own
games, deleting the platform is blocked while either user has a game on
it, and the name stays globally unique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Sonnet 5 committed 2026-10-03 11:34:47 +02:00
1 parent 5072919b61
commit 7fbd403d30
8 files changed
+93 -86

No files matched your search

+12 -12
View File
@@ -15,19 +15,19 @@ import (
// pointer deep in sqlc-generated code.
type MockQuerier struct {
CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error)
CreatePlatformFunc func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error)
CreatePlatformFunc func(ctx context.Context, name string) (db.CreatePlatformRow, error)
CreateSessionFunc func(ctx context.Context, arg db.CreateSessionParams) error
CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error)
DeleteGameFunc func(ctx context.Context, arg db.DeleteGameParams) (int64, error)
DeletePlatformFunc func(ctx context.Context, arg db.DeletePlatformParams) (int64, error)
DeletePlatformFunc func(ctx context.Context, id uuid.UUID) (int64, error)
DeleteSessionFunc func(ctx context.Context, tokenHash []byte) error
GetGameFunc func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error)
GetPlatformFunc func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error)
GetPlatformFunc func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error)
GetSessionAndUserFunc func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error)
GetUserByEmailFunc func(ctx context.Context, email string) (db.GetUserByEmailRow, error)
GetUserByIDFunc func(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error)
ListGamesFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error)
ListPlatformsFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error)
ListPlatformsFunc func(ctx context.Context) ([]db.ListPlatformsRow, error)
TouchSessionFunc func(ctx context.Context, tokenHash []byte) error
UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error)
UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error)
@@ -36,8 +36,8 @@ type MockQuerier struct {
func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) {
return m.CreateGameFunc(ctx, arg)
}
func (m *MockQuerier) CreatePlatform(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) {
return m.CreatePlatformFunc(ctx, arg)
func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.CreatePlatformRow, error) {
return m.CreatePlatformFunc(ctx, name)
}
func (m *MockQuerier) CreateSession(ctx context.Context, arg db.CreateSessionParams) error {
return m.CreateSessionFunc(ctx, arg)
@@ -48,8 +48,8 @@ func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (
func (m *MockQuerier) DeleteGame(ctx context.Context, arg db.DeleteGameParams) (int64, error) {
return m.DeleteGameFunc(ctx, arg)
}
func (m *MockQuerier) DeletePlatform(ctx context.Context, arg db.DeletePlatformParams) (int64, error) {
return m.DeletePlatformFunc(ctx, arg)
func (m *MockQuerier) DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error) {
return m.DeletePlatformFunc(ctx, id)
}
func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error {
return m.DeleteSessionFunc(ctx, tokenHash)
@@ -57,8 +57,8 @@ func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error
func (m *MockQuerier) GetGame(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) {
return m.GetGameFunc(ctx, arg)
}
func (m *MockQuerier) GetPlatform(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) {
return m.GetPlatformFunc(ctx, arg)
func (m *MockQuerier) GetPlatform(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
return m.GetPlatformFunc(ctx, id)
}
func (m *MockQuerier) GetSessionAndUser(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
return m.GetSessionAndUserFunc(ctx, tokenHash)
@@ -72,8 +72,8 @@ func (m *MockQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (db.GetUser
func (m *MockQuerier) ListGames(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) {
return m.ListGamesFunc(ctx, userID)
}
func (m *MockQuerier) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error) {
return m.ListPlatformsFunc(ctx, userID)
func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.ListPlatformsRow, error) {
return m.ListPlatformsFunc(ctx)
}
func (m *MockQuerier) TouchSession(ctx context.Context, tokenHash []byte) error {
return m.TouchSessionFunc(ctx, tokenHash)
+12 -12
View File
@@ -41,6 +41,11 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
}
// --- Platform Routes ---
//
// Platforms are a shared list: every authenticated user reads and writes
// the same rows, unlike games, which are private per user. Routes still
// require auth (so the list isn't public), but none of the queries below
// filter by user_id, because platform has no such column.
func registerPlatformRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{
@@ -51,7 +56,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
}
addAuth(&listOp, api, queries)
huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Platform }, error) {
platforms, err := queries.ListPlatforms(ctx, userFromContext(ctx).ID)
platforms, err := queries.ListPlatforms(ctx)
if err != nil {
return nil, dbError(err)
}
@@ -72,7 +77,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
huma.Register(api, getOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{ Body model.Platform }, error) {
p, err := queries.GetPlatform(ctx, db.GetPlatformParams{ID: input.ID, UserID: userFromContext(ctx).ID})
p, err := queries.GetPlatform(ctx, input.ID)
if err != nil {
return nil, dbError(err)
}
@@ -87,10 +92,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
}
addAuth(&createOp, api, queries)
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) {
platform, err := queries.CreatePlatform(ctx, db.CreatePlatformParams{
UserID: userFromContext(ctx).ID,
Name: input.Body.Name,
})
platform, err := queries.CreatePlatform(ctx, input.Body.Name)
if err != nil {
return nil, dbError(err)
}
@@ -109,9 +111,8 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
Body model.PlatformInput
}) (*struct{ Body model.Platform }, error) {
platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{
ID: input.ID,
UserID: userFromContext(ctx).ID,
Name: input.Body.Name,
ID: input.ID,
Name: input.Body.Name,
})
if err != nil {
return nil, dbError(err)
@@ -129,7 +130,6 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{}, error) {
userID := userFromContext(ctx).ID
// Checked separately so a 0-rows result from DeletePlatform below can
// only mean "blocked by games referencing it", not "doesn't exist" -
// soft delete means the FK alone can't tell the two apart (see
@@ -137,11 +137,11 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
// the platform is deleted between these two queries; harmless, since
// the second query would then just affect 0 rows and report 409
// instead of the 404 a plain retry would hit anyway.
if _, err := queries.GetPlatform(ctx, db.GetPlatformParams{ID: input.ID, UserID: userID}); err != nil {
if _, err := queries.GetPlatform(ctx, input.ID); err != nil {
return nil, dbError(err)
}
rows, err := queries.DeletePlatform(ctx, db.DeletePlatformParams{ID: input.ID, UserID: userID})
rows, err := queries.DeletePlatform(ctx, input.ID)
if err != nil {
return nil, dbError(err)
}
+27 -7
View File
@@ -119,10 +119,10 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
// the 0-rows result itself and turn it into 409, not just pass through
// whatever error (if any) the query returns.
mockDB := authedMock()
mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) {
return db.GetPlatformRow{ID: arg.ID, Name: "PC"}, nil
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
return db.GetPlatformRow{ID: id, Name: "PC"}, nil
}
mockDB.DeletePlatformFunc = func(ctx context.Context, arg db.DeletePlatformParams) (int64, error) {
mockDB.DeletePlatformFunc = func(ctx context.Context, id uuid.UUID) (int64, error) {
return 0, nil
}
@@ -135,7 +135,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
func TestDeletePlatformNotFound(t *testing.T) {
mockDB := authedMock()
mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) {
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
return db.GetPlatformRow{}, pgx.ErrNoRows
}
@@ -161,9 +161,8 @@ func TestCreateGameRejectsScoreOutOfRange(t *testing.T) {
func TestCreatePlatform(t *testing.T) {
mockDB := authedMock()
mockDB.CreatePlatformFunc = func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) {
assert.Equal(t, testUserID, arg.UserID)
return db.CreatePlatformRow{ID: uuid.New(), Name: arg.Name}, nil
mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) {
return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil
}
e, _ := newTestAPI(mockDB)
@@ -174,6 +173,27 @@ func TestCreatePlatform(t *testing.T) {
assert.Contains(t, rec.Body.String(), "Sega Genesis")
}
func TestListPlatformsIsSharedAcrossUsers(t *testing.T) {
// Unlike games, the platform list has no user_id to scope by: every
// authenticated user sees the same rows. ListPlatformsFunc taking no
// user id (beyond ctx) is itself part of what's being checked here -
// this test would fail to compile if a user_id parameter crept back in.
calls := 0
mockDB := authedMock()
mockDB.ListPlatformsFunc = func(ctx context.Context) ([]db.ListPlatformsRow, error) {
calls++
return []db.ListPlatformsRow{{ID: uuid.New(), Name: "PC"}}, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/platforms", ""))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "PC")
assert.Equal(t, 1, calls)
}
func TestRegisterDuplicateEmailConflicts(t *testing.T) {
mockDB := &MockQuerier{
CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) {
-1
View File
@@ -24,7 +24,6 @@ type Game struct {
type Platform struct {
ID uuid.UUID
UserID uuid.UUID
Name string
UpdatedAt pgtype.Timestamptz
DeletedAt pgtype.Timestamptz
+7 -6
View File
@@ -12,23 +12,24 @@ import (
type Querier interface {
CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error)
CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error)
CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error)
CreateSession(ctx context.Context, arg CreateSessionParams) error
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error)
// Soft delete, so the FK on game.platform_id is never touched and never
// blocks this on its own; the NOT EXISTS guard is what stops a platform
// that still has active games from being deleted. Call GetPlatform first to
// tell "not found" apart from "blocked by games" when this affects 0 rows.
DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error)
// that still has active games - from any user, since the list is shared -
// from being deleted. Call GetPlatform first to tell "not found" apart from
// "blocked by games" when this affects 0 rows.
DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error)
DeleteSession(ctx context.Context, tokenHash []byte) error
GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error)
GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error)
GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow, error)
GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error)
ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error)
ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error)
ListPlatforms(ctx context.Context) ([]ListPlatformsRow, error)
TouchSession(ctx context.Context, tokenHash []byte) error
UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error)
UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error)
+21 -36
View File
@@ -70,25 +70,20 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (CreateG
const createPlatform = `-- name: CreatePlatform :one
INSERT INTO platform (
user_id, name
name
) VALUES (
$1, $2
$1
)
RETURNING id, name
`
type CreatePlatformParams struct {
UserID uuid.UUID
Name string
}
type CreatePlatformRow struct {
ID uuid.UUID
Name string
}
func (q *Queries) CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error) {
row := q.db.QueryRow(ctx, createPlatform, arg.UserID, arg.Name)
func (q *Queries) CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error) {
row := q.db.QueryRow(ctx, createPlatform, name)
var i CreatePlatformRow
err := row.Scan(&i.ID, &i.Name)
return i, err
@@ -177,24 +172,20 @@ func (q *Queries) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64,
const deletePlatform = `-- name: DeletePlatform :execrows
UPDATE platform
SET deleted_at = now()
WHERE platform.id = $1 AND platform.user_id = $2 AND platform.deleted_at IS NULL
WHERE platform.id = $1 AND platform.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM game
WHERE game.platform_id = platform.id AND game.deleted_at IS NULL
)
`
type DeletePlatformParams struct {
ID uuid.UUID
UserID uuid.UUID
}
// Soft delete, so the FK on game.platform_id is never touched and never
// blocks this on its own; the NOT EXISTS guard is what stops a platform
// that still has active games from being deleted. Call GetPlatform first to
// tell "not found" apart from "blocked by games" when this affects 0 rows.
func (q *Queries) DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error) {
result, err := q.db.Exec(ctx, deletePlatform, arg.ID, arg.UserID)
// that still has active games - from any user, since the list is shared -
// from being deleted. Call GetPlatform first to tell "not found" apart from
// "blocked by games" when this affects 0 rows.
func (q *Queries) DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deletePlatform, id)
if err != nil {
return 0, err
}
@@ -252,21 +243,16 @@ func (q *Queries) GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, e
const getPlatform = `-- name: GetPlatform :one
SELECT id, name
FROM platform
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
WHERE id = $1 AND deleted_at IS NULL
`
type GetPlatformParams struct {
ID uuid.UUID
UserID uuid.UUID
}
type GetPlatformRow struct {
ID uuid.UUID
Name string
}
func (q *Queries) GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error) {
row := q.db.QueryRow(ctx, getPlatform, arg.ID, arg.UserID)
func (q *Queries) GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow, error) {
row := q.db.QueryRow(ctx, getPlatform, id)
var i GetPlatformRow
err := row.Scan(&i.ID, &i.Name)
return i, err
@@ -406,7 +392,7 @@ func (q *Queries) ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesR
const listPlatforms = `-- name: ListPlatforms :many
SELECT id, name
FROM platform
WHERE user_id = $1 AND deleted_at IS NULL
WHERE deleted_at IS NULL
ORDER BY name
`
@@ -415,8 +401,8 @@ type ListPlatformsRow struct {
Name string
}
func (q *Queries) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error) {
rows, err := q.db.Query(ctx, listPlatforms, userID)
func (q *Queries) ListPlatforms(ctx context.Context) ([]ListPlatformsRow, error) {
rows, err := q.db.Query(ctx, listPlatforms)
if err != nil {
return nil, err
}
@@ -504,15 +490,14 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateG
const updatePlatform = `-- name: UpdatePlatform :one
UPDATE platform
SET name = $3
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
SET name = $2
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, name
`
type UpdatePlatformParams struct {
ID uuid.UUID
UserID uuid.UUID
Name string
ID uuid.UUID
Name string
}
type UpdatePlatformRow struct {
@@ -521,7 +506,7 @@ type UpdatePlatformRow struct {
}
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) {
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.UserID, arg.Name)
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name)
var i UpdatePlatformRow
err := row.Scan(&i.ID, &i.Name)
return i, err