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

@@ -1,3 +0,0 @@
ALTER TABLE users
DROP COLUMN email,
DROP COLUMN password_hash;
@@ -1,3 +0,0 @@
ALTER TABLE users
ADD COLUMN email TEXT NOT NULL UNIQUE,
ADD COLUMN password_hash TEXT NOT NULL;
@@ -0,0 +1,10 @@
DROP TRIGGER IF EXISTS game_touch_change_seq ON game;
DROP TRIGGER IF EXISTS platform_touch_change_seq ON platform;
DROP FUNCTION IF EXISTS touch_change_seq();
DROP TABLE IF EXISTS game;
DROP TABLE IF EXISTS platform;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
DROP SEQUENCE IF EXISTS change_seq_seq;
+74
View File
@@ -0,0 +1,74 @@
-- Requires pgcrypto for gen_random_uuid(); UUIDs are v7 when the app generates
-- them (see internal/model), but the DB default falls back to v4 for rows
-- inserted outside the app (e.g. manual SQL).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS citext;
-- change_seq is a single global sequence shared by platform and game so a
-- client can ask "what changed since N?" across both tables in one pass.
CREATE SEQUENCE change_seq_seq;
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext NOT NULL UNIQUE,
password_hash text NOT NULL,
name text NOT NULL,
bio text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE sessions (
token_hash bytea PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_sessions_user_id ON sessions(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)
);
CREATE INDEX idx_platform_user_change_seq ON platform(user_id, change_seq);
CREATE TABLE game (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform_id uuid NOT NULL REFERENCES platform(id),
name text NOT NULL,
score int NOT NULL CHECK (score BETWEEN 0 AND 100),
release_date date NOT NULL,
finished date,
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
change_seq bigint NOT NULL DEFAULT nextval('change_seq_seq')
);
CREATE INDEX idx_game_user_change_seq ON game(user_id, change_seq);
-- Keep updated_at/change_seq current on every UPDATE so sync can detect
-- changes without clients having to set them manually.
CREATE OR REPLACE FUNCTION touch_change_seq() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
NEW.change_seq := nextval('change_seq_seq');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER platform_touch_change_seq
BEFORE UPDATE ON platform
FOR EACH ROW EXECUTE FUNCTION touch_change_seq();
CREATE TRIGGER game_touch_change_seq
BEFORE UPDATE ON game
FOR EACH ROW EXECUTE FUNCTION touch_change_seq();
+10
View File
@@ -0,0 +1,10 @@
// Package migrations embeds the SQL migration files into the binary, so the
// server doesn't depend on a db/migrations directory existing next to it at
// runtime (which previously broke outside of a specific Docker layout; see
// cmd/api/main.go).
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS
+105 -49
View File
@@ -1,67 +1,123 @@
-- name: GetUser :one
SELECT * FROM users
WHERE id = $1 LIMIT 1;
-- name: ListUsers :many
SELECT * FROM users
ORDER BY name;
-- name: CreateUser :one
INSERT INTO users (
name, bio
email, password_hash, name, bio
) VALUES (
$1, $2
$1, $2, $3, $4
)
RETURNING *;
RETURNING id, email, name, bio, created_at;
-- name: DeleteUser :exec
DELETE FROM users
-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, bio, created_at
FROM users
WHERE email = $1;
-- name: GetUserByID :one
SELECT id, email, name, bio, created_at
FROM users
WHERE id = $1;
-- name: CreateSession :exec
INSERT INTO sessions (
token_hash, user_id, expires_at
) VALUES (
$1, $2, $3
);
-- 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();
-- name: TouchSession :exec
UPDATE sessions
SET last_seen_at = now()
WHERE token_hash = $1;
-- name: DeleteSession :exec
DELETE FROM sessions
WHERE token_hash = $1;
-- name: CreatePlatform :one
INSERT INTO platform (
name
user_id, name
) VALUES (
$1
$1, $2
)
RETURNING *;
RETURNING id, name;
-- name: GetPlatform :one
SELECT id, name
FROM platform
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL;
-- name: ListPlatforms :many
SELECT * FROM platform
SELECT id, name
FROM platform
WHERE user_id = $1 AND deleted_at IS NULL
ORDER BY name;
-- name: CreateGame :one
INSERT INTO game (
name, platform_id, score, release_year, finished
) VALUES (
$1, $2, $3, $4, $5
)
RETURNING *;
-- name: GetGame :one
SELECT * FROM game
WHERE id = $1 LIMIT 1;
-- name: ListGames :many
SELECT * FROM game
ORDER BY name;
-- name: UpdateGame :one
UPDATE game
SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6
WHERE id = $1
RETURNING *;
-- name: DeleteGame :exec
DELETE FROM game
WHERE id = $1;
-- name: UpdatePlatform :one
UPDATE platform
SET name = $2
WHERE id = $1
RETURNING *;
SET name = $3
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
RETURNING id, name;
-- name: DeletePlatform :exec
DELETE FROM platform
WHERE id = $1;
-- 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.
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
);
-- 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;
-- 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;
-- 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;
-- 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;
-- name: DeleteGame :execrows
UPDATE game
SET deleted_at = now()
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL;
-21
View File
@@ -1,21 +0,0 @@
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
bio text
);
CREATE TABLE platform (
id BIGSERIAL PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE game (
id BIGSERIAL PRIMARY KEY,
name text NOT NULL,
platform_id BIGINT NOT NULL REFERENCES platform(id),
score INT NOT NULL,
release_year DATE NOT NULL,
finished DATE
);