From 1bbaa731f30f299ad48276dcdc0064e46daa93d8 Mon Sep 17 00:00:00 2001 From: garrettladley Date: Sat, 23 Nov 2024 22:52:15 -0500 Subject: [PATCH] feat: roundest in goftth stack --- .github/workflows/ci.yml | 115 ++++++++++++++++++ .gitignore | 12 ++ Makefile | 45 +++++++ cmd/seed/main.go | 45 +++++++ cmd/server/main.go | 70 +++++++++++ cmd/server/static_dev.go | 16 +++ cmd/server/static_prod.go | 24 ++++ go.mod | 30 +++++ go.sum | 81 ++++++++++++ internal/handlers/home.go | 16 +++ internal/handlers/results.go | 16 +++ internal/handlers/routes.go | 9 ++ internal/handlers/service.go | 11 ++ internal/handlers/vote.go | 31 +++++ internal/model/pokemon.go | 24 ++++ internal/server/caching.go | 6 + internal/server/caching_dev.go | 7 ++ internal/server/caching_prod.go | 23 ++++ internal/server/init.go | 10 ++ internal/server/server.go | 69 +++++++++++ internal/services/pokeapi/client.go | 75 ++++++++++++ internal/services/pokeapi/service.go | 15 +++ internal/services/pokeapi/types.go | 6 + internal/settings/app.go | 5 + internal/settings/database.go | 10 ++ internal/settings/settings.go | 12 ++ internal/storage/postgres/get_all_pokemon.go | 17 +++ internal/storage/postgres/get_all_results.go | 55 +++++++++ internal/storage/postgres/postgres.go | 43 +++++++ internal/storage/postgres/random_pair.go | 26 ++++ internal/storage/postgres/schema.go | 28 +++++ internal/storage/postgres/seed.go | 51 ++++++++ internal/storage/postgres/vote.go | 53 ++++++++ internal/storage/storage.go | 20 +++ internal/templ/render.go | 11 ++ internal/types/pair.go | 10 ++ .../views/components/pokemon_sprite.templ | 11 ++ internal/views/css/app.css | 7 ++ internal/views/home/index.templ | 55 +++++++++ internal/views/layouts/base.templ | 54 ++++++++ internal/views/results/index.templ | 43 +++++++ internal/xerr/api_error.go | 67 ++++++++++ tailwind.config.js | 7 ++ 43 files changed, 1341 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/seed/main.go create mode 100644 cmd/server/main.go create mode 100644 cmd/server/static_dev.go create mode 100644 cmd/server/static_prod.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/handlers/home.go create mode 100644 internal/handlers/results.go create mode 100644 internal/handlers/routes.go create mode 100644 internal/handlers/service.go create mode 100644 internal/handlers/vote.go create mode 100644 internal/model/pokemon.go create mode 100644 internal/server/caching.go create mode 100644 internal/server/caching_dev.go create mode 100644 internal/server/caching_prod.go create mode 100644 internal/server/init.go create mode 100644 internal/server/server.go create mode 100644 internal/services/pokeapi/client.go create mode 100644 internal/services/pokeapi/service.go create mode 100644 internal/services/pokeapi/types.go create mode 100644 internal/settings/app.go create mode 100644 internal/settings/database.go create mode 100644 internal/settings/settings.go create mode 100644 internal/storage/postgres/get_all_pokemon.go create mode 100644 internal/storage/postgres/get_all_results.go create mode 100644 internal/storage/postgres/postgres.go create mode 100644 internal/storage/postgres/random_pair.go create mode 100644 internal/storage/postgres/schema.go create mode 100644 internal/storage/postgres/seed.go create mode 100644 internal/storage/postgres/vote.go create mode 100644 internal/storage/storage.go create mode 100644 internal/templ/render.go create mode 100644 internal/types/pair.go create mode 100644 internal/views/components/pokemon_sprite.templ create mode 100644 internal/views/css/app.css create mode 100644 internal/views/home/index.templ create mode 100644 internal/views/layouts/base.templ create mode 100644 internal/views/results/index.templ create mode 100644 internal/xerr/api_error.go create mode 100644 tailwind.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f4d306 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,115 @@ +name: CI + +permissions: read-all + +on: + push: + paths: + - cmd/** + - internal/** + - .github/workflows/ci.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + name: Format + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + - name: Get Go Cache Paths + id: go-cache-paths + run: | + echo "go-build=$(go env GOCACHE)" >> $GITHUB_OUTPUT + echo "go-mod=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT + - name: Install gofumpt + run: go install mvdan.cc/gofumpt@latest + - name: Go Build Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-build }} + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + - name: Go Mod Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-mod }} + key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} + - name: Check Code Formatting + run: | + unformatted_files=$(gofumpt -l .) + if [ -n "$unformatted_files" ]; then + echo "Files not formatted:" + echo "$unformatted_files" + exit 1 + fi + + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + checks: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + - name: Get Go Cache Paths + id: go-cache-paths + run: | + echo "go-build=$(go env GOCACHE)" >> $GITHUB_OUTPUT + echo "go-mod=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT + - name: Go Build Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-build }} + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + - name: Go Mod Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-mod }} + key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} + - name: golangci-lint + uses: golangci/golangci-lint-action@v5 + with: + version: latest + working-directory: ./ + args: --timeout=5m + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + - name: Get Go Cache Paths + id: go-cache-paths + run: | + echo "go-build=$(go env GOCACHE)" >> $GITHUB_OUTPUT + echo "go-mod=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT + - name: Go Build Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-build }} + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + - name: Go Mod Cache + uses: actions/cache@v4 + with: + path: ${{ steps.go-cache-paths.outputs.go-mod }} + key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} + - name: Run Tests with Coverage + run: go test -v -race -coverprofile=coverage.txt ./... + - name: Print Coverage + run: go tool cover -func=coverage.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9c67ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +.env +bin/ +tmp/ +htmx/ +*_templ.go +*_templ.txt +cmd/server/deps +cmd/server/public +node_modules/ +package-lock.json +package.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..87fcd8e --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +NODE_BIN := ./node_modules/.bin + +.PHONY:build +build: gen-css gen-templ + @go build -tags dev -o bin/roundest cmd/server/main.go + +.PHONY:build-prod +build-prod: gen-css gen-templ + @go build -tags prod -o bin/roundest cmd/server/main.go + +.PHONY:run +run: build + @./bin/roundest + +.PHONY: install +install: install-templ gen-templ + @go get ./... + @go mod tidy + @go mod download + @mkdir -p cmd/server/deps + @wget -q -O cmd/server/deps/htmx-2.0.3.min.js.gz https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js.gz + @gunzip -f cmd/server/deps/htmx-2.0.3.min.js.gz + @npm install -D daisyui@latest + @npm install -D tailwindcss + + +.PHONY: gen-css +gen-css: + @$(NODE_BIN)/tailwindcss build -i internal/views/css/app.css -o cmd/server/public/styles.css --minify + +.PHONY: watch-css +watch-css: + @$(NODE_BIN)/tailwindcss -i internal/views/css/app.css -o cmd/server/public/styles.css --minify --watch + +.PHONY: install-templ +install-templ: + @go install github.com/a-h/templ/cmd/templ@latest + +.PHONY: gen-templ +gen-templ: + @templ generate + +.PHONY: watch-templ +watch-templ: + @templ generate --watch --proxy=http://127.0.0.1:8000 diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..f1765f8 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "log" + "log/slog" + + "github.com/garrettladley/roundest/internal/services/pokeapi" + "github.com/garrettladley/roundest/internal/settings" + "github.com/garrettladley/roundest/internal/storage/postgres" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(".env"); err != nil { + log.Fatalf("Failed to load .env file: %v", err) + } + + settings, err := settings.Load() + if err != nil { + log.Fatalf("Failed to load settings: %v", err) + } + + db, err := postgres.New(postgres.From(settings.Database)) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + ctx := context.Background() + + if err := db.Schema(ctx); err != nil { + log.Fatalf("Failed to create schema: %v", err) + } + + pokemon, err := pokeapi.GetAllPokemon(ctx) + if err != nil { + log.Fatalf("Failed to get all pokemon: %v", err) + } + + if err := db.Seed(ctx, pokemon); err != nil { + log.Fatalf("Failed to seed database: %v", err) + } + + slog.Info("Database seeded") +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ebdf015 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "embed" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/garrettladley/roundest/internal/server" + "github.com/garrettladley/roundest/internal/settings" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(".env"); err != nil { + log.Fatalf("Failed to load .env file: %v", err) + } + + settings, err := settings.Load() + if err != nil { + log.Fatalf("Failed to load settings: %v", err) + } + + app := server.Init(settings) + + static(app) + + go func() { + if err := app.Listen(":" + settings.App.Port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + <-quit + + slog.Info("Shutting down server") + if err := app.Shutdown(); err != nil { + slog.Error("failed to shutdown server", "error", err) + } + + slog.Info("Server shutdown") +} + +//go:embed public +var PublicFS embed.FS + +//go:embed deps +var DepsFS embed.FS + +func static(app *fiber.App) { + app.Use("/public", filesystem.New(filesystem.Config{ + Root: http.FS(PublicFS), + PathPrefix: "public", + Browse: true, + })) + app.Use("/deps", filesystem.New(filesystem.Config{ + Root: http.FS(DepsFS), + PathPrefix: "deps", + Browse: true, + })) + +} diff --git a/cmd/server/static_dev.go b/cmd/server/static_dev.go new file mode 100644 index 0000000..0265b0a --- /dev/null +++ b/cmd/server/static_dev.go @@ -0,0 +1,16 @@ +//go:build dev + +package main + +import ( + "net/http" + "os" +) + +func public() http.Handler { + return http.StripPrefix("/public/", http.FileServerFS(os.DirFS("public"))) +} + +func deps() http.Handler { + return http.StripPrefix("/deps/", http.FileServerFS(os.DirFS("deps"))) +} diff --git a/cmd/server/static_prod.go b/cmd/server/static_prod.go new file mode 100644 index 0000000..c5d33a7 --- /dev/null +++ b/cmd/server/static_prod.go @@ -0,0 +1,24 @@ +//go:build !dev + +package main + +import ( + "embed" + "net/http" +) + +//go:embed public +var publicFS embed.FS // nolint:unused + +// nolint:unused +func public() http.Handler { + return http.FileServerFS(publicFS) +} + +//go:embed deps +var depsFS embed.FS // nolint:unused + +// nolint:unused +func deps() http.Handler { + return http.FileServerFS(depsFS) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2254736 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/garrettladley/roundest + +go 1.23.2 + +require github.com/gofiber/fiber/v2 v2.52.5 + +require ( + github.com/philhofer/fwd v1.1.2 // indirect + github.com/tinylib/msgp v1.1.8 // indirect +) + +require ( + github.com/a-h/templ v0.2.793 + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/caarlos0/env/v11 v11.2.2 + github.com/goccy/go-json v0.10.3 + github.com/google/uuid v1.5.0 // indirect + github.com/jmoiron/sqlx v1.4.0 + github.com/joho/godotenv v1.5.1 + github.com/klauspost/compress v1.17.0 // indirect + github.com/lib/pq v1.10.9 + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.23.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9da2232 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= +github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg= +github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..c809958 --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "github.com/garrettladley/roundest/internal/templ" + "github.com/garrettladley/roundest/internal/views/home" + "github.com/gofiber/fiber/v2" +) + +func (s *Service) Home(c *fiber.Ctx) error { + pair, err := s.store.RandomPair(c.Context()) + if err != nil { + return err + } + + return templ.Render(c, home.Index(pair)) +} diff --git a/internal/handlers/results.go b/internal/handlers/results.go new file mode 100644 index 0000000..605fc5c --- /dev/null +++ b/internal/handlers/results.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "github.com/garrettladley/roundest/internal/templ" + "github.com/garrettladley/roundest/internal/views/results" + "github.com/gofiber/fiber/v2" +) + +func (s *Service) Results(c *fiber.Ctx) error { + xresults, err := s.store.GetAllResults(c.Context()) + if err != nil { + return err + } + + return templ.Render(c, results.Index(xresults)) +} diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go new file mode 100644 index 0000000..63e254c --- /dev/null +++ b/internal/handlers/routes.go @@ -0,0 +1,9 @@ +package handlers + +import "github.com/gofiber/fiber/v2" + +func (s *Service) Routes(r fiber.Router) { + r.Get("/", s.Home) + r.Post("/vote", s.Vote) + r.Get("/results", s.Results) +} diff --git a/internal/handlers/service.go b/internal/handlers/service.go new file mode 100644 index 0000000..e07d74a --- /dev/null +++ b/internal/handlers/service.go @@ -0,0 +1,11 @@ +package handlers + +import "github.com/garrettladley/roundest/internal/storage" + +type Service struct { + store storage.Storage +} + +func NewService(store storage.Storage) *Service { + return &Service{store: store} +} diff --git a/internal/handlers/vote.go b/internal/handlers/vote.go new file mode 100644 index 0000000..cad85c9 --- /dev/null +++ b/internal/handlers/vote.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "github.com/garrettladley/roundest/internal/templ" + "github.com/garrettladley/roundest/internal/views/home" + "github.com/garrettladley/roundest/internal/xerr" + "github.com/gofiber/fiber/v2" +) + +type voteRequest struct { + UpID int `query:"up"` + DownID int `query:"down"` +} + +func (s *Service) Vote(c *fiber.Ctx) error { + var req voteRequest + if err := c.QueryParser(&req); err != nil { + return xerr.InvalidJSON(err) + } + + if err := s.store.Vote(c.Context(), req.UpID, req.DownID); err != nil { + return err + } + + newPair, err := s.store.RandomPair(c.Context()) + if err != nil { + return err + } + + return templ.Render(c, home.Ballot(newPair)) +} diff --git a/internal/model/pokemon.go b/internal/model/pokemon.go new file mode 100644 index 0000000..a4195dc --- /dev/null +++ b/internal/model/pokemon.go @@ -0,0 +1,24 @@ +package model + +import "time" + +type Pokemon struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + DexID int `db:"dex_id" json:"dexID"` + UpVotes int `db:"up_votes" json:"upVotes"` + DownVotes int `db:"down_votes" json:"downVotes"` + InsertedAt time.Time `db:"inserted_at" json:"insertedAt"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` +} + +type Result struct { + Name string `db:"name" json:"name"` + ID int64 `db:"id" json:"id"` + DexID int `db:"dex_id" json:"dexID"` + UpVotes int `db:"up_votes" json:"upVotes"` + DownVotes int `db:"down_votes" json:"downVotes"` + TotalVotes int `db:"total_votes" json:"totalVotes"` + WinPercentage float64 `db:"win_percentage" json:"winPercentage"` + LossPercentage float64 `db:"loss_percentage" json:"lossPercentage"` +} diff --git a/internal/server/caching.go b/internal/server/caching.go new file mode 100644 index 0000000..b3ea580 --- /dev/null +++ b/internal/server/caching.go @@ -0,0 +1,6 @@ +package server + +var staticPaths = map[string]struct{}{ + "/deps/htmx-2.0.3.min.js": {}, + "/public/styles.css": {}, +} diff --git a/internal/server/caching_dev.go b/internal/server/caching_dev.go new file mode 100644 index 0000000..fad7c3d --- /dev/null +++ b/internal/server/caching_dev.go @@ -0,0 +1,7 @@ +//go:build dev + +package server + +import "github.com/gofiber/fiber/v2" + +func setupCaching(app *fiber.App) {} diff --git a/internal/server/caching_prod.go b/internal/server/caching_prod.go new file mode 100644 index 0000000..e655b53 --- /dev/null +++ b/internal/server/caching_prod.go @@ -0,0 +1,23 @@ +//go:build !dev + +package server + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cache" + "github.com/gofiber/fiber/v2/utils" +) + +func setupCaching(app *fiber.App) { + app.Use(cache.New(cache.Config{ + Next: func(c *fiber.Ctx) bool { + _, ok := staticPaths[c.OriginalURL()] + return !ok + }, + KeyGenerator: func(c *fiber.Ctx) string { return utils.CopyString(c.OriginalURL()) }, + Expiration: time.Hour * 24 * 365, // 1 year + CacheControl: true, + })) +} diff --git a/internal/server/init.go b/internal/server/init.go new file mode 100644 index 0000000..3d5c490 --- /dev/null +++ b/internal/server/init.go @@ -0,0 +1,10 @@ +package server + +import ( + "github.com/garrettladley/roundest/internal/settings" + "github.com/gofiber/fiber/v2" +) + +func Init(settings settings.Settings) *fiber.App { + return newApp(settings) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..a2b3e6b --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,69 @@ +package server + +import ( + "net/http" + "strings" + + go_json "github.com/goccy/go-json" + + "github.com/garrettladley/roundest/internal/handlers" + "github.com/garrettladley/roundest/internal/settings" + "github.com/garrettladley/roundest/internal/storage/postgres" + "github.com/garrettladley/roundest/internal/xerr" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/requestid" +) + +func newApp(settings settings.Settings) *fiber.App { + app := fiber.New(fiber.Config{ + JSONEncoder: go_json.Marshal, + JSONDecoder: go_json.Unmarshal, + ErrorHandler: xerr.ErrorHandler, + }) + setupMiddleware(app) + setupHealthCheck(app) + setupFavicon(app) + setupCaching(app) + store, err := postgres.New(postgres.From(settings.Database)) + if err != nil { + panic("internal/server: failed to create store: " + err.Error()) + } + + handlers.NewService(store).Routes(app) + + return app +} + +func setupMiddleware(app *fiber.App) { + app.Use(recover.New()) + app.Use(requestid.New()) + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${ip}:${port} ${pid} ${locals:requestid} ${status} - ${latency} ${method} ${path}\n", + })) + app.Use(compress.New(compress.Config{ + Level: compress.LevelBestSpeed, + })) + app.Use(cors.New(cors.Config{ + AllowOrigins: strings.Join([]string{"https://garrettladley.com", "http://garrettladley.com", "http://127.0.0.1"}, ","), + AllowMethods: strings.Join([]string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions}, ","), + AllowHeaders: strings.Join([]string{"Accept", "Authorization", "Content-Type"}, ","), + AllowCredentials: true, + MaxAge: 300, + })) +} + +func setupHealthCheck(app *fiber.App) { + app.Get("/health", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) +} + +func setupFavicon(app *fiber.App) { + app.Get("/favicon.ico", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusNoContent) + }) +} diff --git a/internal/services/pokeapi/client.go b/internal/services/pokeapi/client.go new file mode 100644 index 0000000..fefdcf0 --- /dev/null +++ b/internal/services/pokeapi/client.go @@ -0,0 +1,75 @@ +package pokeapi + +import ( + "bytes" + "context" + "fmt" + "net/http" + + go_json "github.com/goccy/go-json" +) + +type apiClient struct { + client *http.Client + baseURL string +} + +func (c *apiClient) getAllPokemon(ctx context.Context) ([]PokemonData, error) { + jsonBody, err := go_json.Marshal(map[string]string{"query": getAllPokemonQuery}) + if err != nil { + return nil, fmt.Errorf("error marshalling JSON: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed with status code: %d", resp.StatusCode) + } + + var graphQLResp getAllPokemonGraphQLResponse + if err := go_json.NewDecoder(resp.Body).Decode(&graphQLResp); err != nil { + return nil, fmt.Errorf("error parsing JSON response: %w", err) + } + + pokemonList := make([]PokemonData, len(graphQLResp.Data.Pokemon)) + for i, p := range graphQLResp.Data.Pokemon { + pokemonList[i] = PokemonData{ + ID: p.ID, + Name: p.PokemonSpecy.Name, + } + } + + return pokemonList, nil +} + +const getAllPokemonQuery string = ` + query GetAllPokemon { + pokemon_v2_pokemon { + id + pokemon_v2_pokemonspecy { + name + } + } + }` + +type getAllPokemonGraphQLResponse struct { + Data struct { + Pokemon []struct { + ID int `json:"id"` + PokemonSpecy struct { + Name string `json:"name"` + } `json:"pokemon_v2_pokemonspecy"` + } `json:"pokemon_v2_pokemon"` + } `json:"data"` +} diff --git a/internal/services/pokeapi/service.go b/internal/services/pokeapi/service.go new file mode 100644 index 0000000..8f8caa3 --- /dev/null +++ b/internal/services/pokeapi/service.go @@ -0,0 +1,15 @@ +package pokeapi + +import ( + "context" + "net/http" +) + +func GetAllPokemon(ctx context.Context) ([]PokemonData, error) { + return client.getAllPokemon(ctx) +} + +var client = &apiClient{ + client: &http.Client{}, + baseURL: "https://beta.pokeapi.co/graphql/v1beta", +} diff --git a/internal/services/pokeapi/types.go b/internal/services/pokeapi/types.go new file mode 100644 index 0000000..c96ee70 --- /dev/null +++ b/internal/services/pokeapi/types.go @@ -0,0 +1,6 @@ +package pokeapi + +type PokemonData struct { + ID int `json:"id"` + Name string `json:"name"` +} diff --git a/internal/settings/app.go b/internal/settings/app.go new file mode 100644 index 0000000..124193b --- /dev/null +++ b/internal/settings/app.go @@ -0,0 +1,5 @@ +package settings + +type App struct { + Port string `env:"PORT" envDefault:"8080"` +} diff --git a/internal/settings/database.go b/internal/settings/database.go new file mode 100644 index 0000000..74c9c81 --- /dev/null +++ b/internal/settings/database.go @@ -0,0 +1,10 @@ +package settings + +import "time" + +type Database struct { + DSN string `env:"URL" envDefault:"postgres://postgres:password@127.0.0.1:5432/roundest?sslmode=disable"` + MaxOpenConns uint `env:"MAX_OPEN_CONNS" envDefault:"25"` + MaxIdleConns uint `env:"MAX_IDLE_CONNS" envDefault:"25"` + ConnMaxLifetime time.Duration `env:"CONN_MAX_LIFETIME" envDefault:"5m"` +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..52a4e94 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,12 @@ +package settings + +import "github.com/caarlos0/env/v11" + +type Settings struct { + App `envPrefix:"APP_"` + Database `envPrefix:"DATABASE_"` +} + +func Load() (Settings, error) { + return env.ParseAs[Settings]() +} diff --git a/internal/storage/postgres/get_all_pokemon.go b/internal/storage/postgres/get_all_pokemon.go new file mode 100644 index 0000000..9244938 --- /dev/null +++ b/internal/storage/postgres/get_all_pokemon.go @@ -0,0 +1,17 @@ +package postgres + +import ( + "context" + + "github.com/garrettladley/roundest/internal/model" +) + +func (db DB) GetAllPokemon(ctx context.Context) ([]model.Pokemon, error) { + const query string = `SELECT id, name, dex_id, up_votes, down_votes, inserted_at, updated_at FROM pokemon WHERE id < 1025 ORDER BY up_votes DESC` + var pokemon []model.Pokemon + if err := db.db.SelectContext(ctx, &pokemon, query); err != nil { + return nil, err + } + + return pokemon, nil +} diff --git a/internal/storage/postgres/get_all_results.go b/internal/storage/postgres/get_all_results.go new file mode 100644 index 0000000..48983ef --- /dev/null +++ b/internal/storage/postgres/get_all_results.go @@ -0,0 +1,55 @@ +package postgres + +import ( + "context" + "math" + "sort" + + "github.com/garrettladley/roundest/internal/model" +) + +func (db DB) GetAllResults(ctx context.Context) ([]model.Result, error) { + pokemon, err := db.GetAllPokemon(ctx) + if err != nil { + return nil, err + } + + results := make([]model.Result, len(pokemon)) + + for i, pokemon := range pokemon { + var ( + totalVotes = pokemon.UpVotes + pokemon.DownVotes + winPercentage float64 + lossPercentage float64 + ) + + if totalVotes > 0 { + winPercentage = math.Round((float64(pokemon.UpVotes)/float64(totalVotes)*100)*100) / 100 + lossPercentage = math.Round((float64(pokemon.DownVotes)/float64(totalVotes)*100)*100) / 100 + } + + results[i] = model.Result{ + Name: pokemon.Name, + ID: pokemon.ID, + DexID: pokemon.DexID, + UpVotes: pokemon.UpVotes, + DownVotes: pokemon.DownVotes, + TotalVotes: totalVotes, + WinPercentage: winPercentage, + LossPercentage: lossPercentage, + } + } + + sort.Slice(results, func(i, j int) bool { + // if win percentages are equal + if results[i].WinPercentage == results[j].WinPercentage { + return results[i].UpVotes > results[j].UpVotes + } + + // sort by win percentage (higher percentage first) + return results[i].WinPercentage > results[j].WinPercentage + }) + + return results, nil + +} diff --git a/internal/storage/postgres/postgres.go b/internal/storage/postgres/postgres.go new file mode 100644 index 0000000..80d1886 --- /dev/null +++ b/internal/storage/postgres/postgres.go @@ -0,0 +1,43 @@ +package postgres + +import ( + "fmt" + "time" + + "github.com/garrettladley/roundest/internal/settings" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +type Config struct { + DSN string + MaxOpenConns uint + MaxIdleConns uint + ConnMaxLifetime time.Duration +} + +func From(settings settings.Database) Config { + return Config{ + DSN: settings.DSN, + MaxOpenConns: settings.MaxOpenConns, + MaxIdleConns: settings.MaxIdleConns, + ConnMaxLifetime: settings.ConnMaxLifetime, + } +} + +type DB struct { + db *sqlx.DB +} + +func New(cfg Config) (DB, error) { + db, err := sqlx.Connect("postgres", cfg.DSN) + if err != nil { + return DB{}, fmt.Errorf("error connecting to database: %v", err) + } + + db.SetMaxOpenConns(int(cfg.MaxOpenConns)) + db.SetMaxIdleConns(int(cfg.MaxIdleConns)) + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + + return DB{db}, nil +} diff --git a/internal/storage/postgres/random_pair.go b/internal/storage/postgres/random_pair.go new file mode 100644 index 0000000..ba67148 --- /dev/null +++ b/internal/storage/postgres/random_pair.go @@ -0,0 +1,26 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/garrettladley/roundest/internal/model" + "github.com/garrettladley/roundest/internal/types" +) + +func (db DB) RandomPair(ctx context.Context) (types.Pair[model.Pokemon], error) { + const query string = `SELECT name, id, dex_id, up_votes, down_votes, inserted_at, updated_at FROM pokemon WHERE id < 1025 ORDER BY RANDOM() LIMIT 2` + var pokemon []model.Pokemon + if err := db.db.SelectContext(ctx, &pokemon, query); err != nil { + return types.Pair[model.Pokemon]{}, err + } + + if len(pokemon) != 2 { + return types.Pair[model.Pokemon]{}, fmt.Errorf("expected 2 pokemon, got %d", len(pokemon)) + } + + return types.Pair[model.Pokemon]{ + A: pokemon[0], + B: pokemon[1], + }, nil +} diff --git a/internal/storage/postgres/schema.go b/internal/storage/postgres/schema.go new file mode 100644 index 0000000..c99c94b --- /dev/null +++ b/internal/storage/postgres/schema.go @@ -0,0 +1,28 @@ +package postgres + +import ( + "context" + "fmt" +) + +func (db DB) Schema(ctx context.Context) error { + const schema string = ` + CREATE TABLE IF NOT EXISTS pokemon ( + id BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + dex_id INTEGER NOT NULL, + up_votes INTEGER DEFAULT 0, + down_votes INTEGER DEFAULT 0, + inserted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + -- Create an index on dex_id since it's likely to be queried + CREATE INDEX IF NOT EXISTS idx_pokemon_dex_id ON pokemon(dex_id);` + + if _, err := db.db.ExecContext(ctx, schema); err != nil { + return fmt.Errorf("failed to create pokemon schema: %w", err) + } + + return nil +} diff --git a/internal/storage/postgres/seed.go b/internal/storage/postgres/seed.go new file mode 100644 index 0000000..52f7a12 --- /dev/null +++ b/internal/storage/postgres/seed.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + + "github.com/garrettladley/roundest/internal/services/pokeapi" +) + +func (db DB) Seed(ctx context.Context, p []pokeapi.PokemonData) error { + if _, err := db.db.ExecContext(ctx, "DELETE FROM pokemon"); err != nil { + return fmt.Errorf("error clearing existing pokemon: %w", err) + } + + if len(p) == 0 { + return nil + } + + var ( + valueArgs = make([]interface{}, 0, len(p)*5) + builder strings.Builder + ) + + builder.Grow(len(`INSERT INTO pokemon (name, dex_id, up_votes, down_votes, id, inserted_at, updated_at) VALUES `) + (len(p) * 50)) + + builder.WriteString(`INSERT INTO pokemon ( + name, dex_id, up_votes, down_votes, id, inserted_at, updated_at + ) VALUES `) + + for i, pokemon := range p { + if i > 0 { + builder.WriteByte(',') + } + fmt.Fprintf(&builder, "($%d, $%d, $%d, $%d, $%d, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + i*5+1, i*5+2, i*5+3, i*5+4, i*5+5) + + valueArgs = append(valueArgs, + pokemon.Name, + pokemon.ID, + 0, // up_votes + 0, // down_votes + pokemon.ID) + } + + if _, err := db.db.ExecContext(ctx, builder.String(), valueArgs...); err != nil { + return fmt.Errorf("error performing bulk insert: %w", err) + } + + return nil +} diff --git a/internal/storage/postgres/vote.go b/internal/storage/postgres/vote.go new file mode 100644 index 0000000..c55cadc --- /dev/null +++ b/internal/storage/postgres/vote.go @@ -0,0 +1,53 @@ +package postgres + +import ( + "context" + "fmt" + "log/slog" +) + +type PokemonVotes struct { + ID int + UpVotes int + DownVotes int +} + +func (db DB) Vote(ctx context.Context, upvoteID int, downvoteID int) error { + tx, err := db.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if r := recover(); r != nil { + if err := tx.Rollback(); err != nil { + slog.Error("rollback failed", slog.Any("error", err), slog.Any("recover", r)) + } + } + }() + + execQuery := func(query string, id int) error { + if _, err := tx.Exec(query, id); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %v (original error: %v)", rbErr, err) + } + return fmt.Errorf("query failed: %w", err) + } + return nil + } + + const upvoteQuery string = `UPDATE pokemon SET up_votes = up_votes + 1 WHERE id = $1` + if err := execQuery(upvoteQuery, upvoteID); err != nil { + return fmt.Errorf("upvote failed: %w", err) + } + + const downvoteQuery string = `UPDATE pokemon SET down_votes = down_votes + 1 WHERE id = $1` + if err := execQuery(downvoteQuery, downvoteID); err != nil { + return fmt.Errorf("downvote failed: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..8feb58a --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,20 @@ +package storage + +import ( + "context" + + "github.com/garrettladley/roundest/internal/model" + "github.com/garrettladley/roundest/internal/services/pokeapi" + "github.com/garrettladley/roundest/internal/types" +) + +type Storage interface { + GetAllPokemon(ctx context.Context) ([]model.Pokemon, error) + RandomPair(ctx context.Context) (types.Pair[model.Pokemon], error) + + GetAllResults(ctx context.Context) ([]model.Result, error) + Vote(ctx context.Context, upvoteID int, downvoteID int) error + + Schema(ctx context.Context) error + Seed(ctx context.Context, p []pokeapi.PokemonData) error +} diff --git a/internal/templ/render.go b/internal/templ/render.go new file mode 100644 index 0000000..7f81c6a --- /dev/null +++ b/internal/templ/render.go @@ -0,0 +1,11 @@ +package templ + +import ( + "github.com/a-h/templ" + "github.com/gofiber/fiber/v2" +) + +func Render(c *fiber.Ctx, component templ.Component) error { + c.Set("Content-Type", "text/html; charset=utf-8") + return component.Render(c.Context(), c.Response().BodyWriter()) +} diff --git a/internal/types/pair.go b/internal/types/pair.go new file mode 100644 index 0000000..522912e --- /dev/null +++ b/internal/types/pair.go @@ -0,0 +1,10 @@ +package types + +type Pair[T any] struct { + A T `json:"a"` + B T `json:"b"` +} + +func Swap[T any](p Pair[T]) Pair[T] { + return Pair[T]{A: p.B, B: p.A} +} diff --git a/internal/views/components/pokemon_sprite.templ b/internal/views/components/pokemon_sprite.templ new file mode 100644 index 0000000..8d673aa --- /dev/null +++ b/internal/views/components/pokemon_sprite.templ @@ -0,0 +1,11 @@ +package components + +import "strconv" + +templ PokemonSprite(dexID int, class string) { + +} diff --git a/internal/views/css/app.css b/internal/views/css/app.css new file mode 100644 index 0000000..c52c300 --- /dev/null +++ b/internal/views/css/app.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-950 text-gray-100; +} diff --git a/internal/views/home/index.templ b/internal/views/home/index.templ new file mode 100644 index 0000000..c30c347 --- /dev/null +++ b/internal/views/home/index.templ @@ -0,0 +1,55 @@ +package home + +import ( + "github.com/garrettladley/roundest/internal/model" + "github.com/garrettladley/roundest/internal/types" + "github.com/garrettladley/roundest/internal/views/components" + "github.com/garrettladley/roundest/internal/views/layouts" + "strconv" +) + +templ Index(pair types.Pair[model.Pokemon]) { + @layouts.Base() { +
+ @Ballot(pair) +
+ } +} + +templ Ballot(pair types.Pair[model.Pokemon]) { +
+ @components.PokemonSprite(pair.A.DexID, "w-64 h-64") +
+ #{ strconv.Itoa(int(pair.A.ID)) } +

{ pair.A.Name }

+ +
+
+
+ @components.PokemonSprite(pair.B.DexID, "w-64 h-64") +
+ #{ strconv.Itoa(int(pair.B.ID)) } +

{ pair.B.Name }

+ +
+
+} + +func votePost(pair types.Pair[model.Pokemon]) string { + return "/vote?up=" + strconv.Itoa(int(pair.A.ID)) + "&down=" + strconv.Itoa(int(pair.B.ID)) +} diff --git a/internal/views/layouts/base.templ b/internal/views/layouts/base.templ new file mode 100644 index 0000000..f5815da --- /dev/null +++ b/internal/views/layouts/base.templ @@ -0,0 +1,54 @@ +package layouts + +templ Base() { + + + + Roundest + + + + + + +
+
+ +
+
+ { children... } +
+ +
+ + +} diff --git a/internal/views/results/index.templ b/internal/views/results/index.templ new file mode 100644 index 0000000..8b36989 --- /dev/null +++ b/internal/views/results/index.templ @@ -0,0 +1,43 @@ +package results + +import ( + "github.com/garrettladley/roundest/internal/model" + "github.com/garrettladley/roundest/internal/views/components" + "github.com/garrettladley/roundest/internal/views/layouts" + "strconv" +) + +templ Index(r []model.Result) { + @layouts.Base() { +
+
+ for index, result := range r { +
+
+ #{ strconv.Itoa(index + 1) } +
+ @components.PokemonSprite(result.DexID, "w-20 h-20") +
+
#{ strconv.Itoa(result.DexID) }
+

{ result.Name }

+
+
+
+ { formatPercentage(result.WinPercentage) }% +
+
+ { strconv.Itoa(result.UpVotes) }W - { strconv.Itoa(result.DownVotes) }L +
+
+
+ } +
+
+ } +} + +func formatPercentage(p float64) string { + return strconv.FormatFloat(p, 'f', 1, 64) +} diff --git a/internal/xerr/api_error.go b/internal/xerr/api_error.go new file mode 100644 index 0000000..ba3ea30 --- /dev/null +++ b/internal/xerr/api_error.go @@ -0,0 +1,67 @@ +package xerr + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/gofiber/fiber/v2" +) + +type APIError struct { + StatusCode int `json:"statusCode"` + Message any `json:"msg"` +} + +func (e APIError) Error() string { + return fmt.Sprintf("api error: %d %v", e.StatusCode, e.Message) +} + +func NewAPIError(statusCode int, err error) APIError { + return APIError{ + StatusCode: statusCode, + Message: err.Error(), + } +} + +func BadRequest(err error) APIError { + return NewAPIError(http.StatusBadRequest, err) +} + +func InvalidJSON(err error) APIError { + return NewAPIError(http.StatusBadRequest, fmt.Errorf("invalid JSON: %w", err)) +} + +func NotFound(title string, withKey string, withValue any) APIError { + return NewAPIError(http.StatusNotFound, fmt.Errorf("%s with %s='%s' not found", title, withKey, withValue)) +} + +func Conflict(title string, withKey string, withValue any) APIError { + return NewAPIError(http.StatusConflict, fmt.Errorf("conflict: %s with %s='%s' already exists", title, withKey, withValue)) +} + +func InvalidRequestData(errors map[string]string) APIError { + return APIError{ + StatusCode: http.StatusUnprocessableEntity, + Message: errors, + } +} + +func InternalServerError() APIError { + return NewAPIError(http.StatusInternalServerError, errors.New("internal server error")) +} + +func ErrorHandler(c *fiber.Ctx, err error) error { + var apiErr APIError + + if castedErr, ok := err.(APIError); ok { + apiErr = castedErr + } else { + apiErr = InternalServerError() + } + + slog.Error("HTTP API error", "err", err.Error(), "method", c.Method(), "path", c.Path()) + + return c.Status(apiErr.StatusCode).JSON(apiErr) +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..0eb832a --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,7 @@ +module.exports = { + content: ["./**/*.html", "./**/*.templ", "./**/*.go",], + plugins: [require("daisyui")], + daisyui: { + themes: ["dark"] + } +}