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

-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