Steps 1-4 of the review plan (docs/plans not checked in; see conversation):
Step 1 - make it boot from a clean checkout:
- Replace the old schema.sql + single ALTER-TABLE migration (which never
created the tables) with one init migration: users, sessions, platform,
game, all with UUIDv7-ready uuid ids, soft deletes (deleted_at), and a
shared change_seq sequence + trigger for a future sync feature.
- sqlc now reads the schema from db/migrations directly.
- Migrations are embedded in the binary (db/migrations/embed.go) instead of
being copied around and found via path fallbacks.
- ensureDatabase/runMigrations failures are now fatal, and main pings the
pool after connecting, instead of starting an API with no schema.
- Switched both the app and golang-migrate to pgx/v5 (dropped lib/pq).
- Fixed the Dockerfile's Go version (1.23 -> 1.25) and build target.
- Added a postgres healthcheck to docker-compose so the app waits for a
ready database instead of racing it.
- go.mod tidied, bin/ added to .gitignore, Justfile's docker-compose calls
switched to the v2 `docker compose` plugin.
Step 2 - auth and per-user data:
- internal/auth: argon2id password hashing, random session tokens stored
only as a hash.
- POST /api/auth/{register,login,logout}, GET /api/auth/me. Sessions work
via either an Authorization: Bearer header or an httpOnly cookie.
- Every platform/game query is scoped to the authenticated user.
Step 3 - API correctness:
- internal/model holds the DTOs, with no server-side dependencies, so
clients can import it later without pulling in Huma/Echo.
- dbError() maps ErrNoRows -> 404, unique violation -> 409, FK violation ->
409, instead of leaking Postgres error text with a 500.
- Soft-delete meant the FK on game.platform_id no longer blocks deleting a
referenced platform on its own; added a NOT EXISTS guard plus an
application-level check so that case now correctly returns 409 instead of
silently succeeding (caught by testing the live server, not by a mock).
- Added GET /games/{id} and GET /platforms/{id}. All JSON routes moved
under /api, freeing / for a future web client.
- ListGames/GetGame join platform_name in.
- cmd/openapi writes the generated spec to api/openapi.json.
Step 4 - tests and CI:
- Rewrote the mocks and handler tests for the new Querier shape; added
coverage for 401 (no session), 404 (not found / another user's row),
409 (duplicate email, platform referenced by games), 422 (score out of
range), and that queries are actually scoped by the authenticated user's
id, not just that the right status code comes back.
- .github/workflows/ci.yml: go vet, go test, go mod tidy -diff, sqlc diff.
Everything above was exercised against a real local Postgres (migrations
up/down, the trigger, constraints) and through the running server via curl
(register, login, scoping, every status-code case), not just unit tests.
Known regression, intentionally deferred to the next step: cmd/tui and
cmd/gui still call the old unauthenticated GET /games, which no longer
exists. They need internal/client and a login flow before they work again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
529 lines
11 KiB
Go
529 lines
11 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: query.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const createGame = `-- name: CreateGame :one
|
|
WITH inserted AS (
|
|
INSERT INTO game (
|
|
user_id, platform_id, name, score, release_date, finished
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6
|
|
)
|
|
RETURNING id, platform_id, name, score, release_date, finished
|
|
)
|
|
SELECT inserted.id, inserted.name, inserted.platform_id, platform.name AS platform_name,
|
|
inserted.score, inserted.release_date, inserted.finished
|
|
FROM inserted
|
|
JOIN platform ON platform.id = inserted.platform_id
|
|
`
|
|
|
|
type CreateGameParams struct {
|
|
UserID uuid.UUID
|
|
PlatformID uuid.UUID
|
|
Name string
|
|
Score int32
|
|
ReleaseDate pgtype.Date
|
|
Finished pgtype.Date
|
|
}
|
|
|
|
type CreateGameRow struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
PlatformID uuid.UUID
|
|
PlatformName string
|
|
Score int32
|
|
ReleaseDate pgtype.Date
|
|
Finished pgtype.Date
|
|
}
|
|
|
|
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) {
|
|
row := q.db.QueryRow(ctx, createGame,
|
|
arg.UserID,
|
|
arg.PlatformID,
|
|
arg.Name,
|
|
arg.Score,
|
|
arg.ReleaseDate,
|
|
arg.Finished,
|
|
)
|
|
var i CreateGameRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.PlatformID,
|
|
&i.PlatformName,
|
|
&i.Score,
|
|
&i.ReleaseDate,
|
|
&i.Finished,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const createPlatform = `-- name: CreatePlatform :one
|
|
INSERT INTO platform (
|
|
user_id, name
|
|
) VALUES (
|
|
$1, $2
|
|
)
|
|
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)
|
|
var i CreatePlatformRow
|
|
err := row.Scan(&i.ID, &i.Name)
|
|
return i, err
|
|
}
|
|
|
|
const createSession = `-- name: CreateSession :exec
|
|
INSERT INTO sessions (
|
|
token_hash, user_id, expires_at
|
|
) VALUES (
|
|
$1, $2, $3
|
|
)
|
|
`
|
|
|
|
type CreateSessionParams struct {
|
|
TokenHash []byte
|
|
UserID uuid.UUID
|
|
ExpiresAt pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
|
|
_, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.UserID, arg.ExpiresAt)
|
|
return err
|
|
}
|
|
|
|
const createUser = `-- name: CreateUser :one
|
|
INSERT INTO users (
|
|
email, password_hash, name, bio
|
|
) VALUES (
|
|
$1, $2, $3, $4
|
|
)
|
|
RETURNING id, email, name, bio, created_at
|
|
`
|
|
|
|
type CreateUserParams struct {
|
|
Email string
|
|
PasswordHash string
|
|
Name string
|
|
Bio pgtype.Text
|
|
}
|
|
|
|
type CreateUserRow struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
Name string
|
|
Bio pgtype.Text
|
|
CreatedAt pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
|
row := q.db.QueryRow(ctx, createUser,
|
|
arg.Email,
|
|
arg.PasswordHash,
|
|
arg.Name,
|
|
arg.Bio,
|
|
)
|
|
var i CreateUserRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Name,
|
|
&i.Bio,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteGame = `-- name: DeleteGame :execrows
|
|
UPDATE game
|
|
SET deleted_at = now()
|
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
|
`
|
|
|
|
type DeleteGameParams struct {
|
|
ID uuid.UUID
|
|
UserID uuid.UUID
|
|
}
|
|
|
|
func (q *Queries) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, deleteGame, arg.ID, arg.UserID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
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
|
|
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)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const deleteSession = `-- name: DeleteSession :exec
|
|
DELETE FROM sessions
|
|
WHERE token_hash = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) error {
|
|
_, err := q.db.Exec(ctx, deleteSession, tokenHash)
|
|
return err
|
|
}
|
|
|
|
const getGame = `-- name: GetGame :one
|
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
|
game.score, game.release_date, game.finished
|
|
FROM game
|
|
JOIN platform ON platform.id = game.platform_id
|
|
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
|
|
`
|
|
|
|
type GetGameParams struct {
|
|
ID uuid.UUID
|
|
UserID uuid.UUID
|
|
}
|
|
|
|
type GetGameRow struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
PlatformID uuid.UUID
|
|
PlatformName string
|
|
Score int32
|
|
ReleaseDate pgtype.Date
|
|
Finished pgtype.Date
|
|
}
|
|
|
|
func (q *Queries) GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error) {
|
|
row := q.db.QueryRow(ctx, getGame, arg.ID, arg.UserID)
|
|
var i GetGameRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.PlatformID,
|
|
&i.PlatformName,
|
|
&i.Score,
|
|
&i.ReleaseDate,
|
|
&i.Finished,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getPlatform = `-- name: GetPlatform :one
|
|
SELECT id, name
|
|
FROM platform
|
|
WHERE id = $1 AND user_id = $2 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)
|
|
var i GetPlatformRow
|
|
err := row.Scan(&i.ID, &i.Name)
|
|
return i, err
|
|
}
|
|
|
|
const getSessionAndUser = `-- name: GetSessionAndUser :one
|
|
SELECT users.id, users.email, users.name, users.bio, sessions.expires_at
|
|
FROM sessions
|
|
JOIN users ON users.id = sessions.user_id
|
|
WHERE sessions.token_hash = $1 AND sessions.expires_at > now()
|
|
`
|
|
|
|
type GetSessionAndUserRow struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
Name string
|
|
Bio pgtype.Text
|
|
ExpiresAt pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error) {
|
|
row := q.db.QueryRow(ctx, getSessionAndUser, tokenHash)
|
|
var i GetSessionAndUserRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Name,
|
|
&i.Bio,
|
|
&i.ExpiresAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
|
SELECT id, email, password_hash, name, bio, created_at
|
|
FROM users
|
|
WHERE email = $1
|
|
`
|
|
|
|
type GetUserByEmailRow struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
PasswordHash string
|
|
Name string
|
|
Bio pgtype.Text
|
|
CreatedAt pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
|
|
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
|
var i GetUserByEmailRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.PasswordHash,
|
|
&i.Name,
|
|
&i.Bio,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getUserByID = `-- name: GetUserByID :one
|
|
SELECT id, email, name, bio, created_at
|
|
FROM users
|
|
WHERE id = $1
|
|
`
|
|
|
|
type GetUserByIDRow struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
Name string
|
|
Bio pgtype.Text
|
|
CreatedAt pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) {
|
|
row := q.db.QueryRow(ctx, getUserByID, id)
|
|
var i GetUserByIDRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Email,
|
|
&i.Name,
|
|
&i.Bio,
|
|
&i.CreatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listGames = `-- name: ListGames :many
|
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
|
game.score, game.release_date, game.finished
|
|
FROM game
|
|
JOIN platform ON platform.id = game.platform_id
|
|
WHERE game.user_id = $1 AND game.deleted_at IS NULL
|
|
ORDER BY game.name
|
|
`
|
|
|
|
type ListGamesRow struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
PlatformID uuid.UUID
|
|
PlatformName string
|
|
Score int32
|
|
ReleaseDate pgtype.Date
|
|
Finished pgtype.Date
|
|
}
|
|
|
|
func (q *Queries) ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error) {
|
|
rows, err := q.db.Query(ctx, listGames, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListGamesRow
|
|
for rows.Next() {
|
|
var i ListGamesRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.PlatformID,
|
|
&i.PlatformName,
|
|
&i.Score,
|
|
&i.ReleaseDate,
|
|
&i.Finished,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listPlatforms = `-- name: ListPlatforms :many
|
|
SELECT id, name
|
|
FROM platform
|
|
WHERE user_id = $1 AND deleted_at IS NULL
|
|
ORDER BY name
|
|
`
|
|
|
|
type ListPlatformsRow struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
}
|
|
|
|
func (q *Queries) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error) {
|
|
rows, err := q.db.Query(ctx, listPlatforms, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListPlatformsRow
|
|
for rows.Next() {
|
|
var i ListPlatformsRow
|
|
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const touchSession = `-- name: TouchSession :exec
|
|
UPDATE sessions
|
|
SET last_seen_at = now()
|
|
WHERE token_hash = $1
|
|
`
|
|
|
|
func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error {
|
|
_, err := q.db.Exec(ctx, touchSession, tokenHash)
|
|
return err
|
|
}
|
|
|
|
const updateGame = `-- name: UpdateGame :one
|
|
WITH updated AS (
|
|
UPDATE game
|
|
SET platform_id = $3, name = $4, score = $5, release_date = $6, finished = $7
|
|
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
|
|
RETURNING id, platform_id, name, score, release_date, finished
|
|
)
|
|
SELECT updated.id, updated.name, updated.platform_id, platform.name AS platform_name,
|
|
updated.score, updated.release_date, updated.finished
|
|
FROM updated
|
|
JOIN platform ON platform.id = updated.platform_id
|
|
`
|
|
|
|
type UpdateGameParams struct {
|
|
ID uuid.UUID
|
|
UserID uuid.UUID
|
|
PlatformID uuid.UUID
|
|
Name string
|
|
Score int32
|
|
ReleaseDate pgtype.Date
|
|
Finished pgtype.Date
|
|
}
|
|
|
|
type UpdateGameRow struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
PlatformID uuid.UUID
|
|
PlatformName string
|
|
Score int32
|
|
ReleaseDate pgtype.Date
|
|
Finished pgtype.Date
|
|
}
|
|
|
|
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error) {
|
|
row := q.db.QueryRow(ctx, updateGame,
|
|
arg.ID,
|
|
arg.UserID,
|
|
arg.PlatformID,
|
|
arg.Name,
|
|
arg.Score,
|
|
arg.ReleaseDate,
|
|
arg.Finished,
|
|
)
|
|
var i UpdateGameRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Name,
|
|
&i.PlatformID,
|
|
&i.PlatformName,
|
|
&i.Score,
|
|
&i.ReleaseDate,
|
|
&i.Finished,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const updatePlatform = `-- name: UpdatePlatform :one
|
|
UPDATE platform
|
|
SET name = $3
|
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
|
RETURNING id, name
|
|
`
|
|
|
|
type UpdatePlatformParams struct {
|
|
ID uuid.UUID
|
|
UserID uuid.UUID
|
|
Name string
|
|
}
|
|
|
|
type UpdatePlatformRow struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
}
|
|
|
|
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) {
|
|
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.UserID, arg.Name)
|
|
var i UpdatePlatformRow
|
|
err := row.Scan(&i.ID, &i.Name)
|
|
return i, err
|
|
}
|