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:
1 parent
5072919b61
commit
7fbd403d30
8 files changed
+93
-86
No files matched your search
@@ -24,7 +24,6 @@ type Game struct {
|
||||
|
||||
type Platform struct {
|
||||
ID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
DeletedAt pgtype.Timestamptz
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user