Rebuild the schema and API on a working, multi-user foundation
Steps 1-4 of the review plan (docs/plans not checked in; see conversation):
Step 1 - make it boot from a clean checkout:
- Replace the old schema.sql + single ALTER-TABLE migration (which never
created the tables) with one init migration: users, sessions, platform,
game, all with UUIDv7-ready uuid ids, soft deletes (deleted_at), and a
shared change_seq sequence + trigger for a future sync feature.
- sqlc now reads the schema from db/migrations directly.
- Migrations are embedded in the binary (db/migrations/embed.go) instead of
being copied around and found via path fallbacks.
- ensureDatabase/runMigrations failures are now fatal, and main pings the
pool after connecting, instead of starting an API with no schema.
- Switched both the app and golang-migrate to pgx/v5 (dropped lib/pq).
- Fixed the Dockerfile's Go version (1.23 -> 1.25) and build target.
- Added a postgres healthcheck to docker-compose so the app waits for a
ready database instead of racing it.
- go.mod tidied, bin/ added to .gitignore, Justfile's docker-compose calls
switched to the v2 `docker compose` plugin.
Step 2 - auth and per-user data:
- internal/auth: argon2id password hashing, random session tokens stored
only as a hash.
- POST /api/auth/{register,login,logout}, GET /api/auth/me. Sessions work
via either an Authorization: Bearer header or an httpOnly cookie.
- Every platform/game query is scoped to the authenticated user.
Step 3 - API correctness:
- internal/model holds the DTOs, with no server-side dependencies, so
clients can import it later without pulling in Huma/Echo.
- dbError() maps ErrNoRows -> 404, unique violation -> 409, FK violation ->
409, instead of leaking Postgres error text with a 500.
- Soft-delete meant the FK on game.platform_id no longer blocks deleting a
referenced platform on its own; added a NOT EXISTS guard plus an
application-level check so that case now correctly returns 409 instead of
silently succeeding (caught by testing the live server, not by a mock).
- Added GET /games/{id} and GET /platforms/{id}. All JSON routes moved
under /api, freeing / for a future web client.
- ListGames/GetGame join platform_name in.
- cmd/openapi writes the generated spec to api/openapi.json.
Step 4 - tests and CI:
- Rewrote the mocks and handler tests for the new Querier shape; added
coverage for 401 (no session), 404 (not found / another user's row),
409 (duplicate email, platform referenced by games), 422 (score out of
range), and that queries are actually scoped by the authenticated user's
id, not just that the right status code comes back.
- .github/workflows/ci.yml: go vet, go test, go mod tidy -diff, sqlc diff.
Everything above was exercised against a real local Postgres (migrations
up/down, the trigger, constraints) and through the running server via curl
(register, login, scoping, every status-code case), not just unit tests.
Known regression, intentionally deferred to the next step: cmd/tui and
cmd/gui still call the old unauthenticated GET /games, which no longer
exists. They need internal/client and a login flow before they work again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47a9719eeb
commit
5072919b61
34 files changed
+1907
-506
No files matched your search
@@ -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
|
||||||
+2
-43
@@ -25,46 +25,5 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# ---> Lua
|
# Justfile build output (bin/api, bin/tui, bin/gui)
|
||||||
# Compiled Lua sources
|
bin/
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
+4
-6
@@ -1,5 +1,5 @@
|
|||||||
# Build Stage
|
# Build Stage
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ COPY . .
|
|||||||
# Build the application
|
# Build the application
|
||||||
# CGO_ENABLED=0 ensures a static binary
|
# CGO_ENABLED=0 ensures a static binary
|
||||||
ARG TARGETOS TARGETARCH
|
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
|
# Runtime Stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
@@ -28,12 +28,10 @@ WORKDIR /app
|
|||||||
# Install ca-certificates for database connections (TLS)
|
# Install ca-certificates for database connections (TLS)
|
||||||
RUN apk add --no-cache ca-certificates
|
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 --from=builder /app/api-server .
|
||||||
|
|
||||||
# Copy migrations
|
|
||||||
COPY --from=builder /app/db/migrations ./db/migrations
|
|
||||||
|
|
||||||
# Expose the application port
|
# Expose the application port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ run-api:
|
|||||||
|
|
||||||
# Start the API and Database using Docker Compose
|
# Start the API and Database using Docker Compose
|
||||||
docker-up:
|
docker-up:
|
||||||
docker-compose up --build -d
|
docker compose up --build -d
|
||||||
|
|
||||||
# Stop Docker Compose services
|
# Stop Docker Compose services
|
||||||
docker-down:
|
docker-down:
|
||||||
docker-compose down
|
docker compose down
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
test:
|
test:
|
||||||
@@ -60,6 +60,10 @@ run-gui:
|
|||||||
sqlc:
|
sqlc:
|
||||||
sqlc generate
|
sqlc generate
|
||||||
|
|
||||||
|
# Write the OpenAPI spec to api/openapi.json
|
||||||
|
openapi:
|
||||||
|
go run ./cmd/openapi
|
||||||
|
|
||||||
# Connect to the running database via psql
|
# Connect to the running database via psql
|
||||||
db-shell:
|
db-shell:
|
||||||
docker-compose exec db psql -U postgres -d gamedb
|
docker compose exec db psql -U postgres -d gamedb
|
||||||
@@ -1,2 +1,62 @@
|
|||||||
# Completed
|
# 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 `<timestamp>_<name>.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.
|
||||||
@@ -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":"Line truncated
|
||||||
+62
-56
@@ -11,21 +11,26 @@ import (
|
|||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
_ "github.com/lib/pq"
|
|
||||||
|
|
||||||
|
"completed/db/migrations"
|
||||||
"completed/internal/api"
|
"completed/internal/api"
|
||||||
"completed/internal/db"
|
"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 {
|
func ensureDatabase(dbURL string) error {
|
||||||
u, err := url.Parse(dbURL)
|
u, err := url.Parse(dbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbName := strings.TrimPrefix(u.Path, "/")
|
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)
|
return fmt.Errorf("database name is empty in URL: %s", dbURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to 'postgres' database to create the new DB
|
|
||||||
u.Path = "/postgres"
|
u.Path = "/postgres"
|
||||||
db, err := sql.Open("postgres", u.String())
|
adminDB, err := sql.Open("pgx", u.String())
|
||||||
if err != nil {
|
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
|
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 {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("check database existence: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
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)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMigrations(dbURL string) {
|
fmt.Printf("Database %q does not exist. Creating...\n", dbName)
|
||||||
m, err := migrate.New(
|
// Table/database identifiers can't be parameterized; dbName comes from
|
||||||
"file:///app/db/migrations", // Path inside Docker container
|
// our own DATABASE_URL, not untrusted request input.
|
||||||
dbURL,
|
if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)); err != nil {
|
||||||
)
|
return fmt.Errorf("create database %q: %w", dbName, err)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("Database %q created.\n", dbName)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Migration initialization failed: %v\n", err)
|
return fmt.Errorf("load embedded migrations: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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 {
|
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||||
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
|
return fmt.Errorf("apply migrations: %w", err)
|
||||||
} else {
|
|
||||||
fmt.Println("Migrations ran successfully")
|
|
||||||
}
|
}
|
||||||
|
fmt.Println("Migrations applied.")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 1. Database connection
|
|
||||||
dbURL := os.Getenv("DATABASE_URL")
|
dbURL := os.Getenv("DATABASE_URL")
|
||||||
if dbURL == "" {
|
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 {
|
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
|
if err := runMigrations(dbURL); err != nil {
|
||||||
runMigrations(dbURL)
|
fmt.Fprintf(os.Stderr, "Failed to run migrations: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||||
if err != nil {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer pool.Close()
|
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)
|
queries := db.New(pool)
|
||||||
|
|
||||||
// 2. Setup Echo
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
e.Use(middleware.Logger())
|
e.Use(middleware.Logger())
|
||||||
e.Use(middleware.Recover())
|
e.Use(middleware.Recover())
|
||||||
|
|
||||||
// 3. Setup Huma
|
humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0"))
|
||||||
humaApi := humaecho.New(e, huma.DefaultConfig("My API", "1.0.0"))
|
|
||||||
|
|
||||||
// 4. Register Routes
|
|
||||||
api.RegisterRoutes(humaApi, queries)
|
api.RegisterRoutes(humaApi, queries)
|
||||||
|
|
||||||
// 5. Start Server
|
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "8080"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE users
|
|
||||||
DROP COLUMN email,
|
|
||||||
DROP COLUMN password_hash;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN email TEXT NOT NULL UNIQUE,
|
|
||||||
ADD COLUMN password_hash TEXT NOT NULL;
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
DROP TRIGGER IF EXISTS game_touch_change_seq ON game;
|
||||||
|
DROP TRIGGER IF EXISTS platform_touch_change_seq ON platform;
|
||||||
|
DROP FUNCTION IF EXISTS touch_change_seq();
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS game;
|
||||||
|
DROP TABLE IF EXISTS platform;
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
|
DROP SEQUENCE IF EXISTS change_seq_seq;
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Package migrations embeds the SQL migration files into the binary, so the
|
||||||
|
// server doesn't depend on a db/migrations directory existing next to it at
|
||||||
|
// runtime (which previously broke outside of a specific Docker layout; see
|
||||||
|
// cmd/api/main.go).
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var FS embed.FS
|
||||||
+105
-49
@@ -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
|
-- name: CreateUser :one
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
name, bio
|
email, password_hash, name, bio
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2
|
$1, $2, $3, $4
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING id, email, name, bio, created_at;
|
||||||
|
|
||||||
-- name: DeleteUser :exec
|
-- name: GetUserByEmail :one
|
||||||
DELETE FROM users
|
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;
|
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
|
-- name: CreatePlatform :one
|
||||||
INSERT INTO platform (
|
INSERT INTO platform (
|
||||||
name
|
user_id, name
|
||||||
) VALUES (
|
) 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
|
-- name: ListPlatforms :many
|
||||||
SELECT * FROM platform
|
SELECT id, name
|
||||||
|
FROM platform
|
||||||
|
WHERE user_id = $1 AND deleted_at IS NULL
|
||||||
ORDER BY name;
|
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
|
-- name: UpdatePlatform :one
|
||||||
UPDATE platform
|
UPDATE platform
|
||||||
SET name = $2
|
SET name = $3
|
||||||
WHERE id = $1
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING id, name;
|
||||||
|
|
||||||
-- name: DeletePlatform :exec
|
-- name: DeletePlatform :execrows
|
||||||
DELETE FROM platform
|
-- Soft delete, so the FK on game.platform_id is never touched and never
|
||||||
WHERE id = $1;
|
-- 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;
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
+7
-3
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
@@ -9,7 +7,8 @@ services:
|
|||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgres://postgres:password@db:5432/gamedb?sslmode=disable
|
- DATABASE_URL=postgres://postgres:password@db:5432/gamedb?sslmode=disable
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
|
||||||
db:
|
db:
|
||||||
@@ -23,6 +22,11 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d gamedb"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
@@ -7,8 +7,13 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/danielgtaylor/huma/v2 v2.34.1
|
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/jackc/pgx/v5 v5.7.6
|
||||||
github.com/labstack/echo/v4 v4.14.0
|
github.com/labstack/echo/v4 v4.14.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -20,13 +25,12 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/ebitengine/purego v0.7.1 // indirect
|
github.com/ebitengine/purego v0.7.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/gen2brain/raylib-go/raylib v0.55.1 // indirect
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/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/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
|||||||
@@ -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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
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/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 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
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 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ=
|
||||||
github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk=
|
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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
|
||||||
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8=
|
||||||
github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
|
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 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M=
|
||||||
github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
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-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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
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 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
|
||||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <token>", 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
|
||||||
|
}
|
||||||
@@ -3,61 +3,84 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"completed/internal/db"
|
"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 {
|
type MockQuerier struct {
|
||||||
CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.Game, error)
|
CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error)
|
||||||
CreatePlatformFunc func(ctx context.Context, name string) (db.Platform, error)
|
CreatePlatformFunc func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error)
|
||||||
CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.User, error)
|
CreateSessionFunc func(ctx context.Context, arg db.CreateSessionParams) error
|
||||||
DeleteGameFunc func(ctx context.Context, id int64) error
|
CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error)
|
||||||
DeletePlatformFunc func(ctx context.Context, id int64) error
|
DeleteGameFunc func(ctx context.Context, arg db.DeleteGameParams) (int64, error)
|
||||||
DeleteUserFunc func(ctx context.Context, id int64) error
|
DeletePlatformFunc func(ctx context.Context, arg db.DeletePlatformParams) (int64, error)
|
||||||
GetGameFunc func(ctx context.Context, id int64) (db.Game, error)
|
DeleteSessionFunc func(ctx context.Context, tokenHash []byte) error
|
||||||
GetUserFunc func(ctx context.Context, id int64) (db.User, error)
|
GetGameFunc func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error)
|
||||||
ListGamesFunc func(ctx context.Context) ([]db.Game, error)
|
GetPlatformFunc func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error)
|
||||||
ListPlatformsFunc func(ctx context.Context) ([]db.Platform, error)
|
GetSessionAndUserFunc func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error)
|
||||||
ListUsersFunc func(ctx context.Context) ([]db.User, error)
|
GetUserByEmailFunc func(ctx context.Context, email string) (db.GetUserByEmailRow, error)
|
||||||
UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.Game, error)
|
GetUserByIDFunc func(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error)
|
||||||
UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, 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)
|
return m.CreateGameFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.Platform, error) {
|
func (m *MockQuerier) CreatePlatform(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) {
|
||||||
return m.CreatePlatformFunc(ctx, name)
|
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)
|
return m.CreateUserFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) DeleteGame(ctx context.Context, id int64) error {
|
func (m *MockQuerier) DeleteGame(ctx context.Context, arg db.DeleteGameParams) (int64, error) {
|
||||||
return m.DeleteGameFunc(ctx, id)
|
return m.DeleteGameFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) DeletePlatform(ctx context.Context, id int64) error {
|
func (m *MockQuerier) DeletePlatform(ctx context.Context, arg db.DeletePlatformParams) (int64, error) {
|
||||||
return m.DeletePlatformFunc(ctx, id)
|
return m.DeletePlatformFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) DeleteUser(ctx context.Context, id int64) error {
|
func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error {
|
||||||
return m.DeleteUserFunc(ctx, id)
|
return m.DeleteSessionFunc(ctx, tokenHash)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) GetGame(ctx context.Context, id int64) (db.Game, error) {
|
func (m *MockQuerier) GetGame(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) {
|
||||||
return m.GetGameFunc(ctx, id)
|
return m.GetGameFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) GetUser(ctx context.Context, id int64) (db.User, error) {
|
func (m *MockQuerier) GetPlatform(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) {
|
||||||
return m.GetUserFunc(ctx, id)
|
return m.GetPlatformFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) ListGames(ctx context.Context) ([]db.Game, error) {
|
func (m *MockQuerier) GetSessionAndUser(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
|
||||||
return m.ListGamesFunc(ctx)
|
return m.GetSessionAndUserFunc(ctx, tokenHash)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.Platform, error) {
|
func (m *MockQuerier) GetUserByEmail(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
|
||||||
return m.ListPlatformsFunc(ctx)
|
return m.GetUserByEmailFunc(ctx, email)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) ListUsers(ctx context.Context) ([]db.User, error) {
|
func (m *MockQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error) {
|
||||||
return m.ListUsersFunc(ctx)
|
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)
|
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)
|
return m.UpdatePlatformFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
+203
-118
@@ -6,9 +6,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"completed/internal/db"
|
"completed/internal/db"
|
||||||
|
"completed/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(api huma.API, queries db.Querier) {
|
func RegisterRoutes(api huma.API, queries db.Querier) {
|
||||||
@@ -33,83 +35,118 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerAuthRoutes(api, queries)
|
||||||
registerPlatformRoutes(api, queries)
|
registerPlatformRoutes(api, queries)
|
||||||
registerGameRoutes(api, queries)
|
registerGameRoutes(api, queries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Platform Routes ---
|
// --- 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) {
|
func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
||||||
huma.Register(api, huma.Operation{
|
listOp := huma.Operation{
|
||||||
OperationID: "list-platforms",
|
OperationID: "list-platforms",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/platforms",
|
Path: "/api/platforms",
|
||||||
Summary: "List 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 {
|
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 {
|
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",
|
OperationID: "create-platform",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/platforms",
|
Path: "/api/platforms",
|
||||||
Summary: "Create Platform",
|
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
|
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{
|
||||||
huma.Register(api, huma.Operation{
|
UserID: userFromContext(ctx).ID,
|
||||||
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,
|
Name: input.Body.Name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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",
|
OperationID: "delete-platform",
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
Path: "/platforms/{id}",
|
Path: "/api/platforms/{id}",
|
||||||
Summary: "Delete Platform",
|
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) {
|
}) (*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 {
|
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
|
return nil, nil
|
||||||
})
|
})
|
||||||
@@ -117,40 +154,21 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
|||||||
|
|
||||||
// --- Game Routes ---
|
// --- Game Routes ---
|
||||||
|
|
||||||
// GameDTO for API communication (Clean JSON)
|
// formatDate and parseDate convert between the API's YYYY-MM-DD strings and
|
||||||
type GameDTO struct {
|
// pgtype.Date. CreateGame, GetGame, ListGames and UpdateGame each return a
|
||||||
ID int64 `json:"id"`
|
// distinct sqlc row type with identical fields (id/name/platform_id/
|
||||||
Name string `json:"name"`
|
// platform_name/score/release_date/finished), so the DTO conversion itself
|
||||||
PlatformID int64 `json:"platform_id"`
|
// is written out at each call site rather than factored into one function.
|
||||||
Score int32 `json:"score"`
|
func formatDate(d pgtype.Date) string {
|
||||||
ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"`
|
if !d.Valid {
|
||||||
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
|
return ""
|
||||||
|
}
|
||||||
|
return d.Time.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameInput struct {
|
// parseDate parses a YYYY-MM-DD string into a pgtype.Date. An empty string
|
||||||
Name string `json:"name"`
|
// parses to an invalid/NULL date, which is valid for the optional
|
||||||
PlatformID int64 `json:"platform_id"`
|
// "finished" field but rejected separately for the required release_date.
|
||||||
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"),
|
|
||||||
}
|
|
||||||
if g.Finished.Valid {
|
|
||||||
dto.Finished = g.Finished.Time.Format("2006-01-02")
|
|
||||||
}
|
|
||||||
return dto
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to parse date string to pgtype.Date
|
|
||||||
func parseDate(s string) (pgtype.Date, error) {
|
func parseDate(s string) (pgtype.Date, error) {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return pgtype.Date{}, nil
|
return pgtype.Date{}, nil
|
||||||
@@ -163,95 +181,162 @@ func parseDate(s string) (pgtype.Date, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func registerGameRoutes(api huma.API, queries db.Querier) {
|
func registerGameRoutes(api huma.API, queries db.Querier) {
|
||||||
huma.Register(api, huma.Operation{
|
listOp := huma.Operation{
|
||||||
OperationID: "list-games",
|
OperationID: "list-games",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/games",
|
Path: "/api/games",
|
||||||
Summary: "List 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 {
|
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 {
|
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",
|
OperationID: "create-game",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/games",
|
Path: "/api/games",
|
||||||
Summary: "Create Game",
|
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 {
|
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)
|
fin, err := parseDate(input.Body.Finished)
|
||||||
if err != nil {
|
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{
|
game, err := queries.CreateGame(ctx, db.CreateGameParams{
|
||||||
|
UserID: userFromContext(ctx).ID,
|
||||||
|
PlatformID: platformID,
|
||||||
Name: input.Body.Name,
|
Name: input.Body.Name,
|
||||||
PlatformID: input.Body.PlatformID,
|
|
||||||
Score: input.Body.Score,
|
Score: input.Body.Score,
|
||||||
ReleaseYear: ry,
|
ReleaseDate: ry,
|
||||||
Finished: fin,
|
Finished: fin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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",
|
OperationID: "update-game",
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Path: "/games/{id}",
|
Path: "/api/games/{id}",
|
||||||
Summary: "Update Game",
|
Summary: "Update Game",
|
||||||
}, func(ctx context.Context, input *struct {
|
}
|
||||||
ID int64 `path:"id"`
|
addAuth(&updateOp, api, queries)
|
||||||
Body GameInput
|
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
|
||||||
}) (*struct{ Body GameDTO }, error) {
|
ID uuid.UUID `path:"id"`
|
||||||
ry, err := parseDate(input.Body.ReleaseYear)
|
Body model.GameInput
|
||||||
|
}) (*struct{ Body model.Game }, error) {
|
||||||
|
platformID, err := uuid.Parse(input.Body.PlatformID)
|
||||||
if err != nil {
|
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)
|
fin, err := parseDate(input.Body.Finished)
|
||||||
if err != nil {
|
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{
|
game, err := queries.UpdateGame(ctx, db.UpdateGameParams{
|
||||||
ID: input.ID,
|
ID: input.ID,
|
||||||
|
UserID: userFromContext(ctx).ID,
|
||||||
|
PlatformID: platformID,
|
||||||
Name: input.Body.Name,
|
Name: input.Body.Name,
|
||||||
PlatformID: input.Body.PlatformID,
|
|
||||||
Score: input.Body.Score,
|
Score: input.Body.Score,
|
||||||
ReleaseYear: ry,
|
ReleaseDate: ry,
|
||||||
Finished: fin,
|
Finished: fin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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",
|
OperationID: "delete-game",
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
Path: "/games/{id}",
|
Path: "/api/games/{id}",
|
||||||
Summary: "Delete Game",
|
Summary: "Delete Game",
|
||||||
}, func(ctx context.Context, input *struct {
|
|
||||||
ID int64 `path:"id"`
|
|
||||||
}) (*struct{}, error) {
|
|
||||||
err := queries.DeleteGame(ctx, input.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
|
||||||
}
|
}
|
||||||
return nil, nil
|
addAuth(&deleteOp, api, queries)
|
||||||
|
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
|
||||||
|
ID uuid.UUID `path:"id"`
|
||||||
|
}) (*struct{}, error) {
|
||||||
|
rows, err := queries.DeleteGame(ctx, db.DeleteGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbError(err)
|
||||||
|
}
|
||||||
|
return nil, notFoundIfZero(rows)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+194
-29
@@ -9,58 +9,223 @@ import (
|
|||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
"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/labstack/echo/v4"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"completed/internal/auth"
|
||||||
"completed/internal/db"
|
"completed/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestListGames(t *testing.T) {
|
// newTestAPI wires mockDB into a fresh Huma/Echo instance exactly as
|
||||||
// 1. Setup Mock
|
// cmd/api/main.go does, so tests exercise routing and middleware, not just
|
||||||
mockDB := &MockQuerier{
|
// the handler bodies.
|
||||||
ListGamesFunc: func(ctx context.Context) ([]db.Game, error) {
|
func newTestAPI(mockDB db.Querier) (*echo.Echo, huma.API) {
|
||||||
return []db.Game{
|
|
||||||
{ID: 1, Name: "Test Game", Score: 90},
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Setup API
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
|
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
|
||||||
RegisterRoutes(api, mockDB)
|
RegisterRoutes(api, mockDB)
|
||||||
|
return e, api
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Execute Request
|
// a fixed valid session, returned by GetSessionAndUserFunc so authed
|
||||||
req := httptest.NewRequest(http.MethodGet, "/games", nil)
|
// 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()
|
rec := httptest.NewRecorder()
|
||||||
e.ServeHTTP(rec, req)
|
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.Equal(t, http.StatusOK, rec.Code)
|
||||||
assert.Contains(t, rec.Body.String(), "Test Game")
|
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) {
|
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{
|
mockDB := &MockQuerier{
|
||||||
CreatePlatformFunc: func(ctx context.Context, name string) (db.Platform, error) {
|
CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) {
|
||||||
return db.Platform{ID: 1, Name: name}, nil
|
return db.CreateUserRow{}, &pgconn.PgError{Code: pgCodeUniqueViolation}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Setup API
|
e, _ := newTestAPI(mockDB)
|
||||||
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")
|
|
||||||
rec := httptest.NewRecorder()
|
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.Equal(t, http.StatusOK, rec.Code)
|
||||||
assert.Contains(t, rec.Body.String(), "Sega Genesis")
|
assert.Contains(t, rec.Header().Get("Set-Cookie"), "session=")
|
||||||
assert.Contains(t, rec.Body.String(), `"id":1`)
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[:]
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
|
|||||||
+27
-6
@@ -1,29 +1,50 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
PlatformID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
PlatformID int64
|
|
||||||
Score int32
|
Score int32
|
||||||
ReleaseYear pgtype.Date
|
ReleaseDate pgtype.Date
|
||||||
Finished pgtype.Date
|
Finished pgtype.Date
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
DeletedAt pgtype.Timestamptz
|
||||||
|
ChangeSeq int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Platform struct {
|
type Platform struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
Name string
|
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 {
|
type User struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
Name string
|
Name string
|
||||||
Bio pgtype.Text
|
Bio pgtype.Text
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
+24
-14
@@ -1,27 +1,37 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Querier interface {
|
type Querier interface {
|
||||||
CreateGame(ctx context.Context, arg CreateGameParams) (Game, error)
|
CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error)
|
||||||
CreatePlatform(ctx context.Context, name string) (Platform, error)
|
CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error)
|
||||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
CreateSession(ctx context.Context, arg CreateSessionParams) error
|
||||||
DeleteGame(ctx context.Context, id int64) error
|
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
||||||
DeletePlatform(ctx context.Context, id int64) error
|
DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error)
|
||||||
DeleteUser(ctx context.Context, id int64) error
|
// Soft delete, so the FK on game.platform_id is never touched and never
|
||||||
GetGame(ctx context.Context, id int64) (Game, error)
|
// blocks this on its own; the NOT EXISTS guard is what stops a platform
|
||||||
GetUser(ctx context.Context, id int64) (User, error)
|
// that still has active games from being deleted. Call GetPlatform first to
|
||||||
ListGames(ctx context.Context) ([]Game, error)
|
// tell "not found" apart from "blocked by games" when this affects 0 rows.
|
||||||
ListPlatforms(ctx context.Context) ([]Platform, error)
|
DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error)
|
||||||
ListUsers(ctx context.Context) ([]User, error)
|
DeleteSession(ctx context.Context, tokenHash []byte) error
|
||||||
UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error)
|
GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error)
|
||||||
UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, 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)
|
var _ Querier = (*Queries)(nil)
|
||||||
+348
-105
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
// source: query.sql
|
// source: query.sql
|
||||||
|
|
||||||
package db
|
package db
|
||||||
@@ -8,41 +8,61 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createGame = `-- name: CreateGame :one
|
const createGame = `-- name: CreateGame :one
|
||||||
|
WITH inserted AS (
|
||||||
INSERT INTO game (
|
INSERT INTO game (
|
||||||
name, platform_id, score, release_year, finished
|
user_id, platform_id, name, score, release_date, finished
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5
|
$1, $2, $3, $4, $5, $6
|
||||||
)
|
)
|
||||||
RETURNING id, name, platform_id, score, release_year, finished
|
RETURNING id, platform_id, name, score, release_date, finished
|
||||||
|
)
|
||||||
|
SELECT inserted.id, inserted.name, inserted.platform_id, platform.name AS platform_name,
|
||||||
|
inserted.score, inserted.release_date, inserted.finished
|
||||||
|
FROM inserted
|
||||||
|
JOIN platform ON platform.id = inserted.platform_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateGameParams struct {
|
type CreateGameParams struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
PlatformID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
PlatformID int64
|
|
||||||
Score int32
|
Score int32
|
||||||
ReleaseYear pgtype.Date
|
ReleaseDate pgtype.Date
|
||||||
Finished 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,
|
row := q.db.QueryRow(ctx, createGame,
|
||||||
arg.Name,
|
arg.UserID,
|
||||||
arg.PlatformID,
|
arg.PlatformID,
|
||||||
|
arg.Name,
|
||||||
arg.Score,
|
arg.Score,
|
||||||
arg.ReleaseYear,
|
arg.ReleaseDate,
|
||||||
arg.Finished,
|
arg.Finished,
|
||||||
)
|
)
|
||||||
var i Game
|
var i CreateGameRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -50,122 +70,327 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
|
|||||||
|
|
||||||
const createPlatform = `-- name: CreatePlatform :one
|
const createPlatform = `-- name: CreatePlatform :one
|
||||||
INSERT INTO platform (
|
INSERT INTO platform (
|
||||||
name
|
user_id, name
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1
|
$1, $2
|
||||||
)
|
)
|
||||||
RETURNING id, name
|
RETURNING id, name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) CreatePlatform(ctx context.Context, name string) (Platform, error) {
|
type CreatePlatformParams struct {
|
||||||
row := q.db.QueryRow(ctx, createPlatform, name)
|
UserID uuid.UUID
|
||||||
var i Platform
|
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)
|
err := row.Scan(&i.ID, &i.Name)
|
||||||
return i, err
|
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
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
name, bio
|
email, password_hash, name, bio
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2
|
$1, $2, $3, $4
|
||||||
)
|
)
|
||||||
RETURNING id, name, bio
|
RETURNING id, email, name, bio, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserParams struct {
|
type CreateUserParams struct {
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
Name string
|
Name string
|
||||||
Bio pgtype.Text
|
Bio pgtype.Text
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
type CreateUserRow struct {
|
||||||
row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Bio)
|
ID uuid.UUID
|
||||||
var i User
|
Email string
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.Bio)
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteGame = `-- name: DeleteGame :exec
|
const deleteGame = `-- name: DeleteGame :execrows
|
||||||
DELETE FROM game
|
UPDATE game
|
||||||
WHERE id = $1
|
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 {
|
type DeleteGameParams struct {
|
||||||
_, err := q.db.Exec(ctx, deleteGame, id)
|
ID uuid.UUID
|
||||||
return err
|
UserID uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletePlatform = `-- name: DeletePlatform :exec
|
func (q *Queries) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) {
|
||||||
DELETE FROM platform
|
result, err := q.db.Exec(ctx, deleteGame, arg.ID, arg.UserID)
|
||||||
WHERE id = $1
|
if err != nil {
|
||||||
`
|
return 0, err
|
||||||
|
}
|
||||||
func (q *Queries) DeletePlatform(ctx context.Context, id int64) error {
|
return result.RowsAffected(), nil
|
||||||
_, err := q.db.Exec(ctx, deletePlatform, id)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteUser = `-- name: DeleteUser :exec
|
const deletePlatform = `-- name: DeletePlatform :execrows
|
||||||
DELETE FROM users
|
UPDATE platform
|
||||||
WHERE id = $1
|
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 {
|
type DeletePlatformParams struct {
|
||||||
_, err := q.db.Exec(ctx, deleteUser, id)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGame = `-- name: GetGame :one
|
const getGame = `-- name: GetGame :one
|
||||||
SELECT id, name, platform_id, score, release_year, finished FROM game
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
||||||
WHERE id = $1 LIMIT 1
|
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) {
|
type GetGameParams struct {
|
||||||
row := q.db.QueryRow(ctx, getGame, id)
|
ID uuid.UUID
|
||||||
var i Game
|
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(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUser = `-- name: GetUser :one
|
const getPlatform = `-- name: GetPlatform :one
|
||||||
SELECT id, name, bio FROM users
|
SELECT id, name
|
||||||
WHERE id = $1 LIMIT 1
|
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) {
|
type GetPlatformParams struct {
|
||||||
row := q.db.QueryRow(ctx, getUser, id)
|
ID uuid.UUID
|
||||||
var i User
|
UserID uuid.UUID
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.Bio)
|
}
|
||||||
|
|
||||||
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listGames = `-- name: ListGames :many
|
const listGames = `-- name: ListGames :many
|
||||||
SELECT id, name, platform_id, score, release_year, finished FROM game
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
||||||
ORDER BY 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) {
|
type ListGamesRow struct {
|
||||||
rows, err := q.db.Query(ctx, listGames)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []Game
|
var items []ListGamesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Game
|
var i ListGamesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -179,19 +404,26 @@ func (q *Queries) ListGames(ctx context.Context) ([]Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listPlatforms = `-- name: ListPlatforms :many
|
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
|
ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
|
type ListPlatformsRow struct {
|
||||||
rows, err := q.db.Query(ctx, listPlatforms)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []Platform
|
var items []ListPlatformsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Platform
|
var i ListPlatformsRow
|
||||||
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -203,63 +435,68 @@ func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listUsers = `-- name: ListUsers :many
|
const touchSession = `-- name: TouchSession :exec
|
||||||
SELECT id, name, bio FROM users
|
UPDATE sessions
|
||||||
ORDER BY name
|
SET last_seen_at = now()
|
||||||
|
WHERE token_hash = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
|
func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error {
|
||||||
rows, err := q.db.Query(ctx, listUsers)
|
_, err := q.db.Exec(ctx, touchSession, tokenHash)
|
||||||
if err != nil {
|
return err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateGame = `-- name: UpdateGame :one
|
const updateGame = `-- name: UpdateGame :one
|
||||||
|
WITH updated AS (
|
||||||
UPDATE game
|
UPDATE game
|
||||||
SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6
|
SET platform_id = $3, name = $4, score = $5, release_date = $6, finished = $7
|
||||||
WHERE id = $1
|
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
|
||||||
RETURNING id, name, platform_id, score, release_year, finished
|
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 {
|
type UpdateGameParams struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
PlatformID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
PlatformID int64
|
|
||||||
Score int32
|
Score int32
|
||||||
ReleaseYear pgtype.Date
|
ReleaseDate pgtype.Date
|
||||||
Finished 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,
|
row := q.db.QueryRow(ctx, updateGame,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
arg.Name,
|
arg.UserID,
|
||||||
arg.PlatformID,
|
arg.PlatformID,
|
||||||
|
arg.Name,
|
||||||
arg.Score,
|
arg.Score,
|
||||||
arg.ReleaseYear,
|
arg.ReleaseDate,
|
||||||
arg.Finished,
|
arg.Finished,
|
||||||
)
|
)
|
||||||
var i Game
|
var i UpdateGameRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -267,19 +504,25 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e
|
|||||||
|
|
||||||
const updatePlatform = `-- name: UpdatePlatform :one
|
const updatePlatform = `-- name: UpdatePlatform :one
|
||||||
UPDATE platform
|
UPDATE platform
|
||||||
SET name = $2
|
SET name = $3
|
||||||
WHERE id = $1
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||||
RETURNING id, name
|
RETURNING id, name
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePlatformParams struct {
|
type UpdatePlatformParams struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) {
|
type UpdatePlatformRow struct {
|
||||||
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name)
|
ID uuid.UUID
|
||||||
var i Platform
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.UserID, arg.Name)
|
||||||
|
var i UpdatePlatformRow
|
||||||
err := row.Scan(&i.ID, &i.Name)
|
err := row.Scan(&i.ID, &i.Name)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -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)"`
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
sql:
|
sql:
|
||||||
- schema: "db/schema.sql"
|
- schema: "db/migrations"
|
||||||
queries: "db/query.sql"
|
queries: "db/query.sql"
|
||||||
engine: "postgresql"
|
engine: "postgresql"
|
||||||
gen:
|
gen:
|
||||||
@@ -9,3 +9,6 @@ sql:
|
|||||||
out: "internal/db"
|
out: "internal/db"
|
||||||
sql_package: "pgx/v5"
|
sql_package: "pgx/v5"
|
||||||
emit_interface: true
|
emit_interface: true
|
||||||
|
overrides:
|
||||||
|
- db_type: "uuid"
|
||||||
|
go_type: "github.com/google/uuid.UUID"
|
||||||
Reference in new issue
Block a user