Rebuild the schema and API on a working, multi-user foundation

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>
This commit is contained in:
SansanandClaude Sonnet 5 committed 2026-10-03 11:24:11 +02:00
1 parent 47a9719eeb
commit 5072919b61
34 files changed
+1913 -512

No files matched your search

+353 -110
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
// source: query.sql
package db
@@ -8,41 +8,61 @@ package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createGame = `-- name: CreateGame :one
INSERT INTO game (
name, platform_id, score, release_year, finished
) VALUES (
$1, $2, $3, $4, $5
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
)
RETURNING id, name, platform_id, score, release_year, 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
PlatformID int64
Score int32
ReleaseYear pgtype.Date
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
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.Name,
arg.UserID,
arg.PlatformID,
arg.Name,
arg.Score,
arg.ReleaseYear,
arg.ReleaseDate,
arg.Finished,
)
var i Game
var i CreateGameRow
err := row.Scan(
&i.ID,
&i.Name,
&i.PlatformID,
&i.PlatformName,
&i.Score,
&i.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
)
return i, err
@@ -50,122 +70,327 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
const createPlatform = `-- name: CreatePlatform :one
INSERT INTO platform (
name
user_id, name
) VALUES (
$1
$1, $2
)
RETURNING id, name
`
func (q *Queries) CreatePlatform(ctx context.Context, name string) (Platform, error) {
row := q.db.QueryRow(ctx, createPlatform, name)
var i Platform
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 (
name, bio
email, password_hash, name, bio
) VALUES (
$1, $2
$1, $2, $3, $4
)
RETURNING id, name, bio
RETURNING id, email, name, bio, created_at
`
type CreateUserParams struct {
Name string
Bio pgtype.Text
Email string
PasswordHash string
Name string
Bio pgtype.Text
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Bio)
var i User
err := row.Scan(&i.ID, &i.Name, &i.Bio)
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 :exec
DELETE FROM game
WHERE id = $1
const deleteGame = `-- name: DeleteGame :execrows
UPDATE game
SET deleted_at = now()
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
`
func (q *Queries) DeleteGame(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deleteGame, id)
return err
type DeleteGameParams struct {
ID uuid.UUID
UserID uuid.UUID
}
const deletePlatform = `-- name: DeletePlatform :exec
DELETE FROM platform
WHERE id = $1
`
func (q *Queries) DeletePlatform(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deletePlatform, id)
return err
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 deleteUser = `-- name: DeleteUser :exec
DELETE FROM users
WHERE id = $1
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
)
`
func (q *Queries) DeleteUser(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deleteUser, id)
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 id, name, platform_id, score, release_year, finished FROM game
WHERE id = $1 LIMIT 1
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
`
func (q *Queries) GetGame(ctx context.Context, id int64) (Game, error) {
row := q.db.QueryRow(ctx, getGame, id)
var i Game
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.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
)
return i, err
}
const getUser = `-- name: GetUser :one
SELECT id, name, bio FROM users
WHERE id = $1 LIMIT 1
const getPlatform = `-- name: GetPlatform :one
SELECT id, name
FROM platform
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
`
func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUser, id)
var i User
err := row.Scan(&i.ID, &i.Name, &i.Bio)
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 id, name, platform_id, score, release_year, finished FROM game
ORDER BY name
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
`
func (q *Queries) ListGames(ctx context.Context) ([]Game, error) {
rows, err := q.db.Query(ctx, listGames)
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 []Game
var items []ListGamesRow
for rows.Next() {
var i Game
var i ListGamesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.PlatformID,
&i.PlatformName,
&i.Score,
&i.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
); err != nil {
return nil, err
@@ -179,19 +404,26 @@ func (q *Queries) ListGames(ctx context.Context) ([]Game, error) {
}
const listPlatforms = `-- name: ListPlatforms :many
SELECT id, name FROM platform
SELECT id, name
FROM platform
WHERE user_id = $1 AND deleted_at IS NULL
ORDER BY name
`
func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
rows, err := q.db.Query(ctx, listPlatforms)
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 []Platform
var items []ListPlatformsRow
for rows.Next() {
var i Platform
var i ListPlatformsRow
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
@@ -203,63 +435,68 @@ func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
return items, nil
}
const listUsers = `-- name: ListUsers :many
SELECT id, name, bio FROM users
ORDER BY name
const touchSession = `-- name: TouchSession :exec
UPDATE sessions
SET last_seen_at = now()
WHERE token_hash = $1
`
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
rows, err := q.db.Query(ctx, listUsers)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error {
_, err := q.db.Exec(ctx, touchSession, tokenHash)
return err
}
const updateGame = `-- name: UpdateGame :one
UPDATE game
SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6
WHERE id = $1
RETURNING id, name, platform_id, score, release_year, finished
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 int64
ID uuid.UUID
UserID uuid.UUID
PlatformID uuid.UUID
Name string
PlatformID int64
Score int32
ReleaseYear pgtype.Date
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) {
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.Name,
arg.UserID,
arg.PlatformID,
arg.Name,
arg.Score,
arg.ReleaseYear,
arg.ReleaseDate,
arg.Finished,
)
var i Game
var i UpdateGameRow
err := row.Scan(
&i.ID,
&i.Name,
&i.PlatformID,
&i.PlatformName,
&i.Score,
&i.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
)
return i, err
@@ -267,19 +504,25 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e
const updatePlatform = `-- name: UpdatePlatform :one
UPDATE platform
SET name = $2
WHERE id = $1
SET name = $3
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
RETURNING id, name
`
type UpdatePlatformParams struct {
ID int64
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) (Platform, error) {
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name)
var i Platform
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
}