diff --git a/cmd/server/main.go b/cmd/server/main.go index 86fef68..9e4864f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "log" + "log/slog" "net/http" "os" @@ -13,6 +14,24 @@ import ( func main() { config.LoadEnv(".env") + cfg := config.LoadConfig() + + database.Init() + defer database.Close() + + // Gamer Activity Handler + ah := &handlers.Handler{ + Config: cfg, + DB: database.DB, + } + + // Logger + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }), + ) + slog.SetDefault(logger) // Initialize database connection database.Init() @@ -24,6 +43,13 @@ func main() { mux.HandleFunc("/health", handlers.HealthCheck) mux.HandleFunc("/db/ping", handlers.DatabasePing) mux.HandleFunc("/admin/generate-key", handlers.GenerateAPIKey) + mux.HandleFunc("/activity/{student_number}", handlers.Wrap(ah.GetGamerActivityByStudent)) + mux.HandleFunc("/activity", handlers.Wrap(ah.AddGamerActivity)) + mux.HandleFunc("/activity/today/{student_number}", handlers.Wrap(ah.GetGamerActivityByTierOneStudentToday)) + mux.HandleFunc("/activity/all/recent", handlers.Wrap(ah.GetGamerActivity)) + mux.HandleFunc("/activity/update/{student_number}", handlers.Wrap(ah.UpdateGamerActivity)) + mux.HandleFunc("/activity/get-active-pcs", handlers.Wrap(ah.GetAllActivePCs)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Echo Base API is running!")) }) diff --git a/config/config.go b/config/config.go index c30eb48..8abb20e 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "bufio" "os" "strings" + "time" ) func LoadEnv(path string) error { @@ -20,12 +21,12 @@ func LoadEnv(path string) error { continue } tokens := strings.SplitN(line, "=", 2) - if len(tokens) == 2{ + if len(tokens) == 2 { key := strings.TrimSpace(tokens[0]) value := strings.TrimSpace(tokens[1]) if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) || - (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { + (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { value = value[1 : len(value)-1] } @@ -33,4 +34,20 @@ func LoadEnv(path string) error { } } return nil -} \ No newline at end of file +} + +type Config struct { + Location *time.Location +} + +func LoadConfig() *Config { + // Load timezone once + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + panic("failed to load timezone: " + err.Error()) + } + + return &Config{ + Location: loc, + } +} diff --git a/go.mod b/go.mod index eff4f30..9c4146e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,13 @@ module github.com/ubcesports/echo-base go 1.24 -require github.com/lib/pq v1.10.9 +require ( + github.com/georgysavva/scany/v2 v2.1.4 + github.com/google/uuid v1.3.0 + github.com/jackc/pgx/v5 v5.5.4 + github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.11.1 +) require ( github.com/Masterminds/goutils v1.1.1 // indirect @@ -10,6 +16,7 @@ require ( github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/denisenkom/go-mssqldb v0.9.0 // indirect github.com/fatih/color v1.13.0 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect @@ -17,12 +24,14 @@ require ( github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/godror/godror v0.40.4 // indirect github.com/godror/knownpb v0.1.1 // indirect - github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.13 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-oci8 v0.1.1 // indirect @@ -32,15 +41,19 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) tool ( diff --git a/go.sum b/go.sum index 6c5a37c..c43e873 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,6 +25,8 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -35,10 +39,11 @@ github.com/godror/godror v0.40.4 h1:X1e7hUd02GDaLWKZj40Z7L0CP0W9TrGgmPQZw6+anBg= github.com/godror/godror v0.40.4/go.mod h1:i8YtVTHUJKfFT3wTat4A9UoqScUtZXiYB9Rf3SVARgc= github.com/godror/knownpb v0.1.1 h1:A4J7jdx7jWBhJm18NntafzSC//iZDHkDi1+juwQ5pTI= github.com/godror/knownpb v0.1.1/go.mod h1:4nRFbQo1dDuwKnblRXDxrfCFYeT4hjg3GjMqef58eRE= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -59,6 +64,14 @@ github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -93,6 +106,8 @@ github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -111,12 +126,16 @@ github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +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= 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-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -135,8 +154,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -159,6 +179,8 @@ 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.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 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= diff --git a/internal/database/db.go b/internal/database/db.go index 52ffcb6..6f4eb9a 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,29 +1,39 @@ package database import ( - "database/sql" + "context" "fmt" "log" "os" + "time" + "github.com/jackc/pgx/v5/pgxpool" _ "github.com/lib/pq" ) -var DB *sql.DB +var DB *pgxpool.Pool func Init() { + dsn := os.Getenv("EB_DSN") + if dsn == "" { + log.Fatal("EB_DSN environment variable not set") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var err error - DB, err = sql.Open("postgres", os.Getenv("EB_DSN")) + DB, err = pgxpool.New(ctx, dsn) if err != nil { - log.Fatal("DB open error:", err) + log.Fatal("Failed to connect to database:", err) } // Test the connection - if err = DB.Ping(); err != nil { - log.Fatal("DB ping error:", err) + if err = DB.Ping(ctx); err != nil { + log.Fatal("Database ping failed:", err) } - log.Println("Connected to database") + log.Println("Connected to database (pgxpool)") } // Ping checks if the database connection is still alive @@ -31,13 +41,13 @@ func Ping() error { if DB == nil { return fmt.Errorf("database connection not initialized") } - return DB.Ping() + return DB.Ping(context.Background()) } // Close closes the database connection func Close() error { if DB != nil { - return DB.Close() + DB.Close() } return nil } diff --git a/internal/handlers/activity.go b/internal/handlers/activity.go index 28ae6f5..86a9d59 100644 --- a/internal/handlers/activity.go +++ b/internal/handlers/activity.go @@ -1 +1,152 @@ -package handlers \ No newline at end of file +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ubcesports/echo-base/config" + "github.com/ubcesports/echo-base/internal/models" + "github.com/ubcesports/echo-base/internal/repositories" +) + +type Handler struct { + Config *config.Config + DB *pgxpool.Pool +} + +func (h *Handler) GetGamerActivityByStudent(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + studentNumber := r.PathValue("student_number") + + var activities []models.GamerActivity + query := repositories.BuildGamerActivityByStudentQuery() + + if err := pgxscan.Select(ctx, h.DB, &activities, query, studentNumber); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) + } + + if len(activities) == 0 { + return NewHTTPError(http.StatusNotFound, "student not found") + } + + return WriteJSON(w, http.StatusOK, activities) +} + +func (h *Handler) GetGamerActivityByTierOneStudentToday(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + studentNumber := r.PathValue("student_number") + now := time.Now().In(h.Config.Location) + + var activities []models.GamerActivity + query := repositories.BuildGamerActivityByTierOneStudentTodayQuery() + + if err := pgxscan.Select(ctx, h.DB, &activities, query, studentNumber, now); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) + } + + return WriteJSON(w, http.StatusOK, activities) +} + +func (h *Handler) GetGamerActivity(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + // Pagination parameters + page := QueryStringToInt(r, "page", 1) + limit := QueryStringToInt(r, "limit", 10) + search := r.URL.Query().Get("search") + offset := (page - 1) * limit + + var activities []models.GamerActivityWithName + query, args := repositories.BuildGamerActivityRecentQuery(limit, offset, search) + + if err := pgxscan.Select(ctx, h.DB, &activities, query, args...); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) + } + + return WriteJSON(w, http.StatusOK, activities) +} + +func (h *Handler) AddGamerActivity(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + started_at := time.Now().In(h.Config.Location) + query := repositories.BuildInsertGamerActivityQuery() + + if !RequireJSONContentType(r) { + return NewHTTPError(http.StatusBadRequest, "invalid content type") + } + + var ga models.GamerActivityWithName + if err := DecodeJSONBody(r, &ga); err != nil { + return NewHTTPError(http.StatusBadRequest, "invalid request body", err) + } + + var m models.MembershipResult + checkMemberQuery := repositories.BuildCheckMemberQuery() + if err := pgxscan.Get(ctx, h.DB, &m, checkMemberQuery, ga.StudentNumber); err != nil { + msg := fmt.Sprintf("foreign key %s not found", ga.StudentNumber) + return NewHTTPError(http.StatusNotFound, msg, err) + } + + today := time.Now().In(h.Config.Location).Truncate(24 * time.Hour) + expiryDate := m.MembershipExpiryDate.In(h.Config.Location).Truncate(24 * time.Hour) + + if today.After(expiryDate) { + msg := fmt.Sprintf( + "Membership expired on %s. Please ask the user to purchase a new membership. "+ + "If the member has already purchased a new membership for this year please verify via Showpass then create a new profile for them.", + expiryDate.Format("2006-01-02"), + ) + return NewHTTPError(http.StatusForbidden, msg) + } + + var gamerActivities []models.GamerActivity + if err := pgxscan.Select(ctx, h.DB, &gamerActivities, query, ga.StudentNumber, ga.PCNumber, ga.Game, started_at); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) + } + + return WriteJSON(w, http.StatusCreated, gamerActivities[0]) +} + +func (h *Handler) UpdateGamerActivity(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + ended_at := time.Now().In(h.Config.Location) + + studentNumber := r.PathValue("student_number") + + if !RequireJSONContentType(r) { + return NewHTTPError(http.StatusBadRequest, "invalid content type") + } + + var rb models.UpdateGamerActivityRequest + if err := DecodeJSONBody(r, &rb); err != nil { + return NewHTTPError(http.StatusBadRequest, "invalid request body", err) + } + + var activities []models.GamerActivity + query := repositories.BuildUpdateGamerActivityQuery() + if err := pgxscan.Select(ctx, h.DB, &activities, query, ended_at, studentNumber, rb.PCNumber, rb.ExecName); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) + } + + if len(activities) == 0 { + return NewHTTPError(http.StatusNotFound, "student not active") + } + + return WriteJSON(w, http.StatusCreated, activities[0]) +} + +func (h *Handler) GetAllActivePCs(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + query := repositories.BuildGetAllActivePCsQuery() + var activePCs []models.ActivePC + + if err := pgxscan.Select(ctx, h.DB, &activePCs, query); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) + } + + return WriteJSON(w, http.StatusOK, activePCs) +} diff --git a/internal/handlers/database_test.go b/internal/handlers/database_test.go index de6302e..c69b0ca 100644 --- a/internal/handlers/database_test.go +++ b/internal/handlers/database_test.go @@ -2,28 +2,20 @@ package handlers import ( "net/http" - "net/http/httptest" "testing" + "github.com/stretchr/testify/require" "github.com/ubcesports/echo-base/internal/tests" ) func TestDatabasePing(t *testing.T) { - tests.SetupTestDB(t) - - req := tests.CreateTestRequest(t, "GET", "/db/ping", nil) - rr := httptest.NewRecorder() + tests.SetupTestDBForTest(t) handler := http.HandlerFunc(DatabasePing) - handler.ServeHTTP(rr, req) + + rr := tests.ExecuteTestRequest(t, handler, "GET", "/db/ping", nil) var response DatabasePingResponse tests.AssertResponse(t, rr, http.StatusOK, &response) - - if response.Status != "ok" { - t.Errorf("Expected status 'ok', got '%s'", response.Status) - } - - if response.ResponseTime == "" { - t.Error("Response time should not be empty") - } + require.Equal(t, "ok", response.Status, "Expected status 'ok'") + require.NotEmpty(t, response.ResponseTime, "Response time should not be empty") } diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go new file mode 100644 index 0000000..f1a5123 --- /dev/null +++ b/internal/handlers/errors.go @@ -0,0 +1,30 @@ +package handlers + +import "fmt" + +type HTTPError struct { + Status int + Message string + Err error +} + +func (e *HTTPError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Err) + } + return e.Message +} + +// Helper constructor +func NewHTTPError(status int, message string, err ...error) *HTTPError { + var internal error + if len(err) > 0 { + internal = err[0] + } + + return &HTTPError{ + Status: status, + Message: message, + Err: internal, + } +} diff --git a/internal/handlers/health_test.go b/internal/handlers/health_test.go index 754c183..10a4a7c 100644 --- a/internal/handlers/health_test.go +++ b/internal/handlers/health_test.go @@ -2,28 +2,22 @@ package handlers import ( "net/http" - "net/http/httptest" "testing" + "github.com/stretchr/testify/require" + "github.com/ubcesports/echo-base/internal/tests" ) func TestHealthCheck(t *testing.T) { - tests.SetupTestDB(t) - - req := tests.CreateTestRequest(t, "GET", "/health", nil) - rr := httptest.NewRecorder() + tests.SetupTestDBForTest(t) handler := http.HandlerFunc(HealthCheck) - handler.ServeHTTP(rr, req) + + rr := tests.ExecuteTestRequest(t, handler, http.MethodGet, "/health", nil) var response HealthResponse tests.AssertResponse(t, rr, http.StatusOK, &response) - if response.Status != "ok" { - t.Errorf("Expected status 'ok', got '%s'", response.Status) - } - - if response.Database != "ok" { - t.Errorf("Expected database status 'ok', got '%s'", response.Database) - } + require.Equal(t, "ok", response.Status) + require.Equal(t, "ok", response.Database) } diff --git a/internal/handlers/keygeneration.go b/internal/handlers/keygeneration.go index 255c431..ff6fa9a 100644 --- a/internal/handlers/keygeneration.go +++ b/internal/handlers/keygeneration.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "crypto/rand" "crypto/sha256" "encoding/base32" @@ -101,7 +102,7 @@ func storeAPIKey(appName string, keyID string, hashedSecret []byte) error { INSERT INTO application (app_name, key_id, hashed_key) VALUES ($1, $2, $3) ` - _, err := database.DB.Exec(query, appName, keyID, hashedSecret) + _, err := database.DB.Exec(context.Background(), query, appName, keyID, hashedSecret) if err != nil { return fmt.Errorf("database storage failed: %s", err.Error()) } diff --git a/internal/handlers/keygeneration_test.go b/internal/handlers/keygeneration_test.go index 66ddf4f..c7689f1 100644 --- a/internal/handlers/keygeneration_test.go +++ b/internal/handlers/keygeneration_test.go @@ -11,7 +11,7 @@ import ( ) func TestGenerateAPIKey(t *testing.T) { - tests.SetupTestDB(t) + tests.SetupTestDBForTest(t) testCases := []struct { name string @@ -84,3 +84,35 @@ func TestGenerateAPIKey(t *testing.T) { }) } } + +/* +for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var response GenerateKeyResponse + + if tc.rawBody != "" { + req, _ := http.NewRequest(tc.method, "/admin/generate-key", strings.NewReader(tc.rawBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler := http.HandlerFunc(GenerateAPIKey) + handler.ServeHTTP(rr, req) + tests.AssertResponse(t, rr, tc.expectedStatus, nil) + } else { + tests.ExecuteTestRequest(t, http.HandlerFunc(GenerateAPIKey), tc.method, "/admin/generate-key", tc.body, tc.expectedStatus, &response) + } + + if tc.expectKey { + if response.KeyID == "" { + t.Error("KeyID should not be empty") + } + if !strings.HasPrefix(response.APIKey, "api_") { + t.Error("API key should start with 'api_'") + } + if response.AppName != "test-app" { + t.Errorf("Expected app name 'test-app', got '%s'", response.AppName) + } + } + }) + } +} +*/ diff --git a/internal/handlers/utils.go b/internal/handlers/utils.go new file mode 100644 index 0000000..bd2cf81 --- /dev/null +++ b/internal/handlers/utils.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +func WriteJSON(w http.ResponseWriter, status int, data any) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(data) +} + +// Ensures the request has Content-Type application/json +func RequireJSONContentType(r *http.Request) bool { + ct := r.Header.Get("Content-Type") + if ct == "" { + return true + } + + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) + return mediaType == "application/json" +} + +// Decodes JSON body into the provided struct +func DecodeJSONBody(r *http.Request, dst interface{}) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + if dec.More() { + return fmt.Errorf("body must contain only one JSON object") + } + return nil +} + +// Converts string query param to integer with default value +func QueryStringToInt(r *http.Request, key string, defaultVal int) int { + s := r.URL.Query().Get(key) + if s == "" { + return defaultVal + } + i, err := strconv.Atoi(s) + if err != nil { + return defaultVal + } + return i +} diff --git a/internal/handlers/wrapper.go b/internal/handlers/wrapper.go new file mode 100644 index 0000000..c09b3db --- /dev/null +++ b/internal/handlers/wrapper.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" +) + +type HTTPHandlerWithErr func(http.ResponseWriter, *http.Request) error + +func Wrap(h HTTPHandlerWithErr) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := h(w, r); err != nil { + var httpErr *HTTPError + if errors.As(err, &httpErr) { + http.Error(w, httpErr.Message, httpErr.Status) + slog.Debug("HTTP error occurred", "status", httpErr.Status, "message", httpErr.Message, "err", err) + } else { + // If stuff went really wrong + http.Error(w, "internal server error", http.StatusInternalServerError) + slog.Error("Internal server error", "err", err) + } + } + } +} diff --git a/internal/handlers_test/gamer_activity_test.go b/internal/handlers_test/gamer_activity_test.go new file mode 100644 index 0000000..e94209a --- /dev/null +++ b/internal/handlers_test/gamer_activity_test.go @@ -0,0 +1,305 @@ +package handlers_test + +import ( + "context" + "log/slog" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ubcesports/echo-base/config" + "github.com/ubcesports/echo-base/internal/database" + "github.com/ubcesports/echo-base/internal/handlers" + "github.com/ubcesports/echo-base/internal/models" + "github.com/ubcesports/echo-base/internal/tests" +) + +var testRouter http.Handler + +var ( + student1 = "11223344" + student2 = "87654321" + student3 = "63347439" + + payloadStudent1 = map[string]interface{}{ + "student_number": student1, + "pc_number": 1, + "game": "Valorant", + } + payloadStudent2 = map[string]interface{}{ + "student_number": student2, + "pc_number": 2, + "game": "Valorant", + } + payloadStudent3 = map[string]interface{}{ + "student_number": student3, + "pc_number": 3, + "game": "CS:GO", + } + badPayload = map[string]interface{}{ + "student_number": "09090909", + "pc_number": 1, + "game": "Valorant", + } +) + +func TestMain(m *testing.M) { + tests.SetupTestDB() + slog.SetDefault(slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}), + )) + testConfig := config.LoadConfig() + testHandler := &handlers.Handler{DB: database.DB, Config: testConfig} + mux := http.NewServeMux() + + mux.HandleFunc("/activity/{student_number}", handlers.Wrap(testHandler.GetGamerActivityByStudent)) + mux.HandleFunc("/activity/today/{student_number}", handlers.Wrap(testHandler.GetGamerActivityByTierOneStudentToday)) + mux.HandleFunc("/activity/all/recent", handlers.Wrap(testHandler.GetGamerActivity)) + mux.HandleFunc("/activity", handlers.Wrap(testHandler.AddGamerActivity)) + mux.HandleFunc("/activity/update/{student_number}", handlers.Wrap(testHandler.UpdateGamerActivity)) + mux.HandleFunc("/activity/get-active-pcs", handlers.Wrap(testHandler.GetAllActivePCs)) + + testRouter = mux + + exitCode := m.Run() + os.Exit(exitCode) +} + +func BeforeEach(t *testing.T) { + ctx := context.Background() + + _, err := database.DB.Exec(ctx, "TRUNCATE TABLE gamer_activity;") + require.NoError(t, err) + + _, err = database.DB.Exec(ctx, "TRUNCATE TABLE gamer_profile CASCADE;") + require.NoError(t, err) + + _, err = database.DB.Exec(ctx, ` + INSERT INTO gamer_profile (first_name, last_name, student_number, membership_expiry_date, membership_tier) + VALUES + ('John','Doe','11223344','2030-09-18',1), + ('Jane','Doe','87654321','2030-09-18',2), + ('Jeffrey','Doe','63347439','2020-09-18',1); + `) + require.NoError(t, err) +} + +func AfterEach(t *testing.T) { + ctx := context.Background() + _, err := database.DB.Exec(ctx, "TRUNCATE TABLE gamer_activity;") + require.NoError(t, err) +} + +func postActivity(t *testing.T, payload map[string]interface{}, out interface{}) { + rr := tests.ExecuteTestRequest(t, testRouter, http.MethodPost, "/activity", payload) + tests.AssertResponse(t, rr, http.StatusCreated, out) +} + +func updateActivity(t *testing.T, student string, payload map[string]interface{}, out interface{}, expectedStatus int) { + url := "/activity/update/" + student + rr := tests.ExecuteTestRequest(t, testRouter, http.MethodPost, url, payload) + tests.AssertResponse(t, rr, expectedStatus, out) +} + +func getJSON(t *testing.T, url string, out interface{}) { + rr := tests.ExecuteTestRequest(t, testRouter, http.MethodGet, url, nil) + tests.AssertResponse(t, rr, http.StatusOK, out) +} + +func TestAddGamerActivity(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + + t.Run("it should add an activity", func(t *testing.T) { + var activity models.GamerActivityWithName + postActivity(t, payloadStudent1, &activity) + + require.Equal(t, student1, activity.StudentNumber) + require.Equal(t, 1, activity.PCNumber) + require.Equal(t, "Valorant", activity.Game) + require.Nil(t, activity.ExecName) + }) + + t.Run("it should return 404 if FK user does not exist", func(t *testing.T) { + rr := tests.ExecuteTestRequest(t, testRouter, http.MethodPost, "/activity", badPayload) + tests.AssertResponse(t, rr, http.StatusNotFound, nil) + require.Equal(t, "foreign key 09090909 not found\n", rr.Body.String()) + }) + + t.Run("it should complain for adding expired user", func(t *testing.T) { + rr := tests.ExecuteTestRequest(t, testRouter, http.MethodPost, "/activity", payloadStudent3) + tests.AssertResponse(t, rr, http.StatusForbidden, nil) + require.Equal(t, + "Membership expired on 2020-09-17. Please ask the user to purchase a new membership. If the member has already purchased a new membership for this year please verify via Showpass then create a new profile for them.\n", + rr.Body.String()) + }) +} + +func TestUpdateGamerActivity(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + + t.Run("should patch an existing activity", func(t *testing.T) { + var activity models.GamerActivityWithName + postActivity(t, payloadStudent1, &activity) + require.Nil(t, activity.EndedAt) + + updatePayload := map[string]interface{}{"pc_number": 1, "exec_name": "John"} + var updated models.GamerActivity + updateActivity(t, student1, updatePayload, &updated, http.StatusCreated) + + require.Equal(t, student1, updated.StudentNumber) + require.Equal(t, "Valorant", updated.Game) + require.Equal(t, "John", *updated.ExecName) + require.NotNil(t, updated.EndedAt) + }) + + t.Run("should return 404 if student does not have active activity", func(t *testing.T) { + updatePayload := map[string]interface{}{"pc_number": 1, "exec_name": "John"} + updateActivity(t, student1, updatePayload, nil, http.StatusNotFound) + }) +} + +func TestGetGamerActivity(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + t.Run("should get no data", func(t *testing.T) { + var activities []models.GamerActivityWithName + getJSON(t, "/activity/all/recent?page=1&limit=2&search=Valorant", &activities) + require.Len(t, activities, 0) + }) + + t.Run("should get a gamer activities", func(t *testing.T) { + postActivity(t, payloadStudent1, nil) + postActivity(t, payloadStudent2, nil) + + var activities []models.GamerActivityWithName + getJSON(t, "/activity/all/recent?page=1&limit=2&search=Valorant", &activities) + + require.LessOrEqual(t, len(activities), 2) + for _, a := range activities { + require.Contains(t, a.Game, "Valorant") + } + }) + + t.Run("should get no data from invalid params", func(t *testing.T) { + var activities []models.GamerActivityWithName + getJSON(t, "/activity/all/recent?page=1&limit=2&search=ClashRoyale", &activities) + require.Len(t, activities, 0) + }) +} + +func TestGetGamerActivityByStudent(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + + t.Run("should get gamer activites for specific student", func(t *testing.T) { + postActivity(t, payloadStudent1, nil) + postActivity(t, payloadStudent2, nil) + + var activities []models.GamerActivity + getJSON(t, "/activity/"+student2, &activities) + + for _, a := range activities { + require.Equal(t, student2, a.StudentNumber) + } + }) +} +func TestGetGamerActivityByTierOneStudentToday(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + + t.Run("tier 1 should see no activity before check-in, and one after", func(t *testing.T) { + var activities []models.GamerActivityWithName + getJSON(t, "/activity/today/"+student1, &activities) + require.Len(t, activities, 0) + + payload := map[string]interface{}{"student_number": student1, "pc_number": 2, "game": "Valorant"} + postActivity(t, payload, nil) + + updatePayload := map[string]interface{}{"pc_number": 2, "exec_name": "John"} + updateActivity(t, student1, updatePayload, nil, http.StatusCreated) + + var updated []models.GamerActivityWithName + getJSON(t, "/activity/today/"+student1, &updated) + + require.Len(t, updated, 1) + require.Equal(t, student1, updated[0].StudentNumber) + require.Equal(t, "John", *updated[0].ExecName) + require.NotNil(t, updated[0].EndedAt) + }) +} + +func TestGetGamerActivityByTierTwoStudentToday(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + + t.Run("tier 2 should see no activity from today", func(t *testing.T) { + var activities []models.GamerActivityWithName + getJSON(t, "/activity/today/"+student2, &activities) + require.Len(t, activities, 0) + + postActivity(t, payloadStudent2, nil) + + updatePayload := map[string]interface{}{"pc_number": 2, "exec_name": "John"} + updateActivity(t, student2, updatePayload, nil, http.StatusCreated) + + var updated []models.GamerActivityWithName + getJSON(t, "/activity/today/"+student2, &updated) + require.Len(t, updated, 0) + }) +} + +func TestTierTwoMultipleCheckIns(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + + t.Run("tier 2 can check in twice", func(t *testing.T) { + var first models.GamerActivity + postActivity(t, payloadStudent2, &first) + require.Equal(t, student2, first.StudentNumber) + require.Equal(t, 2, first.PCNumber) + require.Equal(t, "Valorant", first.Game) + + updatePayload := map[string]interface{}{"pc_number": 2, "exec_name": "Jane"} + var updated models.GamerActivity + updateActivity(t, student2, updatePayload, &updated, http.StatusCreated) + require.NotNil(t, updated.EndedAt) + require.NotNil(t, updated.ExecName) + require.Equal(t, "Jane", *updated.ExecName) + + payload2 := map[string]interface{}{"student_number": student2, "pc_number": 1, "game": "CS:GO"} + var second models.GamerActivity + postActivity(t, payload2, &second) + require.Equal(t, student2, second.StudentNumber) + require.Equal(t, 1, second.PCNumber) + require.Equal(t, "CS:GO", second.Game) + }) +} + +func TestGetAllActivePCs(t *testing.T) { + BeforeEach(t) + t.Cleanup(func() { AfterEach(t) }) + + t.Run("returns all active PCs", func(t *testing.T) { + postActivity(t, payloadStudent1, nil) + + var activePCs []models.ActivePC + getJSON(t, "/activity/get-active-pcs", &activePCs) + require.Len(t, activePCs, 1) + require.Equal(t, 1, activePCs[0].PCNumber) + require.Equal(t, student1, activePCs[0].StudentNumber) + }) + + t.Run("returns empty list if no active PCs", func(t *testing.T) { + postActivity(t, payloadStudent1, nil) + updatePayload := map[string]interface{}{"pc_number": 1, "exec_name": "John"} + updateActivity(t, student1, updatePayload, nil, http.StatusCreated) + + var activePCs []models.ActivePC + getJSON(t, "/activity/get-active-pcs", &activePCs) + require.Len(t, activePCs, 0) + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 4f6705d..4fccb9e 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -41,7 +41,10 @@ func processAPIKey(apiKey string) (appName string, err error) { FROM application WHERE key_id = $1 ` - err = database.DB.QueryRow(query, keyID).Scan(&appName, &hashedSecret, &lastUsed) + + ctx := context.Background() + err = database.DB.QueryRow(ctx, query, keyID).Scan(&appName, &hashedSecret, &lastUsed) + if err != nil { return "", err } @@ -52,7 +55,8 @@ func processAPIKey(apiKey string) (appName string, err error) { updateQuery := `UPDATE application SET last_used_at = NOW() WHERE key_id = $1` go func() { - database.DB.Exec(updateQuery, keyID) + ctx := context.Background() + database.DB.Exec(ctx, updateQuery, keyID) }() return appName, nil diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index da30aba..c1b9ad1 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -9,7 +9,7 @@ import ( ) func TestAuthMiddleware(t *testing.T) { - tests.SetupTestDB(t) + tests.SetupTestDBForTest(t) testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/models/active_pc.go b/internal/models/active_pc.go new file mode 100644 index 0000000..4bec22c --- /dev/null +++ b/internal/models/active_pc.go @@ -0,0 +1,13 @@ +package models + +import "time" + +type ActivePC struct { + GamerActivity + FirstName string `db:"first_name" json:"first_name"` + LastName string `db:"last_name" json:"last_name"` + MembershipTier int `db:"membership_tier" json:"membership_tier"` + Banned *bool `db:"banned" json:"banned,omitempty"` + Notes *string `db:"notes" json:"notes,omitempty"` + CreatedAt *time.Time `db:"created_at" json:"created_at,omitempty"` +} diff --git a/internal/models/gamer_activity.go b/internal/models/gamer_activity.go new file mode 100644 index 0000000..53e9892 --- /dev/null +++ b/internal/models/gamer_activity.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type GamerActivity struct { + ID uuid.UUID `db:"id" json:"id"` + StudentNumber string `db:"student_number" json:"student_number"` + PCNumber int `db:"pc_number" json:"pc_number"` + Game string `db:"game" json:"game"` + StartedAt time.Time `db:"started_at" json:"started_at"` + EndedAt *time.Time `db:"ended_at" json:"ended_at,omitempty"` + ExecName *string `db:"exec_name" json:"exec_name,omitempty"` +} diff --git a/internal/models/gamer_activity_with_name.go b/internal/models/gamer_activity_with_name.go new file mode 100644 index 0000000..d46e75e --- /dev/null +++ b/internal/models/gamer_activity_with_name.go @@ -0,0 +1,7 @@ +package models + +type GamerActivityWithName struct { + GamerActivity + FirstName string `db:"first_name" json:"first_name"` + LastName string `db:"last_name" json:"last_name"` +} diff --git a/internal/models/handler.go b/internal/models/handler.go new file mode 100644 index 0000000..4ff407e --- /dev/null +++ b/internal/models/handler.go @@ -0,0 +1,7 @@ +package models + +import "github.com/ubcesports/echo-base/config" + +type Handler struct { + Config *config.Config +} \ No newline at end of file diff --git a/internal/models/membership_result.go b/internal/models/membership_result.go new file mode 100644 index 0000000..0c18f2b --- /dev/null +++ b/internal/models/membership_result.go @@ -0,0 +1,8 @@ +package models + +import "time" + +type MembershipResult struct { + MembershipExpiryDate time.Time `db:"membership_expiry_date" json:"membership_expiry_date"` + MembershipTier int `db:"membership_tier" json:"membership_tier"` +} diff --git a/internal/models/update_gamer_activity_request.go b/internal/models/update_gamer_activity_request.go new file mode 100644 index 0000000..3ca540b --- /dev/null +++ b/internal/models/update_gamer_activity_request.go @@ -0,0 +1,6 @@ +package models + +type UpdateGamerActivityRequest struct { + PCNumber int `db:"pc_number" json:"pc_number"` + ExecName string `db:"exec_name" json:"exec_name"` +} diff --git a/internal/repositories/gamer_activity_queries.go b/internal/repositories/gamer_activity_queries.go new file mode 100644 index 0000000..cd9c797 --- /dev/null +++ b/internal/repositories/gamer_activity_queries.go @@ -0,0 +1,93 @@ +package repositories + +// Builds query for getting gamer activity by student number +func BuildGamerActivityByStudentQuery() string { + query := ` + SELECT * + FROM gamer_activity + WHERE student_number = $1` + return query +} + +// Builds query for getting today's Tier One member activity by student number +func BuildGamerActivityByTierOneStudentTodayQuery() string { + query := ` + SELECT ga.* + FROM gamer_activity ga + JOIN gamer_profile gp + ON ga.student_number = gp.student_number + WHERE ga.student_number = $1 + AND gp.membership_tier = 1 + AND DATE(ga.started_at) = DATE($2)` + return query +} + +// Builds query for recent gamer activity with optional search +func BuildGamerActivityRecentQuery(limit, offset int, search string) (string, []interface{}) { + base := ` + SELECT ga.*, gp.first_name, gp.last_name + FROM gamer_activity ga + JOIN gamer_profile gp + ON ga.student_number = gp.student_number + ` + args := []interface{}{limit, offset} + + if search != "" { + base += ` + WHERE (ga.student_number ILIKE $3 + OR gp.first_name ILIKE $3 + OR gp.last_name ILIKE $3 + OR ga.game ILIKE $3 + OR ga.exec_name ILIKE $3 + OR TO_CHAR(ga.started_at, 'YYYY-MM-DD') ILIKE $3) + ` + args = append(args, "%"+search+"%") + } + + base += ` ORDER BY ga.started_at DESC NULLS LAST LIMIT $1 OFFSET $2` + return base, args +} + +// Builds query for inserting a new gamer activity +func BuildInsertGamerActivityQuery() string { + query := ` + INSERT INTO gamer_activity + (student_number, pc_number, game, started_at) + VALUES ($1, $2, $3, $4) + RETURNING * + ` + return query +} + +// Builds query for updating gamer activity end time +func BuildUpdateGamerActivityQuery() string { + query := ` + UPDATE gamer_activity + SET ended_at = $1, exec_name = $4 + WHERE student_number = $2 AND pc_number = $3 AND ended_at IS NULL + RETURNING * + ` + return query +} + +// Builds query for getting all active PCs +func BuildGetAllActivePCsQuery() string { + query := ` + SELECT ga.*, gp.first_name, gp.last_name, gp.membership_tier, + gp.banned, gp.notes, gp.created_at + FROM gamer_activity ga + JOIN gamer_profile gp + ON ga.student_number = gp.student_number + WHERE ga.ended_at IS NULL + ` + return query +} + +func BuildCheckMemberQuery() string { + query := ` + SELECT membership_expiry_date, membership_tier + FROM gamer_profile + WHERE student_number = $1 + ` + return query +} diff --git a/internal/tests/testutils.go b/internal/tests/testutils.go index 7f87b36..7cf25a7 100644 --- a/internal/tests/testutils.go +++ b/internal/tests/testutils.go @@ -11,10 +11,14 @@ import ( "github.com/ubcesports/echo-base/internal/database" ) -func SetupTestDB(t *testing.T) { +// Does not include auto cleanup +func SetupTestDB() { os.Setenv("EB_DSN", "postgresql://user:pass@localhost:5433/echobase_test?sslmode=disable") - database.Init() +} + +func SetupTestDBForTest(t *testing.T) { + SetupTestDB() t.Cleanup(func() { database.Close() }) @@ -28,10 +32,7 @@ func CreateTestRequest(t *testing.T, method, url string, body interface{}) *http } } - req, err := http.NewRequest(method, url, &reqBody) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } + req := httptest.NewRequest(method, url, &reqBody) if body != nil { req.Header.Set("Content-Type", "application/json") @@ -45,13 +46,20 @@ func AssertResponse(t *testing.T, rr *httptest.ResponseRecorder, expectedStatus t.Errorf("Handler returned wrong status code: got %v want %v", status, expectedStatus) } - if ct := rr.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", ct) - } - if body != nil { + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", ct) + } + if err := json.NewDecoder(rr.Body).Decode(body); err != nil { t.Fatalf("Failed to decode JSON response: %v", err) } } } + +func ExecuteTestRequest(t *testing.T, router http.Handler, method, url string, body interface{}) *httptest.ResponseRecorder { + req := CreateTestRequest(t, method, url, body) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} diff --git a/migrations/20250804162708-base.sql b/migrations/20250804162708-base.sql index cfecf85..db61b5e 100644 --- a/migrations/20250804162708-base.sql +++ b/migrations/20250804162708-base.sql @@ -7,7 +7,8 @@ CREATE TABLE gamer_profile membership_tier INTEGER DEFAULT 0 NOT NULL, banned BOOLEAN, notes VARCHAR(250), - created_at DATE + created_at DATE, + membership_expiry_date DATE ); CREATE TABLE gamer_activity