diff --git a/.env.example b/.env.example index 4a520bc..2ac9522 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ COD_API_BASE_URL=https://my.callofduty.com/api/papi-client # Expires every ~14 days COD_SSO_TOKEN=your-sso-token-here +# Admin API Key — used to protect admin endpoints (e.g., POST /api/v1/admin/token) +# Generate a secure random string: openssl rand -hex 32 +ADMIN_API_KEY=your-admin-api-key-here + # CORS # Comma-separated allowed origins. Use http://localhost:5173 for local Vue dev server. CORS_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 687698d..3e2d5fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: run: npm ci - name: Lint - run: npm run lint + run: npx oxlint . && npx eslint . --cache - name: Type check run: npm run type-check @@ -54,4 +54,4 @@ jobs: run: npm run build - name: Test - run: npm run test:unit + run: npx vitest run diff --git a/Dockerfile b/Dockerfile index 34d0d77..c2809da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/web/dist ./web/dist -RUN CGO_ENABLED=0 go build -o server ./cmd/server +RUN CGO_ENABLED=0 go build -tags embed_dist -o server ./cmd/server # Stage 3: Runtime FROM alpine:3.21 diff --git a/cmd/server/main.go b/cmd/server/main.go index 73ee15b..5c628e6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,14 +3,22 @@ package main import ( "context" "fmt" + "io/fs" "log/slog" "net/http" "os" "os/signal" + "strings" "syscall" "time" + "github.com/grovecj/warzone-stats-tracker/internal/cache" + "github.com/grovecj/warzone-stats-tracker/internal/codclient" "github.com/grovecj/warzone-stats-tracker/internal/config" + "github.com/grovecj/warzone-stats-tracker/internal/database" + "github.com/grovecj/warzone-stats-tracker/internal/handler" + "github.com/grovecj/warzone-stats-tracker/internal/router" + "github.com/grovecj/warzone-stats-tracker/web" ) func main() { @@ -25,13 +33,54 @@ func main() { })) slog.SetDefault(logger) - // TODO: initialize database connection (issue #5) - // TODO: initialize CoD API client (issue #6) - // TODO: initialize cache layer (issue #7) - // TODO: wire up router with handlers (issue #4) + // Database + ctx := context.Background() + pool, err := database.Connect(ctx, cfg.DatabaseURL) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer pool.Close() + slog.Info("database connected") + + // Run migrations + if err := database.RunMigrations(cfg.DatabaseURL, "migrations"); err != nil { + slog.Error("failed to run migrations", "error", err) + os.Exit(1) + } + + // CoD API client with caching + codAPI := codclient.New(cfg.CodAPIBaseURL, cfg.CodSSOToken) + cachedAPI := cache.New(codAPI, cache.DefaultConfig()) + + // Static files — use embedded FS in production, nil in dev (Vite proxy handles it) + var staticFS fs.FS + if dist, err := fs.Sub(web.DistFS, "dist"); err == nil { + if _, err := fs.Stat(dist, "index.html"); err == nil { + staticFS = dist + slog.Info("serving embedded frontend") + } + } + + // Handlers + adminHandler := handler.NewAdminHandler(cachedAPI) + + // Router + rawOrigins := strings.Split(cfg.CORSAllowedOrigins, ",") + var origins []string + for _, o := range rawOrigins { + if trimmed := strings.TrimSpace(o); trimmed != "" { + origins = append(origins, trimmed) + } + } + mux := router.New(origins, staticFS, router.Deps{ + AdminHandler: adminHandler, + AdminAPIKey: cfg.AdminAPIKey, + }) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, @@ -52,10 +101,10 @@ func main() { <-done slog.Info("shutting down server") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := srv.Shutdown(ctx); err != nil { + if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("server shutdown failed", "error", err) os.Exit(1) } diff --git a/go.mod b/go.mod index 22f5511..fd16203 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,22 @@ module github.com/grovecj/warzone-stats-tracker go 1.25.7 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/cors v1.2.2 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.8.0 + github.com/rs/xid v1.6.0 + resty.dev/v3 v3.0.0-beta.6 +) + +require ( + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d0077df --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +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/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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU= +resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..db7ec8e --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,182 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/grovecj/warzone-stats-tracker/internal/codclient" +) + +// isTransientError returns true for errors where serving stale cache is appropriate. +func isTransientError(err error) bool { + return errors.Is(err, codclient.ErrAPIUnavailable) || errors.Is(err, codclient.ErrRateLimited) +} + +type entry struct { + value any + createdAt time.Time + expiresAt time.Time +} + +func (e entry) isExpired() bool { + return time.Now().After(e.expiresAt) +} + +// CachedClient wraps a CodClient with in-process TTL caching. +type CachedClient struct { + inner codclient.CodClient + mu sync.RWMutex + store map[string]entry + statsTTL time.Duration + matchTTL time.Duration +} + +// Config holds cache TTL settings. +type Config struct { + StatsTTL time.Duration + MatchTTL time.Duration +} + +func DefaultConfig() Config { + return Config{ + StatsTTL: 5 * time.Minute, + MatchTTL: 2 * time.Minute, + } +} + +// New wraps a CodClient with caching. +func New(inner codclient.CodClient, cfg Config) *CachedClient { + c := &CachedClient{ + inner: inner, + store: make(map[string]entry), + statsTTL: cfg.StatsTTL, + matchTTL: cfg.MatchTTL, + } + go c.evictLoop() + return c +} + +func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*codclient.PlayerStats, error) { + key := fmt.Sprintf("stats:%s:%s:%s", platform, gamertag, mode) + + if val, hit := c.get(key); hit { + slog.Debug("cache hit", "key", key) + return val.(*codclient.PlayerStats), nil + } + + stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, mode) + if err != nil { + // Only serve stale data for transient errors (API down, rate limited) + if isTransientError(err) { + if val, ok := c.getStale(key); ok { + slog.Warn("serving stale cache due to API error", "key", key, "error", err) + return val.(*codclient.PlayerStats), nil + } + } + return nil, err + } + + c.set(key, stats, c.statsTTL) + return stats, nil +} + +func (c *CachedClient) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]codclient.Match, error) { + key := fmt.Sprintf("matches:%s:%s", platform, gamertag) + + if val, hit := c.get(key); hit { + slog.Debug("cache hit", "key", key) + return val.([]codclient.Match), nil + } + + matches, err := c.inner.GetRecentMatches(ctx, platform, gamertag) + if err != nil { + if isTransientError(err) { + if val, ok := c.getStale(key); ok { + slog.Warn("serving stale cache due to API error", "key", key, "error", err) + return val.([]codclient.Match), nil + } + } + return nil, err + } + + c.set(key, matches, c.matchTTL) + return matches, nil +} + +// UpdateToken passes through to the inner client. +func (c *CachedClient) UpdateToken(newToken string) { + c.inner.UpdateToken(newToken) +} + +// CacheInfo returns hit/miss status and age for use in response headers. +func (c *CachedClient) CacheInfo(key string) (hit bool, ageSeconds int, stale bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + e, exists := c.store[key] + if !exists { + return false, 0, false + } + + age := int(time.Since(e.createdAt).Seconds()) + if age < 0 { + age = 0 + } + + return true, age, e.isExpired() +} + +func (c *CachedClient) get(key string) (any, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + e, ok := c.store[key] + if !ok || e.isExpired() { + return nil, false + } + return e.value, true +} + +func (c *CachedClient) getStale(key string) (any, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + e, ok := c.store[key] + if !ok { + return nil, false + } + return e.value, true +} + +func (c *CachedClient) set(key string, value any, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + c.store[key] = entry{ + value: value, + createdAt: now, + expiresAt: now.Add(ttl), + } +} + +func (c *CachedClient) evictLoop() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + // Evict entries that have been stale for more than 1 hour + cutoff := time.Now().Add(-1 * time.Hour) + for k, e := range c.store { + if e.expiresAt.Before(cutoff) { + delete(c.store, k) + } + } + c.mu.Unlock() + } +} diff --git a/internal/codclient/client.go b/internal/codclient/client.go index 980ae43..46c6121 100644 --- a/internal/codclient/client.go +++ b/internal/codclient/client.go @@ -1,10 +1,191 @@ package codclient -import "context" +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "sync" + "time" + + "resty.dev/v3" +) + +const ( + defaultTitle = "mw" // Modern Warfare / Warzone title code +) // CodClient defines the interface for interacting with the Call of Duty API. -// Full implementation in issue #6. type CodClient interface { GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error) + UpdateToken(newToken string) +} + +type client struct { + http *resty.Client + baseURL string + mu sync.RWMutex + token string +} + +// New creates a new CoD API client. +func New(baseURL, ssoToken string) CodClient { + c := resty.New() + c.SetBaseURL(baseURL) + c.SetTimeout(10 * time.Second) + c.SetRetryCount(3) + c.SetRetryWaitTime(1 * time.Second) + c.SetRetryMaxWaitTime(5 * time.Second) + c.SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + c.SetHeader("Accept", "application/json") + + return &client{http: c, baseURL: baseURL, token: ssoToken} +} + +func (c *client) authCookie() *http.Cookie { + c.mu.RLock() + defer c.mu.RUnlock() + return &http.Cookie{Name: "ACT_SSO_COOKIE", Value: c.token} +} + +func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error) { + if mode == "" { + mode = "wz" + } + + encodedTag := url.PathEscape(gamertag) + endpoint := fmt.Sprintf("/stats/cod/v1/title/%s/platform/%s/gamer/%s/profile/type/%s", + defaultTitle, platform, encodedTag, mode) + + resp, err := c.http.R(). + SetContext(ctx). + SetCookie(c.authCookie()). + Get(endpoint) + if err != nil { + slog.Error("cod api request failed", "endpoint", endpoint, "error", err) + return nil, ErrAPIUnavailable + } + + if err := c.checkResponse(resp); err != nil { + return nil, err + } + + var profileResp profileResponse + if err := json.Unmarshal(resp.Bytes(), &profileResp); err != nil { + return nil, fmt.Errorf("decoding profile response: %w", err) + } + + stats := c.mapProfileToStats(profileResp, platform, gamertag) + return stats, nil +} + +func (c *client) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error) { + encodedTag := url.PathEscape(gamertag) + endpoint := fmt.Sprintf("/crm/cod/v2/title/%s/platform/%s/gamer/%s/matches/wz/start/0/end/0/details", + defaultTitle, platform, encodedTag) + + resp, err := c.http.R(). + SetContext(ctx). + SetCookie(c.authCookie()). + Get(endpoint) + if err != nil { + slog.Error("cod api request failed", "endpoint", endpoint, "error", err) + return nil, ErrAPIUnavailable + } + + if err := c.checkResponse(resp); err != nil { + return nil, err + } + + var matchResp matchesResponse + if err := json.Unmarshal(resp.Bytes(), &matchResp); err != nil { + return nil, fmt.Errorf("decoding matches response: %w", err) + } + + matches := make([]Match, 0, len(matchResp.Data.Matches)) + for _, m := range matchResp.Data.Matches { + gulag := "" + if m.PlayerStats.GulagKills > 0 { + gulag = "win" + } else if m.PlayerStats.GulagDeaths > 0 { + gulag = "loss" + } + + matches = append(matches, Match{ + MatchID: m.MatchID, + Mode: m.Mode, + Map: m.Map, + Placement: m.PlayerStats.TeamPlacement, + Kills: m.PlayerStats.Kills, + Deaths: m.PlayerStats.Deaths, + KDRatio: m.PlayerStats.KDRatio, + DamageDealt: m.PlayerStats.DamageDone, + DamageTaken: m.PlayerStats.DamageTaken, + GulagResult: gulag, + Duration: m.Duration, + MatchTime: time.Unix(int64(m.UTCStartSeconds), 0), + }) + } + + return matches, nil +} + +// UpdateToken replaces the SSO token at runtime (for admin token refresh). +func (c *client) UpdateToken(newToken string) { + c.mu.Lock() + defer c.mu.Unlock() + c.token = newToken + slog.Info("cod api sso token updated") +} + +func (c *client) checkResponse(resp *resty.Response) error { + switch resp.StatusCode() { + case http.StatusOK: + return nil + case http.StatusUnauthorized: + return ErrTokenExpired + case http.StatusForbidden: + return ErrPrivateProfile + case http.StatusNotFound: + return ErrPlayerNotFound + case http.StatusTooManyRequests: + return ErrRateLimited + default: + if resp.StatusCode() >= 500 { + return ErrAPIUnavailable + } + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode(), resp.String()) + } +} + +func (c *client) mapProfileToStats(resp profileResponse, platform, gamertag string) *PlayerStats { + stats := &PlayerStats{ + Platform: platform, + Gamertag: gamertag, + Level: resp.Data.Level, + Prestige: resp.Data.Prestige, + } + + if props, ok := resp.Data.Lifetime.All["properties"]; ok { + stats.Kills = int(props.Kills) + stats.Deaths = int(props.Deaths) + stats.KDRatio = props.KDRatio + stats.Wins = int(props.Wins) + stats.Losses = int(props.Losses) + stats.WinPct = props.WinPct + stats.ScorePerMin = props.ScorePerMin + stats.Headshots = int(props.Headshots) + stats.TimePlayed = int(props.TimePlayed) + stats.MatchesPlayed = int(props.MatchesPlayed) + stats.TopFive = int(props.TopFive) + stats.TopTen = int(props.TopTen) + stats.TopTwentyFive = int(props.TopTwentyFive) + stats.Assists = int(props.Assists) + stats.DamageDone = int(props.DamageDone) + } + + return stats } diff --git a/internal/codclient/errors.go b/internal/codclient/errors.go new file mode 100644 index 0000000..3359889 --- /dev/null +++ b/internal/codclient/errors.go @@ -0,0 +1,11 @@ +package codclient + +import "errors" + +var ( + ErrPrivateProfile = errors.New("player profile is set to private") + ErrPlayerNotFound = errors.New("player not found") + ErrRateLimited = errors.New("rate limited by CoD API") + ErrAPIUnavailable = errors.New("CoD API is unavailable") + ErrTokenExpired = errors.New("SSO token has expired") +) diff --git a/internal/codclient/types.go b/internal/codclient/types.go index c0008be..7e9eb48 100644 --- a/internal/codclient/types.go +++ b/internal/codclient/types.go @@ -4,20 +4,25 @@ import "time" // PlayerStats represents lifetime player statistics from the CoD API. type PlayerStats struct { - Platform string `json:"platform"` - Gamertag string `json:"gamertag"` - Level int `json:"level"` - Prestige int `json:"prestige"` - Kills int `json:"kills"` - Deaths int `json:"deaths"` - KDRatio float64 `json:"kdRatio"` - Wins int `json:"wins"` - Losses int `json:"losses"` - WinPct float64 `json:"winPct"` - ScorePerMin float64 `json:"scorePerMin"` - Headshots int `json:"headshots"` - TimePlayed int `json:"timePlayed"` - MatchesPlayed int `json:"matchesPlayed"` + Platform string `json:"platform"` + Gamertag string `json:"gamertag"` + Level int `json:"level"` + Prestige int `json:"prestige"` + Kills int `json:"kills"` + Deaths int `json:"deaths"` + KDRatio float64 `json:"kdRatio"` + Wins int `json:"wins"` + Losses int `json:"losses"` + WinPct float64 `json:"winPct"` + ScorePerMin float64 `json:"scorePerMin"` + Headshots int `json:"headshots"` + TimePlayed int `json:"timePlayed"` + MatchesPlayed int `json:"matchesPlayed"` + TopFive int `json:"topFive"` + TopTen int `json:"topTen"` + TopTwentyFive int `json:"topTwentyFive"` + Assists int `json:"assists"` + DamageDone int `json:"damageDone"` } // Match represents a single match from the CoD API. @@ -32,5 +37,76 @@ type Match struct { DamageDealt int `json:"damageDealt"` DamageTaken int `json:"damageTaken"` GulagResult string `json:"gulagResult"` + Duration int `json:"duration"` MatchTime time.Time `json:"matchTime"` + RawData any `json:"rawData,omitempty"` +} + +// apiResponse is the wrapper returned by the CoD API. +type apiResponse struct { + Status string `json:"status"` + Data any `json:"data"` +} + +// profileResponse maps the nested profile endpoint response. +type profileResponse struct { + Status string `json:"status"` + Data struct { + Type string `json:"type"` + Message string `json:"message"` + Lifetime struct { + All map[string]statsBlock `json:"all"` + Mode map[string]any `json:"mode"` + } `json:"lifetime"` + Level int `json:"level"` + Prestige int `json:"prestige"` + } `json:"data"` +} + +type statsBlock struct { + Kills float64 `json:"kills"` + Deaths float64 `json:"deaths"` + KDRatio float64 `json:"kdRatio"` + Wins float64 `json:"wins"` + Losses float64 `json:"losses"` + WinPct float64 `json:"wlRatio"` + ScorePerMin float64 `json:"scorePerMinute"` + Headshots float64 `json:"headshots"` + TimePlayed float64 `json:"timePlayed"` + MatchesPlayed float64 `json:"matchesPlayed"` + TopFive float64 `json:"topFive"` + TopTen float64 `json:"topTen"` + TopTwentyFive float64 `json:"topTwentyFive"` + Assists float64 `json:"assists"` + DamageDone float64 `json:"damageDone"` +} + +// matchesResponse maps the matches endpoint response. +type matchesResponse struct { + Status string `json:"status"` + Data struct { + Matches []matchData `json:"matches"` + Message string `json:"message"` + } `json:"data"` +} + +type matchData struct { + MatchID string `json:"matchID"` + Mode string `json:"mode"` + Map string `json:"map"` + PlayerStats matchPlayerStats `json:"playerStats"` + Duration int `json:"duration"` + UTCStartSeconds float64 `json:"utcStartSeconds"` + RawData any `json:"-"` +} + +type matchPlayerStats struct { + Kills int `json:"kills"` + Deaths int `json:"deaths"` + KDRatio float64 `json:"kdRatio"` + DamageDone int `json:"damageDone"` + DamageTaken int `json:"damageTaken"` + TeamPlacement int `json:"teamPlacement"` + GulagKills int `json:"gulagKills"` + GulagDeaths int `json:"gulagDeaths"` } diff --git a/internal/config/config.go b/internal/config/config.go index 40e871f..439f40e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { DatabaseURL string CodAPIBaseURL string CodSSOToken string + AdminAPIKey string CORSAllowedOrigins string LogLevelStr string } @@ -22,6 +23,7 @@ func Load() (*Config, error) { DatabaseURL: getEnv("DATABASE_URL", ""), CodAPIBaseURL: getEnv("COD_API_BASE_URL", "https://my.callofduty.com/api/papi-client"), CodSSOToken: getEnv("COD_SSO_TOKEN", ""), + AdminAPIKey: getEnv("ADMIN_API_KEY", ""), CORSAllowedOrigins: getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5173"), LogLevelStr: getEnv("LOG_LEVEL", "info"), } diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..6d22515 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parsing database URL: %w", err) + } + + config.MaxConns = 20 + config.MinConns = 2 + config.MaxConnLifetime = 30 * time.Minute + config.MaxConnIdleTime = 5 * time.Minute + config.HealthCheckPeriod = 1 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("creating connection pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + + return pool, nil +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..9871e54 --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,39 @@ +package database + +import ( + "fmt" + "log/slog" + "net/url" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func RunMigrations(databaseURL, migrationsPath string) error { + m, err := migrate.New( + fmt.Sprintf("file://%s", migrationsPath), + pgxConnString(databaseURL), + ) + if err != nil { + return fmt.Errorf("creating migrator: %w", err) + } + defer m.Close() + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("running migrations: %w", err) + } + + version, dirty, _ := m.Version() + slog.Info("migrations complete", "version", version, "dirty", dirty) + return nil +} + +func pgxConnString(databaseURL string) string { + u, err := url.Parse(databaseURL) + if err != nil { + return databaseURL + } + u.Scheme = "pgx5" + return u.String() +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..9acf7c5 --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,54 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/grovecj/warzone-stats-tracker/internal/codclient" +) + +// AdminHandler holds dependencies for admin endpoints. +type AdminHandler struct { + codClient codclient.CodClient +} + +// NewAdminHandler creates a new AdminHandler. +func NewAdminHandler(codClient codclient.CodClient) *AdminHandler { + return &AdminHandler{codClient: codClient} +} + +type updateTokenRequest struct { + Token string `json:"token"` +} + +// UpdateToken handles POST /api/v1/admin/token to refresh the SSO token at runtime. +func (h *AdminHandler) UpdateToken(w http.ResponseWriter, r *http.Request) { + var req updateTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_request", + "message": "Request body must contain a JSON object with a 'token' field", + }) + return + } + + if req.Token == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_request", + "message": "Token must not be empty", + }) + return + } + + h.codClient.UpdateToken(req.Token) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "message": "SSO token updated successfully", + }) +} diff --git a/internal/handler/health.go b/internal/handler/health.go index f78a5a6..00f830a 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -13,3 +13,12 @@ func Health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) } + +func NotImplemented(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + json.NewEncoder(w).Encode(map[string]string{ + "error": "not_implemented", + "message": "This endpoint is not yet implemented", + }) +} diff --git a/internal/handler/match.go b/internal/handler/match.go index ef26650..d0dc593 100644 --- a/internal/handler/match.go +++ b/internal/handler/match.go @@ -1,3 +1,4 @@ package handler // Match history HTTP handlers will be implemented in issue #12. +// Route stubs are registered in router/router.go using handler.NotImplemented. diff --git a/internal/handler/player.go b/internal/handler/player.go index fb14460..34bfad5 100644 --- a/internal/handler/player.go +++ b/internal/handler/player.go @@ -1,3 +1,4 @@ package handler // Player HTTP handlers will be implemented in issue #9. +// Route stubs are registered in router/router.go using handler.NotImplemented. diff --git a/internal/handler/squad.go b/internal/handler/squad.go index 31a430e..e681cf0 100644 --- a/internal/handler/squad.go +++ b/internal/handler/squad.go @@ -1,3 +1,4 @@ package handler // Squad HTTP handlers will be implemented in issue #16. +// Route stubs are registered in router/router.go using handler.NotImplemented. diff --git a/internal/middleware/adminauth.go b/internal/middleware/adminauth.go new file mode 100644 index 0000000..d53bf54 --- /dev/null +++ b/internal/middleware/adminauth.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "strings" +) + +// AdminAuth returns middleware that validates the ADMIN_API_KEY via the Authorization header. +func AdminAuth(apiKey string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if apiKey == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{ + "error": "admin_not_configured", + "message": "Admin API key is not configured", + }) + return + } + + auth := r.Header.Get("Authorization") + token := strings.TrimPrefix(auth, "Bearer ") + if token == "" || token == auth || token != apiKey { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthorized", + "message": "Invalid or missing admin API key", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go index b6f3a40..0d6cca5 100644 --- a/internal/middleware/logging.go +++ b/internal/middleware/logging.go @@ -1,3 +1,40 @@ package middleware -// Request logging middleware will be implemented in issue #4. +import ( + "log/slog" + "net/http" + "time" + + "github.com/rs/xid" +) + +type responseWriter struct { + http.ResponseWriter + status int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func RequestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + reqID := xid.New().String() + + w.Header().Set("X-Request-ID", reqID) + + wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(wrapped, r) + + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", wrapped.status, + "duration_ms", time.Since(start).Milliseconds(), + "request_id", reqID, + "remote_addr", r.RemoteAddr, + ) + }) +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..2fe333f --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,78 @@ +package middleware + +import ( + "encoding/json" + "net" + "net/http" + "sync" + "time" +) + +type visitor struct { + count int + lastSeen time.Time +} + +type RateLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + limit int + window time.Duration +} + +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + visitors: make(map[string]*visitor), + limit: limit, + window: window, + } + go rl.cleanup() + return rl +} + +func (rl *RateLimiter) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + if ip == "" { + ip = r.RemoteAddr + } + + rl.mu.Lock() + v, exists := rl.visitors[ip] + if !exists || time.Since(v.lastSeen) > rl.window { + rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()} + rl.mu.Unlock() + next.ServeHTTP(w, r) + return + } + + v.count++ + v.lastSeen = time.Now() + if v.count > rl.limit { + rl.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]string{ + "error": "rate_limited", + "message": "Too many requests", + }) + return + } + rl.mu.Unlock() + + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) cleanup() { + for { + time.Sleep(rl.window) + rl.mu.Lock() + for ip, v := range rl.visitors { + if time.Since(v.lastSeen) > rl.window { + delete(rl.visitors, ip) + } + } + rl.mu.Unlock() + } +} diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..ba0e4fb --- /dev/null +++ b/internal/middleware/recovery.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "encoding/json" + "log/slog" + "net/http" + "runtime/debug" +) + +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + slog.Error("panic recovered", + "error", err, + "stack", string(debug.Stack()), + "path", r.URL.Path, + ) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "internal_server_error", + "message": "An unexpected error occurred", + }) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/internal/repository/match_repo.go b/internal/repository/match_repo.go index f3c754a..4b43ca2 100644 --- a/internal/repository/match_repo.go +++ b/internal/repository/match_repo.go @@ -1,3 +1,73 @@ package repository -// Match data access layer will be implemented in issue #5. +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/grovecj/warzone-stats-tracker/internal/model" +) + +type MatchRepo struct { + pool *pgxpool.Pool +} + +func NewMatchRepo(pool *pgxpool.Pool) *MatchRepo { + return &MatchRepo{pool: pool} +} + +func (r *MatchRepo) UpsertBatch(ctx context.Context, playerID string, matches []model.Match) error { + for _, m := range matches { + rawJSON, err := json.Marshal(m) + if err != nil { + return err + } + _, err = r.pool.Exec(ctx, ` + INSERT INTO matches (match_id, player_id, mode, map_name, placement, kills, deaths, + damage_dealt, damage_taken, gulag_result, match_time, raw_data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (match_id) DO NOTHING + `, m.MatchID, playerID, m.Mode, m.MapName, m.Placement, + m.Kills, m.Deaths, m.DamageDealt, m.DamageTaken, + m.GulagResult, m.MatchTime, rawJSON) + if err != nil { + return err + } + } + return nil +} + +func (r *MatchRepo) GetByPlayerID(ctx context.Context, playerID string, limit, offset int) ([]model.Match, error) { + if limit <= 0 { + limit = 20 + } + + rows, err := r.pool.Query(ctx, ` + SELECT id, match_id, player_id, mode, map_name, placement, kills, deaths, + damage_dealt, damage_taken, gulag_result, match_time, created_at + FROM matches + WHERE player_id = $1 + ORDER BY match_time DESC NULLS LAST + LIMIT $2 OFFSET $3 + `, playerID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var matches []model.Match + for rows.Next() { + var m model.Match + if err := rows.Scan(&m.ID, &m.MatchID, &m.PlayerID, &m.Mode, &m.MapName, + &m.Placement, &m.Kills, &m.Deaths, &m.DamageDealt, &m.DamageTaken, + &m.GulagResult, &m.MatchTime, &m.CreatedAt); err != nil { + return nil, err + } + matches = append(matches, m) + } + if err := rows.Err(); err != nil { + return nil, err + } + return matches, nil +} diff --git a/internal/repository/player_repo.go b/internal/repository/player_repo.go index 131c027..4439d8a 100644 --- a/internal/repository/player_repo.go +++ b/internal/repository/player_repo.go @@ -1,3 +1,97 @@ package repository -// Player data access layer will be implemented in issue #5. +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/grovecj/warzone-stats-tracker/internal/model" +) + +type PlayerRepo struct { + pool *pgxpool.Pool +} + +func NewPlayerRepo(pool *pgxpool.Pool) *PlayerRepo { + return &PlayerRepo{pool: pool} +} + +func (r *PlayerRepo) Upsert(ctx context.Context, platform, gamertag string) (*model.Player, error) { + var p model.Player + err := r.pool.QueryRow(ctx, ` + INSERT INTO players (platform, gamertag, last_fetched_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (platform, gamertag) + DO UPDATE SET last_fetched_at = NOW(), updated_at = NOW() + RETURNING id, platform, gamertag, activision_id, last_fetched_at, created_at, updated_at + `, platform, gamertag).Scan( + &p.ID, &p.Platform, &p.Gamertag, &p.ActivisionID, + &p.LastFetchedAt, &p.CreatedAt, &p.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &p, nil +} + +func (r *PlayerRepo) GetByPlatformAndTag(ctx context.Context, platform, gamertag string) (*model.Player, error) { + var p model.Player + err := r.pool.QueryRow(ctx, ` + SELECT id, platform, gamertag, activision_id, last_fetched_at, created_at, updated_at + FROM players WHERE platform = $1 AND gamertag = $2 + `, platform, gamertag).Scan( + &p.ID, &p.Platform, &p.Gamertag, &p.ActivisionID, + &p.LastFetchedAt, &p.CreatedAt, &p.UpdatedAt, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (r *PlayerRepo) GetByID(ctx context.Context, id string) (*model.Player, error) { + var p model.Player + err := r.pool.QueryRow(ctx, ` + SELECT id, platform, gamertag, activision_id, last_fetched_at, created_at, updated_at + FROM players WHERE id = $1 + `, id).Scan( + &p.ID, &p.Platform, &p.Gamertag, &p.ActivisionID, + &p.LastFetchedAt, &p.CreatedAt, &p.UpdatedAt, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (r *PlayerRepo) SaveStatsSnapshot(ctx context.Context, playerID, mode string, statsData any) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO player_stats (player_id, mode, stats_data) VALUES ($1, $2, $3) + `, playerID, mode, statsData) + return err +} + +func (r *PlayerRepo) GetLatestStats(ctx context.Context, playerID, mode string) (any, *time.Time, error) { + var statsData any + var fetchedAt time.Time + err := r.pool.QueryRow(ctx, ` + SELECT stats_data, fetched_at FROM player_stats + WHERE player_id = $1 AND mode = $2 + ORDER BY fetched_at DESC LIMIT 1 + `, playerID, mode).Scan(&statsData, &fetchedAt) + if err == pgx.ErrNoRows { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + return statsData, &fetchedAt, nil +} diff --git a/internal/repository/squad_repo.go b/internal/repository/squad_repo.go index c7c573f..330e9e2 100644 --- a/internal/repository/squad_repo.go +++ b/internal/repository/squad_repo.go @@ -1,3 +1,104 @@ package repository -// Squad data access layer will be implemented in issue #5. +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/grovecj/warzone-stats-tracker/internal/model" +) + +type SquadRepo struct { + pool *pgxpool.Pool +} + +func NewSquadRepo(pool *pgxpool.Pool) *SquadRepo { + return &SquadRepo{pool: pool} +} + +func (r *SquadRepo) Create(ctx context.Context, name string) (*model.Squad, error) { + var s model.Squad + err := r.pool.QueryRow(ctx, ` + INSERT INTO squads (name) VALUES ($1) + RETURNING id, name, created_at, updated_at + `, name).Scan(&s.ID, &s.Name, &s.CreatedAt, &s.UpdatedAt) + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *SquadRepo) GetByID(ctx context.Context, id string) (*model.Squad, error) { + var s model.Squad + err := r.pool.QueryRow(ctx, ` + SELECT id, name, created_at, updated_at FROM squads WHERE id = $1 + `, id).Scan(&s.ID, &s.Name, &s.CreatedAt, &s.UpdatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + rows, err := r.pool.Query(ctx, ` + SELECT p.id, p.platform, p.gamertag, p.activision_id, p.last_fetched_at, p.created_at, p.updated_at + FROM players p + JOIN squad_members sm ON sm.player_id = p.id + WHERE sm.squad_id = $1 + ORDER BY sm.added_at + `, id) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var p model.Player + if err := rows.Scan(&p.ID, &p.Platform, &p.Gamertag, &p.ActivisionID, + &p.LastFetchedAt, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + s.Members = append(s.Members, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return &s, nil +} + +func (r *SquadRepo) Update(ctx context.Context, id, name string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE squads SET name = $2, updated_at = NOW() WHERE id = $1 + `, id, name) + return err +} + +func (r *SquadRepo) Delete(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, `DELETE FROM squads WHERE id = $1`, id) + return err +} + +func (r *SquadRepo) AddMember(ctx context.Context, squadID, playerID string) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO squad_members (squad_id, player_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, squadID, playerID) + return err +} + +func (r *SquadRepo) RemoveMember(ctx context.Context, squadID, playerID string) error { + _, err := r.pool.Exec(ctx, ` + DELETE FROM squad_members WHERE squad_id = $1 AND player_id = $2 + `, squadID, playerID) + return err +} + +func (r *SquadRepo) MemberCount(ctx context.Context, squadID string) (int, error) { + var count int + err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM squad_members WHERE squad_id = $1 + `, squadID).Scan(&count) + return count, err +} diff --git a/internal/repository/stats_repo.go b/internal/repository/stats_repo.go index b144fbe..3a681ab 100644 --- a/internal/repository/stats_repo.go +++ b/internal/repository/stats_repo.go @@ -1,3 +1,3 @@ package repository -// Stats snapshot data access layer will be implemented in issue #5. +// Stats snapshot storage is handled by PlayerRepo.SaveStatsSnapshot and PlayerRepo.GetLatestStats. diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..ab7cf72 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,92 @@ +package router + +import ( + "io/fs" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" + + "github.com/grovecj/warzone-stats-tracker/internal/handler" + "github.com/grovecj/warzone-stats-tracker/internal/middleware" +) + +// Deps holds dependencies injected into the router. +type Deps struct { + AdminHandler *handler.AdminHandler + AdminAPIKey string +} + +func New(allowedOrigins []string, staticFS fs.FS, deps Deps) http.Handler { + r := chi.NewRouter() + + // Global middleware + r.Use(middleware.Recovery) + r.Use(middleware.RequestLogger) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"}, + ExposedHeaders: []string{"X-Request-ID", "X-Cache", "X-Data-Age"}, + AllowCredentials: false, + MaxAge: 300, + })) + r.Use(middleware.NewRateLimiter(100, 1*time.Minute).Handler) + + // API routes + r.Route("/api/v1", func(r chi.Router) { + r.Get("/health", handler.Health) + + // Player routes (issue #9) + r.Route("/players", func(r chi.Router) { + r.Get("/search", handler.NotImplemented) + r.Get("/{platform}/{gamertag}/stats", handler.NotImplemented) + r.Get("/{platform}/{gamertag}/matches", handler.NotImplemented) + }) + + // Comparison routes (issue #14) + r.Get("/compare", handler.NotImplemented) + + // Admin routes (protected by ADMIN_API_KEY) + r.Route("/admin", func(r chi.Router) { + r.Use(middleware.AdminAuth(deps.AdminAPIKey)) + if deps.AdminHandler != nil { + r.Post("/token", deps.AdminHandler.UpdateToken) + } + }) + + // Squad routes (issue #16) + r.Route("/squads", func(r chi.Router) { + r.Post("/", handler.NotImplemented) + r.Get("/{squadID}", handler.NotImplemented) + r.Put("/{squadID}", handler.NotImplemented) + r.Delete("/{squadID}", handler.NotImplemented) + r.Post("/{squadID}/members", handler.NotImplemented) + r.Delete("/{squadID}/members/{playerID}", handler.NotImplemented) + r.Get("/{squadID}/stats", handler.NotImplemented) + }) + }) + + // Serve static frontend files with SPA fallback + if staticFS != nil { + fileServer := http.FileServerFS(staticFS) + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + // Try to serve the file directly + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + + if _, err := fs.Stat(staticFS, path); err != nil { + // File doesn't exist — serve index.html for SPA routing + r.URL.Path = "/" + } + + fileServer.ServeHTTP(w, r) + }) + } + + return r +} diff --git a/internal/service/player.go b/internal/service/player.go index c1cb4dd..ee53c3d 100644 --- a/internal/service/player.go +++ b/internal/service/player.go @@ -1,3 +1,3 @@ package service -// Player business logic will be implemented in issues #9, #10. +// Player business logic will be implemented in issue #9. diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql new file mode 100644 index 0000000..efff67f --- /dev/null +++ b/migrations/000001_init.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS player_stats; +DROP TABLE IF EXISTS matches; +DROP TABLE IF EXISTS squad_members; +DROP TABLE IF EXISTS squads; +DROP TABLE IF EXISTS players; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql new file mode 100644 index 0000000..65f3659 --- /dev/null +++ b/migrations/000001_init.up.sql @@ -0,0 +1,56 @@ +-- gen_random_uuid() is built-in since PostgreSQL 13. For older versions, uncomment: +-- CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE players ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform VARCHAR(10) NOT NULL, + gamertag VARCHAR(100) NOT NULL, + activision_id VARCHAR(100), + last_fetched_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(platform, gamertag) +); + +CREATE TABLE squads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE squad_members ( + squad_id UUID NOT NULL REFERENCES squads(id) ON DELETE CASCADE, + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (squad_id, player_id) +); + +CREATE TABLE matches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + match_id VARCHAR(100) NOT NULL UNIQUE, + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + mode VARCHAR(50), + map_name VARCHAR(100), + placement INT, + kills INT, + deaths INT, + damage_dealt INT, + damage_taken INT, + gulag_result VARCHAR(10), + match_time TIMESTAMPTZ, + raw_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE player_stats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + mode VARCHAR(50) NOT NULL, + stats_data JSONB NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_matches_player_id ON matches(player_id); +CREATE INDEX idx_matches_match_time ON matches(match_time); +CREATE INDEX idx_player_stats_player_mode ON player_stats(player_id, mode); diff --git a/terraform/digitalocean/main.tf b/terraform/digitalocean/main.tf index 6b39230..68137cc 100644 --- a/terraform/digitalocean/main.tf +++ b/terraform/digitalocean/main.tf @@ -85,6 +85,12 @@ resource "digitalocean_app" "warzone_stats_tracker" { type = "SECRET" } + env { + key = "ADMIN_API_KEY" + value = var.admin_api_key + type = "SECRET" + } + env { key = "PORT" value = "8080" diff --git a/terraform/digitalocean/terraform.tfvars.example b/terraform/digitalocean/terraform.tfvars.example index 6633c98..cf42cef 100644 --- a/terraform/digitalocean/terraform.tfvars.example +++ b/terraform/digitalocean/terraform.tfvars.example @@ -22,8 +22,12 @@ postgres_cluster_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # Call of Duty SSO Token # Obtain from: Log into my.callofduty.com → DevTools → Application → Cookies → ACT_SSO_COOKIE -# Expires every ~14 days — update when it does +# Expires every ~14 days — refresh via POST /api/v1/admin/token or update here and redeploy cod_sso_token = "your-sso-token-here" +# Admin API Key — protects admin endpoints (token refresh, etc.) +# Generate: openssl rand -hex 32 +admin_api_key = "your-admin-api-key-here" + # Custom domain (optional — leave empty to use DO default domain) # domain_name = "warzone.yourdomain.com" diff --git a/terraform/digitalocean/variables.tf b/terraform/digitalocean/variables.tf index caa9b52..f1c7acd 100644 --- a/terraform/digitalocean/variables.tf +++ b/terraform/digitalocean/variables.tf @@ -65,6 +65,12 @@ variable "cod_sso_token" { sensitive = true } +variable "admin_api_key" { + description = "API key for protecting admin endpoints (e.g., token refresh)" + type = string + sensitive = true +} + # ----------------------------------------------------------------------------- # Domain (optional) # ----------------------------------------------------------------------------- diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..3821f48 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +//go:build !embed_dist + +package web + +import "embed" + +// DistFS is empty when the frontend hasn't been built. +// The main.go code gracefully handles this (no static files served). +var DistFS embed.FS diff --git a/web/embed_prod.go b/web/embed_prod.go new file mode 100644 index 0000000..d083e59 --- /dev/null +++ b/web/embed_prod.go @@ -0,0 +1,8 @@ +//go:build embed_dist + +package web + +import "embed" + +//go:embed all:dist +var DistFS embed.FS diff --git a/web/src/App.vue b/web/src/App.vue index 7905b05..d66874f 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,85 +1,106 @@ diff --git a/web/src/assets/base.css b/web/src/assets/base.css deleted file mode 100644 index 8816868..0000000 --- a/web/src/assets/base.css +++ /dev/null @@ -1,86 +0,0 @@ -/* color palette from */ -:root { - --vt-c-white: #ffffff; - --vt-c-white-soft: #f8f8f8; - --vt-c-white-mute: #f2f2f2; - - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; - - --vt-c-indigo: #2c3e50; - - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); - - --vt-c-text-light-1: var(--vt-c-indigo); - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); -} - -/* semantic color variables for this project */ -:root { - --color-background: var(--vt-c-white); - --color-background-soft: var(--vt-c-white-soft); - --color-background-mute: var(--vt-c-white-mute); - - --color-border: var(--vt-c-divider-light-2); - --color-border-hover: var(--vt-c-divider-light-1); - - --color-heading: var(--vt-c-text-light-1); - --color-text: var(--vt-c-text-light-1); - - --section-gap: 160px; -} - -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - font-weight: normal; -} - -body { - min-height: 100vh; - color: var(--color-text); - background: var(--color-background); - transition: - color 0.5s, - background-color 0.5s; - line-height: 1.6; - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - Roboto, - Oxygen, - Ubuntu, - Cantarell, - 'Fira Sans', - 'Droid Sans', - 'Helvetica Neue', - sans-serif; - font-size: 15px; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/web/src/assets/logo.svg b/web/src/assets/logo.svg deleted file mode 100644 index 7565660..0000000 --- a/web/src/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 36fb845..49001d0 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -1,35 +1,49 @@ -@import './base.css'; +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; +} -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - font-weight: normal; +body { + background: #0a0a0f; + color: #e0e0e0; + font-family: + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -a, -.green { - text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; - padding: 3px; +/* Stat color utilities */ +.stat-positive { + color: #4caf50; } -@media (hover: hover) { - a:hover { - background-color: hsla(160, 100%, 37%, 0.2); - } +.stat-negative { + color: #f44336; } -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } +.stat-neutral { + color: #ff9800; +} + +.stat-highlight { + color: #00e5ff; +} - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; - } +/* Win highlight */ +.match-win { + background: rgba(0, 229, 255, 0.05); + border-left: 3px solid #00e5ff; } diff --git a/web/src/components/HelloWorld.vue b/web/src/components/HelloWorld.vue deleted file mode 100644 index d174cf8..0000000 --- a/web/src/components/HelloWorld.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/web/src/components/TheWelcome.vue b/web/src/components/TheWelcome.vue deleted file mode 100644 index 8b731d9..0000000 --- a/web/src/components/TheWelcome.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/web/src/components/WelcomeItem.vue b/web/src/components/WelcomeItem.vue deleted file mode 100644 index 6d7086a..0000000 --- a/web/src/components/WelcomeItem.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/web/src/components/__tests__/HelloWorld.spec.ts b/web/src/components/__tests__/HelloWorld.spec.ts deleted file mode 100644 index 2533202..0000000 --- a/web/src/components/__tests__/HelloWorld.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { mount } from '@vue/test-utils' -import HelloWorld from '../HelloWorld.vue' - -describe('HelloWorld', () => { - it('renders properly', () => { - const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }) - expect(wrapper.text()).toContain('Hello Vitest') - }) -}) diff --git a/web/src/components/icons/IconCommunity.vue b/web/src/components/icons/IconCommunity.vue deleted file mode 100644 index 2dc8b05..0000000 --- a/web/src/components/icons/IconCommunity.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconDocumentation.vue b/web/src/components/icons/IconDocumentation.vue deleted file mode 100644 index 6d4791c..0000000 --- a/web/src/components/icons/IconDocumentation.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconEcosystem.vue b/web/src/components/icons/IconEcosystem.vue deleted file mode 100644 index c3a4f07..0000000 --- a/web/src/components/icons/IconEcosystem.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconSupport.vue b/web/src/components/icons/IconSupport.vue deleted file mode 100644 index 7452834..0000000 --- a/web/src/components/icons/IconSupport.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconTooling.vue b/web/src/components/icons/IconTooling.vue deleted file mode 100644 index 660598d..0000000 --- a/web/src/components/icons/IconTooling.vue +++ /dev/null @@ -1,19 +0,0 @@ - - diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 3e49915..77f6eca 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -10,12 +10,24 @@ const router = createRouter({ component: HomeView, }, { - path: '/about', - name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('../views/AboutView.vue'), + path: '/player/:platform/:gamertag', + name: 'player', + component: () => import('../views/PlayerView.vue'), + }, + { + path: '/compare', + name: 'compare', + component: () => import('../views/CompareView.vue'), + }, + { + path: '/squads', + name: 'squads', + component: () => import('../views/SquadsView.vue'), + }, + { + path: '/squad/:id', + name: 'squad', + component: () => import('../views/SquadView.vue'), }, ], }) diff --git a/web/src/stores/counter.ts b/web/src/stores/counter.ts deleted file mode 100644 index b6757ba..0000000 --- a/web/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue' -import { defineStore } from 'pinia' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - const doubleCount = computed(() => count.value * 2) - function increment() { - count.value++ - } - - return { count, doubleCount, increment } -}) diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts new file mode 100644 index 0000000..50701fb --- /dev/null +++ b/web/src/stores/index.ts @@ -0,0 +1,85 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import type { PlayerStats, Match } from '@/types' + +export const usePlayerStore = defineStore('player', () => { + const loading = ref(false) + const error = ref(null) + const stats = ref(null) + const matches = ref([]) + + async function fetchStats(platform: string, gamertag: string, mode = 'wz') { + loading.value = true + error.value = null + try { + const tag = encodeURIComponent(gamertag) + const res = await fetch(`/api/v1/players/${platform}/${tag}/stats?mode=${encodeURIComponent(mode)}`) + if (!res.ok) { + const text = await res.text() + let message = 'Failed to fetch stats' + try { + const body = JSON.parse(text) + message = body.message || message + } catch { + // Response was not JSON + } + throw new Error(message) + } + stats.value = await res.json() + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'An unexpected error occurred' + } finally { + loading.value = false + } + } + + async function fetchMatches(platform: string, gamertag: string) { + loading.value = true + error.value = null + try { + const tag = encodeURIComponent(gamertag) + const res = await fetch(`/api/v1/players/${platform}/${tag}/matches`) + if (!res.ok) { + const text = await res.text() + let message = 'Failed to fetch matches' + try { + const body = JSON.parse(text) + message = body.message || message + } catch { + // Response was not JSON + } + throw new Error(message) + } + matches.value = await res.json() + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'An unexpected error occurred' + } finally { + loading.value = false + } + } + + return { loading, error, stats, matches, fetchStats, fetchMatches } +}) + +export const useSquadStore = defineStore('squad', () => { + const loading = ref(false) + const error = ref(null) + + return { loading, error } +}) + +export const useCompareStore = defineStore('compare', () => { + const players = ref<{ platform: string; gamertag: string }[]>([]) + + function addPlayer(platform: string, gamertag: string) { + if (players.value.length < 4) { + players.value.push({ platform, gamertag }) + } + } + + function removePlayer(index: number) { + players.value.splice(index, 1) + } + + return { players, addPlayer, removePlayer } +}) diff --git a/web/src/types/index.ts b/web/src/types/index.ts new file mode 100644 index 0000000..74f8349 --- /dev/null +++ b/web/src/types/index.ts @@ -0,0 +1,50 @@ +export interface PlayerStats { + platform: string + gamertag: string + level: number + prestige: number + kills: number + deaths: number + kdRatio: number + wins: number + losses: number + winPct: number + scorePerMin: number + headshots: number + timePlayed: number + matchesPlayed: number + topFive: number + topTen: number + topTwentyFive: number + assists: number + damageDone: number +} + +export interface Match { + matchID: string + mode: string + map: string + placement: number + kills: number + deaths: number + kdRatio: number + damageDealt: number + damageTaken: number + gulagResult: string + duration: number + matchTime: string +} + +export interface Squad { + id: string + name: string + members: SquadMember[] + createdAt: string + updatedAt: string +} + +export interface SquadMember { + id: string + platform: string + gamertag: string +} diff --git a/web/src/views/AboutView.vue b/web/src/views/AboutView.vue deleted file mode 100644 index 756ad2a..0000000 --- a/web/src/views/AboutView.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/web/src/views/CompareView.vue b/web/src/views/CompareView.vue new file mode 100644 index 0000000..b9937d8 --- /dev/null +++ b/web/src/views/CompareView.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index d5c0217..f2ea46f 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -1,9 +1,91 @@ + + diff --git a/web/src/views/PlayerView.vue b/web/src/views/PlayerView.vue new file mode 100644 index 0000000..21bb9ec --- /dev/null +++ b/web/src/views/PlayerView.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/web/src/views/SquadView.vue b/web/src/views/SquadView.vue new file mode 100644 index 0000000..048eec7 --- /dev/null +++ b/web/src/views/SquadView.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/web/src/views/SquadsView.vue b/web/src/views/SquadsView.vue new file mode 100644 index 0000000..0aa9ae7 --- /dev/null +++ b/web/src/views/SquadsView.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/web/vitest.config.ts b/web/vitest.config.ts index c328717..7849a69 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -9,6 +9,7 @@ export default mergeConfig( environment: 'jsdom', exclude: [...configDefaults.exclude, 'e2e/**'], root: fileURLToPath(new URL('./', import.meta.url)), + passWithNoTests: true, }, }), )