diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15c8c3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go mod tidy (no diff expected) + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + sqlc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: sqlc-dev/setup-sqlc@v4 + with: + sqlc-version: "1.31.1" + + - name: sqlc diff (generated code must match the schema/queries) + run: sqlc diff diff --git a/.gitignore b/.gitignore index f3e8d20..8629865 100644 --- a/.gitignore +++ b/.gitignore @@ -25,46 +25,5 @@ go.work.sum # env file .env -# ---> Lua -# Compiled Lua sources -luac.out - -# luarocks build files -*.src.rock -*.zip -*.tar.gz - -# Object files -*.o -*.os -*.ko -*.obj -*.elf - -# Precompiled Headers -*.gch -*.pch - -# Libraries -*.lib -*.a -*.la -*.lo -*.def -*.exp - -# Shared objects (inc. Windows DLLs) -*.dll -*.so -*.so.* -*.dylib - -# Executables -*.exe -*.out -*.app -*.i*86 -*.x86_64 -*.hex - - +# Justfile build output (bin/api, bin/tui, bin/gui) +bin/ diff --git a/Dockerfile b/Dockerfile index 4e60d8d..78c9e01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build Stage -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app @@ -18,7 +18,7 @@ COPY . . # Build the application # CGO_ENABLED=0 ensures a static binary ARG TARGETOS TARGETARCH -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o api-server ./cmd/api/main.go +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o api-server ./cmd/api # Runtime Stage FROM alpine:latest @@ -28,12 +28,10 @@ WORKDIR /app # Install ca-certificates for database connections (TLS) RUN apk add --no-cache ca-certificates -# Copy the binary from the builder stage +# Copy the binary from the builder stage. Migrations are embedded in the +# binary (see db/migrations/embed.go), so there's nothing else to copy. COPY --from=builder /app/api-server . -# Copy migrations -COPY --from=builder /app/db/migrations ./db/migrations - # Expose the application port EXPOSE 8080 diff --git a/Justfile b/Justfile index 65ebe1d..60b56ef 100644 --- a/Justfile +++ b/Justfile @@ -16,11 +16,11 @@ run-api: # Start the API and Database using Docker Compose docker-up: - docker-compose up --build -d + docker compose up --build -d # Stop Docker Compose services docker-down: - docker-compose down + docker compose down # Run all tests test: @@ -60,6 +60,10 @@ run-gui: sqlc: sqlc generate +# Write the OpenAPI spec to api/openapi.json +openapi: + go run ./cmd/openapi + # Connect to the running database via psql db-shell: - docker-compose exec db psql -U postgres -d gamedb + docker compose exec db psql -U postgres -d gamedb diff --git a/README.md b/README.md index 0ef55ea..c61868b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ # Completed +A tracker for a video game collection: what you own, which platform it's on, +your score for it, and whether (and when) you finished it. + +## Status + +Each account has its own private collection. The API is usable; there's no +GUI yet beyond two read-only clients (see below). + +## Running it + +```sh +just docker-up # starts Postgres and the API on :8080 +just docker-down # stops them +``` + +Or against a Postgres you already have running locally: + +```sh +export DATABASE_URL="postgres://postgres:password@localhost:5432/gamedb?sslmode=disable" +just run-api +``` + +The API creates its database and runs migrations on startup if needed. +Browse `http://localhost:8080/docs` for the generated API docs. + +## Using the API + +```sh +# Register (also logs you in) +curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \ + -d '{"email":"you@example.com","password":"a long password","name":"You"}' +# -> {"token": "...", "user": {...}} + +TOKEN=... # the token from above + +curl -X POST localhost:8080/api/platforms -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"name":"PC"}' + +curl localhost:8080/api/games -H "Authorization: Bearer $TOKEN" +``` + +A browser-based client can skip the `Authorization` header: register/login +also set an httpOnly `session` cookie. + +## Other clients + +- `just run-tui` — a read-only terminal viewer of `/games` (no login yet) +- `just run-gui` — a read-only raylib desktop viewer of `/games` (no login + yet; planned to move to Fyne) + +## Development + +```sh +just test # go test ./... +just sqlc # regenerate internal/db after changing db/query.sql or a migration +``` + +Schema changes go in `db/migrations` as a new `_.up.sql` / +`.down.sql` pair; `sqlc.yaml` reads the schema from that directory directly, +so there's no separate schema file to keep in sync. diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 0000000..8be1d82 --- /dev/null +++ b/api/openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","email","name"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account"}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session"}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user"}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account"}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games"},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game"}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game"},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game"},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game"}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms"},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform"}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform"},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform"},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform"}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello"}}}} diff --git a/cmd/api/main.go b/cmd/api/main.go index 9265dc1..7209db3 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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" diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go new file mode 100644 index 0000000..07b4dcd --- /dev/null +++ b/cmd/openapi/main.go @@ -0,0 +1,47 @@ +// Command openapi writes the API's OpenAPI spec to api/openapi.json. +// Clients (the web frontend, generated SDKs) treat that file as the +// contract for what the server exposes; regenerate it after any route +// change with `just openapi`. +// +// It builds the same route tree as cmd/api, but against a stub echo +// server with no real database connection: registering routes only +// inspects Go types via reflection and never calls the database, so a nil +// *db.Queries (which still satisfies db.Querier) is enough. +package main + +import ( + "fmt" + "os" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/labstack/echo/v4" + + "completed/internal/api" + "completed/internal/db" +) + +func main() { + e := echo.New() + humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) + + var queries db.Querier = (*db.Queries)(nil) + api.RegisterRoutes(humaApi, queries) + + data, err := humaApi.OpenAPI().MarshalJSON() + if err != nil { + fmt.Fprintf(os.Stderr, "marshal OpenAPI spec: %v\n", err) + os.Exit(1) + } + + const outPath = "api/openapi.json" + if err := os.MkdirAll("api", 0o755); err != nil { + fmt.Fprintf(os.Stderr, "create api/ directory: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(outPath, append(data, '\n'), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err) + os.Exit(1) + } + fmt.Printf("Wrote %s\n", outPath) +} diff --git a/db/migrations/20251222211500_add_auth_to_users.down.sql b/db/migrations/20251222211500_add_auth_to_users.down.sql deleted file mode 100644 index 3616c65..0000000 --- a/db/migrations/20251222211500_add_auth_to_users.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE users -DROP COLUMN email, -DROP COLUMN password_hash; diff --git a/db/migrations/20251222211500_add_auth_to_users.up.sql b/db/migrations/20251222211500_add_auth_to_users.up.sql deleted file mode 100644 index 0f3a471..0000000 --- a/db/migrations/20251222211500_add_auth_to_users.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE users -ADD COLUMN email TEXT NOT NULL UNIQUE, -ADD COLUMN password_hash TEXT NOT NULL; diff --git a/db/migrations/20261003000000_init.down.sql b/db/migrations/20261003000000_init.down.sql new file mode 100644 index 0000000..b42f354 --- /dev/null +++ b/db/migrations/20261003000000_init.down.sql @@ -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; diff --git a/db/migrations/20261003000000_init.up.sql b/db/migrations/20261003000000_init.up.sql new file mode 100644 index 0000000..fa5be1c --- /dev/null +++ b/db/migrations/20261003000000_init.up.sql @@ -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(); diff --git a/db/migrations/embed.go b/db/migrations/embed.go new file mode 100644 index 0000000..bf8cb46 --- /dev/null +++ b/db/migrations/embed.go @@ -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 diff --git a/db/query.sql b/db/query.sql index 39b5fd8..a13bd9f 100644 --- a/db/query.sql +++ b/db/query.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql deleted file mode 100644 index 9ba015c..0000000 --- a/db/schema.sql +++ /dev/null @@ -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 -); diff --git a/docker-compose.yaml b/docker-compose.yaml index 0936c24..7765d71 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: app: build: . @@ -9,7 +7,8 @@ services: - PORT=8080 - DATABASE_URL=postgres://postgres:password@db:5432/gamedb?sslmode=disable depends_on: - - db + db: + condition: service_healthy restart: on-failure db: @@ -23,6 +22,11 @@ services: - "5432:5432" volumes: - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d gamedb"] + interval: 5s + timeout: 5s + retries: 10 volumes: db-data: diff --git a/go.mod b/go.mod index 5993383..069d649 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,13 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/danielgtaylor/huma/v2 v2.34.1 + github.com/gen2brain/raylib-go/raylib v0.55.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/labstack/echo/v4 v4.14.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.46.0 ) require ( @@ -20,13 +25,12 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/ebitengine/purego v0.7.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/gen2brain/raylib-go/raylib v0.55.1 // indirect - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/lib/pq v1.10.9 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -37,11 +41,10 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 94e8194..e7a55fa 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -14,23 +18,46 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8= github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -39,6 +66,10 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M= github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -55,19 +86,32 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -79,10 +123,18 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= @@ -98,6 +150,8 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/auth_routes.go b/internal/api/auth_routes.go new file mode 100644 index 0000000..a74175c --- /dev/null +++ b/internal/api/auth_routes.go @@ -0,0 +1,160 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "completed/internal/auth" + "completed/internal/db" + "completed/internal/model" +) + +type authOutput struct { + SetCookie string `header:"Set-Cookie"` + Body model.AuthResponse +} + +// sessionCookie builds the Set-Cookie value for a successful login. Secure +// is left unset because the app is also meant to run over plain HTTP in +// local/docker-compose use; put a TLS-terminating proxy in front for +// anything reachable over the network and set Secure there instead. +func sessionCookie(token string) string { + c := http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: auth.SessionDuration, + } + return c.String() +} + +func clearedSessionCookie() string { + c := http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + } + return c.String() +} + +// newSession creates and stores a session for userID, returning the token +// to hand back to the client (only its hash is ever persisted). +func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (string, error) { + token, tokenHash, err := auth.NewSessionToken() + if err != nil { + return "", err + } + err = queries.CreateSession(ctx, db.CreateSessionParams{ + TokenHash: tokenHash, + UserID: userID, + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(auth.SessionDuration * time.Second), Valid: true}, + }) + return token, err +} + +func registerAuthRoutes(api huma.API, queries db.Querier) { + huma.Register(api, huma.Operation{ + OperationID: "register", + Method: http.MethodPost, + Path: "/api/auth/register", + Summary: "Register a new account", + }, func(ctx context.Context, input *struct{ Body model.RegisterInput }) (*authOutput, error) { + hash, err := auth.HashPassword(input.Body.Password) + if err != nil { + return nil, huma.Error500InternalServerError("could not process password") + } + + u, err := queries.CreateUser(ctx, db.CreateUserParams{ + Email: input.Body.Email, + PasswordHash: hash, + Name: input.Body.Name, + Bio: pgtype.Text{}, + }) + if err != nil { + return nil, dbError(err) + } + + token, err := newSession(ctx, queries, u.ID) + if err != nil { + return nil, huma.Error500InternalServerError("could not create session") + } + + return &authOutput{ + SetCookie: sessionCookie(token), + Body: model.AuthResponse{ + Token: token, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + }, + }, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "login", + Method: http.MethodPost, + Path: "/api/auth/login", + Summary: "Log in to an existing account", + }, func(ctx context.Context, input *struct{ Body model.LoginInput }) (*authOutput, error) { + u, err := queries.GetUserByEmail(ctx, input.Body.Email) + if err != nil { + // Same message whether the email is unknown or the password is + // wrong, so a client can't use this endpoint to enumerate + // registered emails. + return nil, huma.Error401Unauthorized("invalid email or password") + } + + ok, err := auth.VerifyPassword(input.Body.Password, u.PasswordHash) + if err != nil || !ok { + return nil, huma.Error401Unauthorized("invalid email or password") + } + + token, err := newSession(ctx, queries, u.ID) + if err != nil { + return nil, huma.Error500InternalServerError("could not create session") + } + + return &authOutput{ + SetCookie: sessionCookie(token), + Body: model.AuthResponse{ + Token: token, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + }, + }, nil + }) + + logoutOp := huma.Operation{ + OperationID: "logout", + Method: http.MethodPost, + Path: "/api/auth/logout", + Summary: "Log out of the current session", + } + addAuth(&logoutOp, api, queries) + huma.Register(api, logoutOp, func(ctx context.Context, input *struct{}) (*struct { + SetCookie string `header:"Set-Cookie"` + }, error) { + _ = queries.DeleteSession(ctx, auth.HashSessionToken(tokenFromContext(ctx))) + return &struct { + SetCookie string `header:"Set-Cookie"` + }{SetCookie: clearedSessionCookie()}, nil + }) + + meOp := huma.Operation{ + OperationID: "get-me", + Method: http.MethodGet, + Path: "/api/auth/me", + Summary: "Get the current user", + } + addAuth(&meOp, api, queries) + huma.Register(api, meOp, func(ctx context.Context, input *struct{}) (*struct{ Body model.User }, error) { + return &struct{ Body model.User }{Body: userFromContext(ctx).toModel()}, nil + }) +} diff --git a/internal/api/context.go b/internal/api/context.go new file mode 100644 index 0000000..2540fe7 --- /dev/null +++ b/internal/api/context.go @@ -0,0 +1,38 @@ +package api + +import ( + "context" + + "github.com/google/uuid" + + "completed/internal/model" +) + +// authedUser is what AuthMiddleware stores in the request context after +// validating a session token. Handlers read it with userFromContext. +type authedUser struct { + ID uuid.UUID + Email string + Name string + Bio string +} + +type ctxKey string + +const userCtxKey ctxKey = "completed.user" + +func (u authedUser) toModel() model.User { + return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio} +} + +// userFromContext returns the authenticated user set by AuthMiddleware. It +// panics if called from a route that isn't behind AuthMiddleware, since that +// is a programming error (a handler trusting a context value that was never +// guaranteed to be there), not a condition callers should need to check. +func userFromContext(ctx context.Context) authedUser { + u, ok := ctx.Value(userCtxKey).(authedUser) + if !ok { + panic("api: userFromContext called outside AuthMiddleware") + } + return u +} diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..ff9825c --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,52 @@ +package api + +import ( + "errors" + "log" + + "github.com/danielgtaylor/huma/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Postgres error codes this package maps to specific HTTP statuses. +// See https://www.postgresql.org/docs/current/errcodes-appendix.html +const ( + pgCodeUniqueViolation = "23505" + pgCodeForeignKeyViolation = "23503" +) + +// dbError turns a database error into the huma error a handler should +// return, so Postgres's own error text never reaches a client and callers +// get the right HTTP status instead of a generic 500. +func dbError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return huma.Error404NotFound("not found") + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case pgCodeUniqueViolation: + return huma.Error409Conflict("already exists") + case pgCodeForeignKeyViolation: + // A foreign key failure here is either a reference to a platform + // that doesn't exist (create/update) or a delete blocked by + // games that still reference this platform. + return huma.Error409Conflict("referenced by another record, or refers to one that doesn't exist") + } + } + + log.Printf("unmapped database error: %v", err) + return huma.Error500InternalServerError("internal error") +} + +// notFoundIfZero turns the "0 rows affected" result of a soft-delete +// UPDATE into a 404, since the row either never existed or was already +// deleted. +func notFoundIfZero(rows int64) error { + if rows == 0 { + return huma.Error404NotFound("not found") + } + return nil +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..ba0998c --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,88 @@ +package api + +import ( + "context" + "errors" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/jackc/pgx/v5" + + "completed/internal/auth" + "completed/internal/db" +) + +// sessionCookieName is also used by the auth routes when setting/clearing +// the cookie, so it lives here as the one shared constant. +const sessionCookieName = "session" + +// bearerToken extracts the token from "Authorization: Bearer ", or +// the empty string if the header is missing or malformed. +func bearerToken(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + return strings.TrimPrefix(header, prefix) +} + +// tokenFromRequest looks for a session token in the Authorization header +// first (used by the TUI and Fyne clients), falling back to the session +// cookie (used by the web client). +func tokenFromRequest(ctx huma.Context) string { + if t := bearerToken(ctx.Header("Authorization")); t != "" { + return t + } + if c, err := huma.ReadCookie(ctx, sessionCookieName); err == nil && c != nil { + return c.Value + } + return "" +} + +// newAuthMiddleware returns Huma middleware that requires a valid session, +// set via AuthMiddleware on individual operations. On success it stores the +// authenticated user in the request context for userFromContext to read. +func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + token := tokenFromRequest(ctx) + if token == "" { + huma.WriteErr(api, ctx, 401, "authentication required") + return + } + + row, err := queries.GetSessionAndUser(ctx.Context(), auth.HashSessionToken(token)) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + // Fail closed: an unreachable DB fails auth, not through. + huma.WriteErr(api, ctx, 401, "authentication required") + return + } + huma.WriteErr(api, ctx, 401, "session expired or invalid") + return + } + + // Best-effort; a failure to record activity shouldn't fail the request. + _ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token)) + + user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String} + newCtx := huma.WithValue(ctx, userCtxKey, user) + newCtx = huma.WithValue(newCtx, tokenCtxKey, token) + next(newCtx) + } +} + +// addAuth attaches the auth middleware to op, so it requires a valid +// session. Call it on the *huma.Operation value before huma.Register. +func addAuth(op *huma.Operation, api huma.API, queries db.Querier) { + op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries)) +} + +// tokenCtxKey holds the raw bearer/cookie token for the current request, so +// the logout handler can hash and delete the exact session that was used +// without re-parsing the request. +const tokenCtxKey ctxKey = "completed.token" + +func tokenFromContext(ctx context.Context) string { + t, _ := ctx.Value(tokenCtxKey).(string) + return t +} diff --git a/internal/api/mock_db_test.go b/internal/api/mock_db_test.go index 2c31b9f..ec1e30a 100644 --- a/internal/api/mock_db_test.go +++ b/internal/api/mock_db_test.go @@ -3,61 +3,84 @@ package api import ( "context" + "github.com/google/uuid" + "completed/internal/db" ) +// MockQuerier is a hand-written fake implementing db.Querier for handler +// tests. Each test sets only the *Func fields its handler path reaches; +// any other field left nil panics on call, which is deliberate: it makes an +// unexpectedly-reached query visible as a test failure instead of a nil +// pointer deep in sqlc-generated code. type MockQuerier struct { - CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.Game, error) - CreatePlatformFunc func(ctx context.Context, name string) (db.Platform, error) - CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.User, error) - DeleteGameFunc func(ctx context.Context, id int64) error - DeletePlatformFunc func(ctx context.Context, id int64) error - DeleteUserFunc func(ctx context.Context, id int64) error - GetGameFunc func(ctx context.Context, id int64) (db.Game, error) - GetUserFunc func(ctx context.Context, id int64) (db.User, error) - ListGamesFunc func(ctx context.Context) ([]db.Game, error) - ListPlatformsFunc func(ctx context.Context) ([]db.Platform, error) - ListUsersFunc func(ctx context.Context) ([]db.User, error) - UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) - UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) + CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) + CreatePlatformFunc func(ctx context.Context, arg db.CreatePlatformParams) (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) + 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) + 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) + 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) } -func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.Game, error) { +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, name string) (db.Platform, error) { - return m.CreatePlatformFunc(ctx, name) +func (m *MockQuerier) CreatePlatform(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) { + return m.CreatePlatformFunc(ctx, arg) } -func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) { +func (m *MockQuerier) CreateSession(ctx context.Context, arg db.CreateSessionParams) error { + return m.CreateSessionFunc(ctx, arg) +} +func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) { return m.CreateUserFunc(ctx, arg) } -func (m *MockQuerier) DeleteGame(ctx context.Context, id int64) error { - return m.DeleteGameFunc(ctx, id) +func (m *MockQuerier) DeleteGame(ctx context.Context, arg db.DeleteGameParams) (int64, error) { + return m.DeleteGameFunc(ctx, arg) } -func (m *MockQuerier) DeletePlatform(ctx context.Context, id int64) error { - return m.DeletePlatformFunc(ctx, id) +func (m *MockQuerier) DeletePlatform(ctx context.Context, arg db.DeletePlatformParams) (int64, error) { + return m.DeletePlatformFunc(ctx, arg) } -func (m *MockQuerier) DeleteUser(ctx context.Context, id int64) error { - return m.DeleteUserFunc(ctx, id) +func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error { + return m.DeleteSessionFunc(ctx, tokenHash) } -func (m *MockQuerier) GetGame(ctx context.Context, id int64) (db.Game, error) { - return m.GetGameFunc(ctx, id) +func (m *MockQuerier) GetGame(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) { + return m.GetGameFunc(ctx, arg) } -func (m *MockQuerier) GetUser(ctx context.Context, id int64) (db.User, error) { - return m.GetUserFunc(ctx, id) +func (m *MockQuerier) GetPlatform(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { + return m.GetPlatformFunc(ctx, arg) } -func (m *MockQuerier) ListGames(ctx context.Context) ([]db.Game, error) { - return m.ListGamesFunc(ctx) +func (m *MockQuerier) GetSessionAndUser(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { + return m.GetSessionAndUserFunc(ctx, tokenHash) } -func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.Platform, error) { - return m.ListPlatformsFunc(ctx) +func (m *MockQuerier) GetUserByEmail(ctx context.Context, email string) (db.GetUserByEmailRow, error) { + return m.GetUserByEmailFunc(ctx, email) } -func (m *MockQuerier) ListUsers(ctx context.Context) ([]db.User, error) { - return m.ListUsersFunc(ctx) +func (m *MockQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error) { + return m.GetUserByIDFunc(ctx, id) } -func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) { +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) TouchSession(ctx context.Context, tokenHash []byte) error { + return m.TouchSessionFunc(ctx, tokenHash) +} +func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error) { return m.UpdateGameFunc(ctx, arg) } -func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) { +func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error) { return m.UpdatePlatformFunc(ctx, arg) } diff --git a/internal/api/routes.go b/internal/api/routes.go index f8ceef2..7ff8685 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -6,9 +6,11 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "completed/internal/db" + "completed/internal/model" ) func RegisterRoutes(api huma.API, queries db.Querier) { @@ -33,83 +35,118 @@ func RegisterRoutes(api huma.API, queries db.Querier) { return resp, nil }) + registerAuthRoutes(api, queries) registerPlatformRoutes(api, queries) registerGameRoutes(api, queries) } // --- Platform Routes --- -// PlatformDTO ensures consistent JSON output -type PlatformDTO struct { - ID int64 `json:"id"` - Name string `json:"name"` -} - -type PlatformInput struct { - Name string `json:"name" doc:"Name of the platform"` -} - func registerPlatformRoutes(api huma.API, queries db.Querier) { - huma.Register(api, huma.Operation{ + listOp := huma.Operation{ OperationID: "list-platforms", Method: http.MethodGet, - Path: "/platforms", + Path: "/api/platforms", Summary: "List Platforms", - }, func(ctx context.Context, input *struct{}) (*struct{ Body []PlatformDTO }, error) { - platforms, err := queries.ListPlatforms(ctx) + } + 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) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - dtos := make([]PlatformDTO, len(platforms)) + dtos := make([]model.Platform, len(platforms)) for i, p := range platforms { - dtos[i] = PlatformDTO{ID: p.ID, Name: p.Name} + dtos[i] = model.Platform{ID: p.ID.String(), Name: p.Name} } - return &struct{ Body []PlatformDTO }{Body: dtos}, nil + return &struct{ Body []model.Platform }{Body: dtos}, nil }) - huma.Register(api, huma.Operation{ + getOp := huma.Operation{ + OperationID: "get-platform", + Method: http.MethodGet, + Path: "/api/platforms/{id}", + Summary: "Get Platform", + } + addAuth(&getOp, api, queries) + 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}) + if err != nil { + return nil, dbError(err) + } + return &struct{ Body model.Platform }{Body: model.Platform{ID: p.ID.String(), Name: p.Name}}, nil + }) + + createOp := huma.Operation{ OperationID: "create-platform", Method: http.MethodPost, - Path: "/platforms", + Path: "/api/platforms", Summary: "Create Platform", - }, func(ctx context.Context, input *struct{ Body PlatformInput }) (*struct{ Body PlatformDTO }, error) { - platform, err := queries.CreatePlatform(ctx, input.Body.Name) - if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) - } - return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil - }) - - huma.Register(api, huma.Operation{ - OperationID: "update-platform", - Method: http.MethodPut, - Path: "/platforms/{id}", - Summary: "Update Platform", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` - Body PlatformInput - }) (*struct{ Body PlatformDTO }, error) { - platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{ - ID: input.ID, - Name: input.Body.Name, + } + 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, }) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil + return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil }) - huma.Register(api, huma.Operation{ + updateOp := huma.Operation{ + OperationID: "update-platform", + Method: http.MethodPut, + Path: "/api/platforms/{id}", + Summary: "Update Platform", + } + addAuth(&updateOp, api, queries) + huma.Register(api, updateOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` + 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, + }) + if err != nil { + return nil, dbError(err) + } + return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil + }) + + deleteOp := huma.Operation{ OperationID: "delete-platform", Method: http.MethodDelete, - Path: "/platforms/{id}", + Path: "/api/platforms/{id}", Summary: "Delete Platform", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` + } + addAuth(&deleteOp, api, queries) + huma.Register(api, deleteOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` }) (*struct{}, error) { - err := queries.DeletePlatform(ctx, input.ID) + 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 + // DeletePlatform's comment in db/query.sql). Accepts a race where + // 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 { + return nil, dbError(err) + } + + rows, err := queries.DeletePlatform(ctx, db.DeletePlatformParams{ID: input.ID, UserID: userID}) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) + } + if rows == 0 { + return nil, huma.Error409Conflict("platform still has games; delete or reassign them first") } return nil, nil }) @@ -117,40 +154,21 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { // --- Game Routes --- -// GameDTO for API communication (Clean JSON) -type GameDTO struct { - ID int64 `json:"id"` - Name string `json:"name"` - PlatformID int64 `json:"platform_id"` - Score int32 `json:"score"` - ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"` - Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` -} - -type GameInput struct { - Name string `json:"name"` - PlatformID int64 `json:"platform_id"` - Score int32 `json:"score"` - ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"` - Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` -} - -// Helper to convert DB Game to DTO -func toGameDTO(g db.Game) GameDTO { - dto := GameDTO{ - ID: g.ID, - Name: g.Name, - PlatformID: g.PlatformID, - Score: g.Score, - ReleaseYear: g.ReleaseYear.Time.Format("2006-01-02"), +// formatDate and parseDate convert between the API's YYYY-MM-DD strings and +// pgtype.Date. CreateGame, GetGame, ListGames and UpdateGame each return a +// distinct sqlc row type with identical fields (id/name/platform_id/ +// platform_name/score/release_date/finished), so the DTO conversion itself +// is written out at each call site rather than factored into one function. +func formatDate(d pgtype.Date) string { + if !d.Valid { + return "" } - if g.Finished.Valid { - dto.Finished = g.Finished.Time.Format("2006-01-02") - } - return dto + return d.Time.Format("2006-01-02") } -// Helper to parse date string to pgtype.Date +// parseDate parses a YYYY-MM-DD string into a pgtype.Date. An empty string +// parses to an invalid/NULL date, which is valid for the optional +// "finished" field but rejected separately for the required release_date. func parseDate(s string) (pgtype.Date, error) { if s == "" { return pgtype.Date{}, nil @@ -163,95 +181,162 @@ func parseDate(s string) (pgtype.Date, error) { } func registerGameRoutes(api huma.API, queries db.Querier) { - huma.Register(api, huma.Operation{ + listOp := huma.Operation{ OperationID: "list-games", Method: http.MethodGet, - Path: "/games", + Path: "/api/games", Summary: "List Games", - }, func(ctx context.Context, input *struct{}) (*struct{ Body []GameDTO }, error) { - games, err := queries.ListGames(ctx) + } + addAuth(&listOp, api, queries) + huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Game }, error) { + games, err := queries.ListGames(ctx, userFromContext(ctx).ID) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - dtos := make([]GameDTO, len(games)) + dtos := make([]model.Game, len(games)) for i, g := range games { - dtos[i] = toGameDTO(g) + dtos[i] = model.Game{ + ID: g.ID.String(), + Name: g.Name, + PlatformID: g.PlatformID.String(), + PlatformName: g.PlatformName, + Score: g.Score, + ReleaseDate: formatDate(g.ReleaseDate), + Finished: formatDate(g.Finished), + } } - return &struct{ Body []GameDTO }{Body: dtos}, nil + return &struct{ Body []model.Game }{Body: dtos}, nil }) - huma.Register(api, huma.Operation{ + getOp := huma.Operation{ + OperationID: "get-game", + Method: http.MethodGet, + Path: "/api/games/{id}", + Summary: "Get Game", + } + addAuth(&getOp, api, queries) + huma.Register(api, getOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` + }) (*struct{ Body model.Game }, error) { + g, err := queries.GetGame(ctx, db.GetGameParams{ID: input.ID, UserID: userFromContext(ctx).ID}) + if err != nil { + return nil, dbError(err) + } + return &struct{ Body model.Game }{Body: model.Game{ + ID: g.ID.String(), + Name: g.Name, + PlatformID: g.PlatformID.String(), + PlatformName: g.PlatformName, + Score: g.Score, + ReleaseDate: formatDate(g.ReleaseDate), + Finished: formatDate(g.Finished), + }}, nil + }) + + createOp := huma.Operation{ OperationID: "create-game", Method: http.MethodPost, - Path: "/games", + Path: "/api/games", Summary: "Create Game", - }, func(ctx context.Context, input *struct{ Body GameInput }) (*struct{ Body GameDTO }, error) { - ry, err := parseDate(input.Body.ReleaseYear) + } + addAuth(&createOp, api, queries) + huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.GameInput }) (*struct{ Body model.Game }, error) { + platformID, err := uuid.Parse(input.Body.PlatformID) if err != nil { - return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid platform_id") + } + ry, err := parseDate(input.Body.ReleaseDate) + if err != nil || !ry.Valid { + return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)") } fin, err := parseDate(input.Body.Finished) if err != nil { - return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)") } game, err := queries.CreateGame(ctx, db.CreateGameParams{ + UserID: userFromContext(ctx).ID, + PlatformID: platformID, Name: input.Body.Name, - PlatformID: input.Body.PlatformID, Score: input.Body.Score, - ReleaseYear: ry, + ReleaseDate: ry, Finished: fin, }) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil + return &struct{ Body model.Game }{Body: model.Game{ + ID: game.ID.String(), + Name: game.Name, + PlatformID: game.PlatformID.String(), + PlatformName: game.PlatformName, + Score: game.Score, + ReleaseDate: formatDate(game.ReleaseDate), + Finished: formatDate(game.Finished), + }}, nil }) - huma.Register(api, huma.Operation{ + updateOp := huma.Operation{ OperationID: "update-game", Method: http.MethodPut, - Path: "/games/{id}", + Path: "/api/games/{id}", Summary: "Update Game", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` - Body GameInput - }) (*struct{ Body GameDTO }, error) { - ry, err := parseDate(input.Body.ReleaseYear) + } + addAuth(&updateOp, api, queries) + huma.Register(api, updateOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` + Body model.GameInput + }) (*struct{ Body model.Game }, error) { + platformID, err := uuid.Parse(input.Body.PlatformID) if err != nil { - return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid platform_id") + } + ry, err := parseDate(input.Body.ReleaseDate) + if err != nil || !ry.Valid { + return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)") } fin, err := parseDate(input.Body.Finished) if err != nil { - return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)") } game, err := queries.UpdateGame(ctx, db.UpdateGameParams{ ID: input.ID, + UserID: userFromContext(ctx).ID, + PlatformID: platformID, Name: input.Body.Name, - PlatformID: input.Body.PlatformID, Score: input.Body.Score, - ReleaseYear: ry, + ReleaseDate: ry, Finished: fin, }) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil + return &struct{ Body model.Game }{Body: model.Game{ + ID: game.ID.String(), + Name: game.Name, + PlatformID: game.PlatformID.String(), + PlatformName: game.PlatformName, + Score: game.Score, + ReleaseDate: formatDate(game.ReleaseDate), + Finished: formatDate(game.Finished), + }}, nil }) - huma.Register(api, huma.Operation{ + deleteOp := huma.Operation{ OperationID: "delete-game", Method: http.MethodDelete, - Path: "/games/{id}", + Path: "/api/games/{id}", Summary: "Delete Game", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` + } + addAuth(&deleteOp, api, queries) + huma.Register(api, deleteOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` }) (*struct{}, error) { - err := queries.DeleteGame(ctx, input.ID) + rows, err := queries.DeleteGame(ctx, db.DeleteGameParams{ID: input.ID, UserID: userFromContext(ctx).ID}) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return nil, nil + return nil, notFoundIfZero(rows) }) } diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go index 99bdbf6..1ddd215 100644 --- a/internal/api/routes_test.go +++ b/internal/api/routes_test.go @@ -9,58 +9,223 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "completed/internal/auth" "completed/internal/db" ) -func TestListGames(t *testing.T) { - // 1. Setup Mock - mockDB := &MockQuerier{ - ListGamesFunc: func(ctx context.Context) ([]db.Game, error) { - return []db.Game{ - {ID: 1, Name: "Test Game", Score: 90}, - }, nil - }, - } - - // 2. Setup API +// newTestAPI wires mockDB into a fresh Huma/Echo instance exactly as +// cmd/api/main.go does, so tests exercise routing and middleware, not just +// the handler bodies. +func newTestAPI(mockDB db.Querier) (*echo.Echo, huma.API) { e := echo.New() api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0")) RegisterRoutes(api, mockDB) + return e, api +} - // 3. Execute Request - req := httptest.NewRequest(http.MethodGet, "/games", nil) +// a fixed valid session, returned by GetSessionAndUserFunc so authed +// requests don't need a real database. +var testUserID = uuid.New() + +func authedMock() *MockQuerier { + return &MockQuerier{ + GetSessionAndUserFunc: func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { + return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice"}, nil + }, + TouchSessionFunc: func(ctx context.Context, tokenHash []byte) error { return nil }, + } +} + +func authedRequest(method, path, body string) *http.Request { + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer any-token-the-mock-accepts") + return req +} + +func TestListGamesRequiresAuth(t *testing.T) { + e, _ := newTestAPI(&MockQuerier{}) + + req := httptest.NewRequest(http.MethodGet, "/api/games", nil) rec := httptest.NewRecorder() e.ServeHTTP(rec, req) - // 4. Assert + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestListGames(t *testing.T) { + mockDB := authedMock() + mockDB.ListGamesFunc = func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) { + assert.Equal(t, testUserID, userID, "handler must scope the query to the authenticated user") + return []db.ListGamesRow{ + {ID: uuid.New(), Name: "Test Game", Score: 90, PlatformName: "PC"}, + }, nil + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games", "")) + assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "Test Game") + assert.Contains(t, rec.Body.String(), `"platform_name":"PC"`) +} + +func TestGetGameNotFoundForOtherUsersGame(t *testing.T) { + // A game that exists, but belongs to someone else: the query is scoped + // by user_id, so sqlc/postgres report it as not found, same as a + // genuinely missing id. The handler must turn that into 404, not 500. + mockDB := authedMock() + mockDB.GetGameFunc = func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) { + assert.Equal(t, testUserID, arg.UserID, "handler must scope the lookup to the authenticated user") + return db.GetGameRow{}, pgx.ErrNoRows + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestDeleteGameNotFoundWhenNoRowsAffected(t *testing.T) { + mockDB := authedMock() + mockDB.DeleteGameFunc = func(ctx context.Context, arg db.DeleteGameParams) (int64, error) { + return 0, nil + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/games/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { + // Soft delete means the FK is never actually checked by Postgres (see + // db/query.sql's DeletePlatform comment), so the handler has to notice + // 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.DeletePlatformFunc = func(ctx context.Context, arg db.DeletePlatformParams) (int64, error) { + return 0, nil + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusConflict, rec.Code) +} + +func TestDeletePlatformNotFound(t *testing.T) { + mockDB := authedMock() + mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { + return db.GetPlatformRow{}, pgx.ErrNoRows + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestCreateGameRejectsScoreOutOfRange(t *testing.T) { + mockDB := authedMock() + // No CreateGameFunc set: validation must reject the request before the + // handler ever calls the database, or this test would panic. + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + body := `{"name":"Bad","platform_id":"` + uuid.New().String() + `","score":150,"release_date":"2020-01-01"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/games", body)) + + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) } func TestCreatePlatform(t *testing.T) { - // 1. Setup Mock + 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 + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`)) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Sega Genesis") +} + +func TestRegisterDuplicateEmailConflicts(t *testing.T) { mockDB := &MockQuerier{ - CreatePlatformFunc: func(ctx context.Context, name string) (db.Platform, error) { - return db.Platform{ID: 1, Name: name}, nil + CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) { + return db.CreateUserRow{}, &pgconn.PgError{Code: pgCodeUniqueViolation} }, } - // 2. Setup API - e := echo.New() - api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0")) - RegisterRoutes(api, mockDB) - - // 3. Execute Request - req := httptest.NewRequest(http.MethodPost, "/platforms", strings.NewReader(`{"name": "Sega Genesis"}`)) - req.Header.Set("Content-Type", "application/json") + e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) + body := `{"email":"a@example.com","password":"password123","name":"Alice"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/register", body)) - // 4. Assert + assert.Equal(t, http.StatusConflict, rec.Code) +} + +func TestLoginWrongPasswordUnauthorized(t *testing.T) { + hash, err := auth.HashPassword("correct-password") + require.NoError(t, err) + + mockDB := &MockQuerier{ + GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) { + return db.GetUserByEmailRow{ID: testUserID, Email: email, PasswordHash: hash}, nil + }, + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + body := `{"email":"a@example.com","password":"wrong-password"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body)) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestLoginSuccessSetsSessionCookie(t *testing.T) { + hash, err := auth.HashPassword("correct-password") + require.NoError(t, err) + + mockDB := &MockQuerier{ + GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) { + return db.GetUserByEmailRow{ID: testUserID, Email: email, Name: "Alice", PasswordHash: hash}, nil + }, + CreateSessionFunc: func(ctx context.Context, arg db.CreateSessionParams) error { + assert.Equal(t, testUserID, arg.UserID) + return nil + }, + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + body := `{"email":"a@example.com","password":"correct-password"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body)) + assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "Sega Genesis") - assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Header().Get("Set-Cookie"), "session=") } diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..3b5a627 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,69 @@ +// Package auth handles password hashing and session tokens. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters, taken from the RFC 9106 "low memory" recommendation +// (2nd choice) as a reasonable default for a server handling interactive +// logins without dedicated hardware. +const ( + argonTime = 2 + argonMemory = 19 * 1024 // KiB + argonThreads = 1 + argonKeyLen = 32 + saltLen = 16 +) + +// HashPassword returns a self-describing hash string: algorithm, parameters, +// salt and key are all encoded together so the parameters can change later +// without invalidating hashes already stored. +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Key := base64.RawStdEncoding.EncodeToString(key) + return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonTime, argonThreads, b64Salt, b64Key), nil +} + +// VerifyPassword checks password against an encoded hash produced by +// HashPassword. It returns an error only on malformed input; a wrong +// password is reported via the bool return. +func VerifyPassword(password, encoded string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 5 || parts[0] != "argon2id" { + return false, errors.New("unrecognized hash format") + } + + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false, fmt.Errorf("parse hash params: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false, fmt.Errorf("decode salt: %w", err) + } + wantKey, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("decode key: %w", err) + } + + gotKey := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantKey))) + return subtle.ConstantTimeCompare(gotKey, wantKey) == 1, nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..e48bd8c --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,51 @@ +package auth + +import "testing" + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + ok, err := VerifyPassword("correct horse battery staple", hash) + if err != nil { + t.Fatalf("VerifyPassword: %v", err) + } + if !ok { + t.Error("VerifyPassword returned false for the correct password") + } + + ok, err = VerifyPassword("wrong password", hash) + if err != nil { + t.Fatalf("VerifyPassword: %v", err) + } + if ok { + t.Error("VerifyPassword returned true for an incorrect password") + } +} + +func TestVerifyPasswordMalformedHash(t *testing.T) { + if _, err := VerifyPassword("anything", "not-a-valid-hash"); err == nil { + t.Error("expected an error for a malformed hash, got nil") + } +} + +func TestNewSessionTokenIsUnpredictableAndHashVerifiable(t *testing.T) { + token1, hash1, err := NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + token2, _, err := NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + if token1 == token2 { + t.Error("two calls to NewSessionToken produced the same token") + } + + gotHash := HashSessionToken(token1) + if string(gotHash) != string(hash1) { + t.Error("HashSessionToken(token) does not match the hash returned alongside it") + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..0d23181 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,37 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// SessionDuration controls how long a login stays valid without activity. +// TouchSession bumps last_seen_at on each authenticated request, but +// expires_at (set at login) is what actually gates access. +const SessionDuration = 30 * 24 * 60 * 60 // 30 days, in seconds + +// tokenLen is deliberately large: this is the only secret standing between +// an attacker and a full account take-over, so it gets more entropy than a +// password would need. +const tokenLen = 32 + +// NewSessionToken returns a random token to hand to the client, and the hash +// of it to store in the database. Only the hash is ever persisted, so a +// leaked database dump doesn't also leak usable session tokens. +func NewSessionToken() (token string, hash []byte, err error) { + raw := make([]byte, tokenLen) + if _, err := rand.Read(raw); err != nil { + return "", nil, fmt.Errorf("generate token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(raw) + return token, HashSessionToken(token), nil +} + +// HashSessionToken hashes a token presented by a client so it can be looked +// up against the stored hash. +func HashSessionToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} diff --git a/internal/db/db.go b/internal/db/db.go index 9d485b5..468d1fa 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db diff --git a/internal/db/models.go b/internal/db/models.go index 48c38f8..b26e2ce 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -1,29 +1,50 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db import ( + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type Game 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 + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + ChangeSeq int64 } type Platform struct { - ID int64 - Name string + ID uuid.UUID + UserID uuid.UUID + Name string + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + ChangeSeq int64 +} + +type Session struct { + TokenHash []byte + UserID uuid.UUID + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz + LastSeenAt pgtype.Timestamptz } type User struct { - ID int64 - Name string - Bio pgtype.Text + ID uuid.UUID + Email string + PasswordHash string + Name string + Bio pgtype.Text + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz } diff --git a/internal/db/querier.go b/internal/db/querier.go index f1e8f05..b297975 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -1,27 +1,37 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db import ( "context" + + "github.com/google/uuid" ) type Querier interface { - CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) - CreatePlatform(ctx context.Context, name string) (Platform, error) - CreateUser(ctx context.Context, arg CreateUserParams) (User, error) - DeleteGame(ctx context.Context, id int64) error - DeletePlatform(ctx context.Context, id int64) error - DeleteUser(ctx context.Context, id int64) error - GetGame(ctx context.Context, id int64) (Game, error) - GetUser(ctx context.Context, id int64) (User, error) - ListGames(ctx context.Context) ([]Game, error) - ListPlatforms(ctx context.Context) ([]Platform, error) - ListUsers(ctx context.Context) ([]User, error) - UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) - UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) + CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) + CreatePlatform(ctx context.Context, arg CreatePlatformParams) (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) + DeleteSession(ctx context.Context, tokenHash []byte) error + GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error) + GetPlatform(ctx context.Context, arg GetPlatformParams) (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) + TouchSession(ctx context.Context, tokenHash []byte) error + UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error) + UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index f0ffc87..4d39be9 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -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 } diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..6903f92 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,58 @@ +// Package model holds the plain data types shared between the API and its +// clients. It has no dependency on Huma, Echo or the database, so clients +// can import it without pulling in the server. +package model + +// User is the public view of an account; it never carries the password hash. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Bio string `json:"bio,omitempty"` +} + +type RegisterInput struct { + Email string `json:"email" format:"email"` + Password string `json:"password" minLength:"8"` + Name string `json:"name" minLength:"1"` +} + +type LoginInput struct { + Email string `json:"email" format:"email"` + Password string `json:"password"` +} + +// AuthResponse is returned by register and login. Token is also set as an +// httpOnly cookie, but is included in the body so non-browser clients (the +// TUI, the Fyne GUI) can store and send it themselves. +type AuthResponse struct { + Token string `json:"token"` + User User `json:"user"` +} + +type Platform struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type PlatformInput struct { + Name string `json:"name" minLength:"1" doc:"Name of the platform"` +} + +type Game struct { + ID string `json:"id"` + Name string `json:"name"` + PlatformID string `json:"platform_id"` + PlatformName string `json:"platform_name"` + Score int32 `json:"score"` + ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"` + Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` +} + +type GameInput struct { + Name string `json:"name" minLength:"1"` + PlatformID string `json:"platform_id"` + Score int32 `json:"score" minimum:"0" maximum:"100"` + ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"` + Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` +} diff --git a/sqlc.yaml b/sqlc.yaml index 6dd3c49..a29b8b4 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,6 +1,6 @@ version: "2" sql: - - schema: "db/schema.sql" + - schema: "db/migrations" queries: "db/query.sql" engine: "postgresql" gen: @@ -9,3 +9,6 @@ sql: out: "internal/db" sql_package: "pgx/v5" emit_interface: true + overrides: + - db_type: "uuid" + go_type: "github.com/google/uuid.UUID"