diff --git a/db/migrations/20261003000000_init.up.sql b/db/migrations/20261003000000_init.up.sql index fa5be1c..345dbba 100644 --- a/db/migrations/20261003000000_init.up.sql +++ b/db/migrations/20261003000000_init.up.sql @@ -28,17 +28,18 @@ CREATE TABLE sessions ( CREATE INDEX idx_sessions_user_id ON sessions(user_id); +-- Platforms are shared: every user picks from and adds to the same list, +-- unlike games, which are private per user_id. CREATE TABLE platform ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, name text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, change_seq bigint NOT NULL DEFAULT nextval('change_seq_seq'), - UNIQUE (user_id, name) + UNIQUE (name) ); -CREATE INDEX idx_platform_user_change_seq ON platform(user_id, change_seq); +CREATE INDEX idx_platform_change_seq ON platform(change_seq); CREATE TABLE game ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/db/query.sql b/db/query.sql index a13bd9f..a19efd9 100644 --- a/db/query.sql +++ b/db/query.sql @@ -40,37 +40,38 @@ WHERE token_hash = $1; -- name: CreatePlatform :one INSERT INTO platform ( - user_id, name + name ) VALUES ( - $1, $2 + $1 ) RETURNING id, name; -- 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; -- 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; -- 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; -- name: DeletePlatform :execrows -- 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. +-- 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. 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 diff --git a/internal/api/mock_db_test.go b/internal/api/mock_db_test.go index ec1e30a..9592118 100644 --- a/internal/api/mock_db_test.go +++ b/internal/api/mock_db_test.go @@ -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) diff --git a/internal/api/routes.go b/internal/api/routes.go index 7ff8685..19e6da0 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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) } diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go index 1ddd215..713e365 100644 --- a/internal/api/routes_test.go +++ b/internal/api/routes_test.go @@ -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) { diff --git a/internal/db/models.go b/internal/db/models.go index b26e2ce..4f80886 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -24,7 +24,6 @@ type Game struct { type Platform struct { ID uuid.UUID - UserID uuid.UUID Name string UpdatedAt pgtype.Timestamptz DeletedAt pgtype.Timestamptz diff --git a/internal/db/querier.go b/internal/db/querier.go index b297975..05a6900 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -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) diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 4d39be9..aed90ae 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -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