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

+61 -55
View File
@@ -11,21 +11,26 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
_ "github.com/lib/pq"
"completed/db/migrations"
"completed/internal/api"
"completed/internal/db"
)
// ensureDatabase creates the target database if it doesn't exist yet, by
// connecting to the server's default "postgres" database first. This is a
// convenience for local/compose use; a real deployment would provision the
// database up front and this would be a no-op every time.
func ensureDatabase(dbURL string) error {
u, err := url.Parse(dbURL)
if err != nil {
return err
return fmt.Errorf("parse DATABASE_URL: %w", err)
}
dbName := strings.TrimPrefix(u.Path, "/")
@@ -33,103 +38,104 @@ func ensureDatabase(dbURL string) error {
return fmt.Errorf("database name is empty in URL: %s", dbURL)
}
// Connect to 'postgres' database to create the new DB
u.Path = "/postgres"
db, err := sql.Open("postgres", u.String())
adminDB, err := sql.Open("pgx", u.String())
if err != nil {
return err
return fmt.Errorf("open admin connection: %w", err)
}
defer db.Close()
defer adminDB.Close()
// Check if DB exists
var exists bool
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists)
err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists)
if err != nil {
return err
return fmt.Errorf("check database existence: %w", err)
}
if !exists {
fmt.Printf("Database '%s' does not exist. Creating...\n", dbName)
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", dbName))
if err != nil {
return err
}
fmt.Printf("Database '%s' created successfully.\n", dbName)
} else {
fmt.Printf("Database '%s' already exists.\n", dbName)
if exists {
return nil
}
fmt.Printf("Database %q does not exist. Creating...\n", dbName)
// Table/database identifiers can't be parameterized; dbName comes from
// our own DATABASE_URL, not untrusted request input.
if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)); err != nil {
return fmt.Errorf("create database %q: %w", dbName, err)
}
fmt.Printf("Database %q created.\n", dbName)
return nil
}
func runMigrations(dbURL string) {
m, err := migrate.New(
"file:///app/db/migrations", // Path inside Docker container
dbURL,
)
// runMigrations applies pending migrations, embedded into the binary via
// db/migrations/embed.go. A failure here is fatal: the alternative is an API
// that starts and answers requests against a schema that was never created.
func runMigrations(dbURL string) error {
source, err := iofs.New(migrations.FS, ".")
if err != nil {
// Fallback for local development if not in /app
if os.Getenv("MIGRATION_PATH") != "" {
m, err = migrate.New(
"file://"+os.Getenv("MIGRATION_PATH"),
dbURL,
)
} else {
// Try relative path default
m, err = migrate.New(
"file://db/migrations",
dbURL,
)
}
return fmt.Errorf("load embedded migrations: %w", err)
}
// golang-migrate's pgx/v5 driver is registered under the "pgx5" scheme;
// swap it in regardless of whether DATABASE_URL says postgres:// or
// postgresql://.
u, err := url.Parse(dbURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Migration initialization failed: %v\n", err)
return
return fmt.Errorf("parse DATABASE_URL: %w", err)
}
u.Scheme = "pgx5"
m, err := migrate.NewWithSourceInstance("iofs", source, u.String())
if err != nil {
return fmt.Errorf("init migrator: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
} else {
fmt.Println("Migrations ran successfully")
return fmt.Errorf("apply migrations: %w", err)
}
fmt.Println("Migrations applied.")
return nil
}
func main() {
// 1. Database connection
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://user:password@localhost:5432/dbname?sslmode=disable"
// Matches docker-compose.yaml's db service, so `just run-api`
// against `just docker-up`'s database works without extra setup.
dbURL = "postgres://postgres:password@localhost:5432/gamedb?sslmode=disable"
}
// Ensure Database Exists
if err := ensureDatabase(dbURL); err != nil {
fmt.Fprintf(os.Stderr, "Failed to ensure database: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to ensure database exists: %v\n", err)
os.Exit(1)
}
// Run Migrations
runMigrations(dbURL)
if err := runMigrations(dbURL); err != nil {
fmt.Fprintf(os.Stderr, "Failed to run migrations: %v\n", err)
os.Exit(1)
}
pool, err := pgxpool.New(context.Background(), dbURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Ping(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
}
queries := db.New(pool)
// 2. Setup Echo
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// 3. Setup Huma
humaApi := humaecho.New(e, huma.DefaultConfig("My API", "1.0.0"))
humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0"))
// 4. Register Routes
api.RegisterRoutes(humaApi, queries)
// 5. Start Server
port := os.Getenv("PORT")
if port == "" {
port = "8080"