From dbcec26e4ec493590bfb7e7ad0bda4da9abe81eb Mon Sep 17 00:00:00 2001 From: Jeffrey Date: Sat, 20 Sep 2025 14:35:25 -0700 Subject: [PATCH 1/2] gamer activity --- cmd/server/main.go | 5 + config/config.go | 29 +- internal/handlers/activity.go | 485 +++++++++++++++++++++++++++++++++- 3 files changed, 516 insertions(+), 3 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 86fef68..4d15dbb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,10 @@ import ( func main() { config.LoadEnv(".env") + cfg := config.LoadConfig() + + // Initialize activity handlers with config + ah := &handlers.Handler{Config: cfg} // Initialize database connection database.Init() @@ -24,6 +28,7 @@ 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}", ah.GetGamerActivityByStudent) 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 0a7fcb0..6c5ec7c 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "bufio" "os" "strings" + "time" ) func LoadEnv(path string) { @@ -20,8 +21,32 @@ func LoadEnv(path string) { continue } tokens := strings.SplitN(line, "=", 2) - if len(tokens) == 2{ + if len(tokens) == 2 { os.Setenv(tokens[0], tokens[1]) } } -} \ No newline at end of file +} + +type Config struct { + Schema string + Location *time.Location +} + +func LoadConfig() *Config { + // Pick schema based on environment + schema := os.Getenv("LIVE_SCHEMA") + if os.Getenv("NODE_ENV") == "test" { + schema = os.Getenv("TEST_SCHEMA") + } + + // Load timezone once + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + panic("failed to load timezone: " + err.Error()) + } + + return &Config{ + Schema: schema, + Location: loc, + } +} diff --git a/internal/handlers/activity.go b/internal/handlers/activity.go index 28ae6f5..dce5569 100644 --- a/internal/handlers/activity.go +++ b/internal/handlers/activity.go @@ -1 +1,484 @@ -package handlers \ No newline at end of file +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/lib/pq" + "github.com/ubcesports/echo-base/config" + "github.com/ubcesports/echo-base/internal/database" +) + +const FK_VIOLATION = "23503" + +type Handler struct { + Config *config.Config +} + +type GamerActivity struct { + StudentNumber string `json:"student_number"` + PCNumber int `json:"pc_number"` + Game string `json:"game"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` // optional + ExecName *string `json:"exec_name,omitempty"` // optional +} + +type GamerActivityWithName struct { + GamerActivity + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +type ActivePC struct { + GamerActivity + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + MembershipTier int `json:"membership_tier"` + Banned bool `json:"banned"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` +} + +type UpdateGamerActivityRequest struct { + PCNumber int `json:"pc_number"` + ExecName string `json:"exec_name"` +} + +func ScanGamerActivity(rows *sql.Rows) ([]GamerActivity, error) { + var result []GamerActivity + for rows.Next() { + var a GamerActivity + if err := rows.Scan(&a.StudentNumber, &a.PCNumber, &a.Game, &a.StartedAt, &a.EndedAt, &a.ExecName); err != nil { + return nil, err + } + result = append(result, a) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +func ScanGamerActivityWithName(rows *sql.Rows) ([]GamerActivityWithName, error) { + var result []GamerActivityWithName + for rows.Next() { + var a GamerActivityWithName + if err := rows.Scan(&a.StudentNumber, &a.PCNumber, &a.Game, &a.StartedAt, &a.EndedAt, &a.ExecName, &a.FirstName, &a.LastName); err != nil { + return nil, err + } + result = append(result, a) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +func ScanActivePC(rows *sql.Rows) ([]ActivePC, error) { + var result []ActivePC + for rows.Next() { + var a ActivePC + if err := rows.Scan(&a.StudentNumber, &a.PCNumber, &a.Game, &a.StartedAt, &a.EndedAt, &a.ExecName, &a.FirstName, &a.LastName, &a.MembershipTier, &a.Banned, &a.Notes, &a.CreatedAt); err != nil { + return nil, err + } + result = append(result, a) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return result, 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 +} + +func WriteJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func WriteError(w http.ResponseWriter, status int, msg string, err error) { + http.Error(w, fmt.Sprintf("%s: %v", msg, err), status) +} + +// 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 +} + +/** + * @api {get} /activity/:student_number Get Gamer Activity for specific student + * @apiName GetGamerActivityByStudent + * @apiGroup Activity + * + * @apiParam {String} student_number Student's unique number. + * + * @apiSuccess {Object} gamer_activity Gamer activity object. + * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. + * @apiSuccess {Number} gamer_activity.pc_number PC number. + * @apiSuccess {String} gamer_activity.game Game name. + * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. + * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. + * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. + * + * @apiError {String} 500 Server error. + */ + +func (h *Handler) GetGamerActivityByStudent(w http.ResponseWriter, r *http.Request) { + + // Extract dynamic path param + studentNumber := r.PathValue("student_number") + + // Build query + query := fmt.Sprintf(` + SELECT * + FROM %[1]s.gamer_activity + WHERE student_number = $1 + `, h.Config.Schema) + + // Execute query and close connection + rows, err := database.DB.Query(query, studentNumber) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error finding gamer activity: ", err) + return + } + defer rows.Close() + + // Scan results + response, err := ScanGamerActivity(rows) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) + return + } + + if len(response) == 0 { + WriteError(w, http.StatusNotFound, "Student not found", nil) + return + } + + WriteJSON(w, http.StatusOK, response) +} + +/** + * @api {get} /activity/today/:student_number Get Gamer Tier One Member Activity today for specific student + * @apiName GetGamerActivityByTierOneStudentToday + * @apiGroup Activity + * + * @apiParam {String} student_number Student's unique number. + * + * @apiSuccess {Object} gamer_activity Gamer activity object. + * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. + * @apiSuccess {Number} gamer_activity.pc_number PC number. + * @apiSuccess {String} gamer_activity.game Game name. + * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. + * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. + * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. + * + * @apiError {String} 500 Server error. + */ +func (h *Handler) GetGamerActivityByTierOneStudentToday(w http.ResponseWriter, r *http.Request) { + studentNumber := r.PathValue("student_number") + + now := time.Now().In(h.Config.Location) + + query := fmt.Sprintf(` + SELECT ga.* + FROM %[1]s.gamer_activity ga + JOIN %[1]s.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::timestamp) = DATE($2::timestamp) + `, h.Config.Schema) + + rows, err := database.DB.Query(query, studentNumber, now) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error checking Tier one member sign in: ", err) + return + } + defer rows.Close() + + response, err := ScanGamerActivity(rows) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) + return + } + + WriteJSON(w, http.StatusOK, response) +} + +// Gamer Activity with Profile info +/** + * @api {get} /activity/all/recent Get Gamer Activity + * @apiName GetGamerActivity + * @apiGroup Activity + * + * @apiParam {Number} page Page number. + * @apiParam {Number} limit Limit Number of results per page. + * @apiParam {String} search Search query. + * + * @apiSuccess {Object} gamer_activity Gamer activty object with additional profile fields. + * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. + * @apiSuccess {Number} gamer_activity.pc_number PC number. + * @apiSuccess {String} gamer_activity.game Game name. + * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. + * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. + * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. + * + * @apiError {String} 500 Server error. + */ +func (h *Handler) GetGamerActivity(w http.ResponseWriter, r *http.Request) { + page := QueryStringToInt(r, "page", 1) + limit := QueryStringToInt(r, "limit", 10) + search := r.URL.Query().Get("search") + + offset := (page - 1) * limit + + // Base query + query := fmt.Sprintf(` + SELECT ga.*, gp.first_name, gp.last_name + FROM %[1]s.gamer_activity ga + JOIN %[1]s.gamer_profile gp + ON ga.student_number = gp.student_number + `, h.Config.Schema) + + args := []interface{}{limit, offset} + + if search != "" { + query += ` + 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+"%") + } + query += `ORDER BY ga.started_at DESC NULLS LAST LIMIT $1 OFFSET $2` + + rows, err := database.DB.Query(query, args...) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error finding recent activity: ", err) + return + } + defer rows.Close() + + response, err := ScanGamerActivityWithName(rows) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) + return + } + + WriteJSON(w, http.StatusOK, response) +} + +//Gamer Activity only +/** + * @api {post} /activity Add Gamer Activity + * @apiName AddGamerActivity + * @apiGroup Activity + * + * @apiParam {String} student_number Student number, 8 digit integer. + * @apiParam {String} pc_number PC number. + * @apiParam {String} game Game name. + * @apiParam {Number} started_at Date when the activity started. + * + * @apiSuccess {Object} gamer_activity Gamer activity object. + * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. + * @apiSuccess {Number} gamer_activity.pc_number PC number. + * @apiSuccess {String} gamer_activity.game Game name. + * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. + * @apiSuccess {Null} gamer_activity.ended_at Datetime will be null. + * @apiSuccess {Null} gamer_activity.exec_name will be null. + * + * @apiError {String} 500 Server error. + * @apiError {String} 404 Foreign key not found. + */ +func (h *Handler) AddGamerActivity(w http.ResponseWriter, r *http.Request) { + + if !RequireJSONContentType(r) { + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return + } + + var ga GamerActivity + if err := DecodeJSONBody(r, &ga); err != nil { + WriteError(w, http.StatusBadRequest, "Invalid request body", err) + return + } + + now := time.Now().In(h.Config.Location) + + query := fmt.Sprintf(` + INSERT INTO %[1]s.gamer_activity + (student_number, pc_number, game, started_at) + VALUES ($1, $2, $3, $4) + RETURNING *`, h.Config.Schema) + + rows, err := database.DB.Query(query, ga.StudentNumber, ga.PCNumber, ga.Game, now) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == FK_VIOLATION { + WriteError(w, http.StatusNotFound, "Foreign key violation: Gamer profile not found", err) + return + } + WriteError(w, http.StatusInternalServerError, "Error creating activity", err) + return + } + defer rows.Close() + + activities, err := ScanGamerActivity(rows) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) + return + } + + WriteJSON(w, http.StatusCreated, activities[0]) +} + +//Gamer Activity only +/** + * @api {patch} /activity/update/:student_number Update Gamer Activity End Time + * @apiName UpdateGamerActivity + * @apiGroup Activity + * + * @apiParam {String} student_number Student number, 8 digit integer. + * + * @apiSuccess {Object} gamer_activity Gamer activity object. + * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. + * @apiSuccess {Number} gamer_activity.pc_number PC number. + * @apiSuccess {String} gamer_activity.game Game name. + * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. + * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. + * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. + * + * @apiError {String} 500 Internal server error. + */ +func (h *Handler) UpdateGamerActivity(w http.ResponseWriter, r *http.Request) { + now := time.Now().In(h.Config.Location) + + studentNumber := r.PathValue("student_number") + + if !RequireJSONContentType(r) { + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return + } + + var rb UpdateGamerActivityRequest + if err := DecodeJSONBody(r, &rb); err != nil { + WriteError(w, http.StatusBadRequest, "Invalid request body", err) + return + } + + query := fmt.Sprintf(` + UPDATE %[1]s.gamer_activity + SET ended_at = $1, exec_name = $4 + WHERE student_number = $2 AND pc_number = $3 AND ended_at IS NULL + RETURNING * + `, h.Config.Schema) + + rows, err := database.DB.Query(query, now, studentNumber, rb.PCNumber, rb.ExecName) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error finding recent activity: ", err) + return + } + defer rows.Close() + + activities, err := ScanGamerActivity(rows) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) + return + } + + if len(activities) == 0 { + WriteError(w, http.StatusNotFound, "Student not active", nil) + return + } + + WriteJSON(w, http.StatusOK, activities[0]) +} + +/** + * @api {get} /activity/get-active-pcs Gets all active PCs + * @apiName GetAllActivePCs + * @apiGroup Activity + * + * @apiParam {Null} No parameters required. + * + * @apiSuccess {Object} gamer_activity Gamer activty object with additional profile fields. + * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. + * @apiSuccess {Number} gamer_activity.pc_number PC number. + * @apiSuccess {String} gamer_activity.game Game name. + * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. + * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. + * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. + * + * @apiError {String} 500 Internal server error. + */ +func (h *Handler) GetAllActivePCs(w http.ResponseWriter, r *http.Request) { + query := fmt.Sprintf(` + SELECT ga.*, gp.first_name, gp.last_name, gp.membership_tier, + gp.banned, gp.notes, gp.created_at + FROM %[1]s.gamer_activity ga + JOIN %[1]s.gamer_profile gp + ON ga.student_number = gp.student_number + WHERE ga.ended_at IS NULL + `, h.Config.Schema) + + rows, err := database.DB.Query(query) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error finding recent activity: ", err) + return + } + defer rows.Close() + + response, err := ScanActivePC(rows) + if err != nil { + WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) + return + } + + WriteJSON(w, http.StatusOK, response) +} From f3ed2baa84f18270db5b46174a849dc28f1be1d7 Mon Sep 17 00:00:00 2001 From: Jeffrey Date: Wed, 24 Sep 2025 12:54:03 -0700 Subject: [PATCH 2/2] WIP: finishing tests --- cmd/server/main.go | 11 +- config/config.go | 8 - go.mod | 17 +- go.sum | 34 +- internal/database/db.go | 28 +- internal/handlers/activity.go | 486 +++--------------- internal/handlers/errors.go | 30 ++ internal/handlers/keygeneration.go | 3 +- internal/handlers/utils.go | 52 ++ internal/handlers/wrapper.go | 25 + internal/handlers_test/db.go | 64 +++ internal/handlers_test/gamer_activity_test.go | 76 +++ internal/middleware/auth.go | 8 +- internal/models/active_pc.go | 13 + internal/models/gamer_activity.go | 12 + internal/models/gamer_activity_with_name.go | 7 + internal/models/handler.go | 7 + internal/models/membership_result.go | 8 + .../models/update_gamer_activity_request.go | 6 + .../repositories/gamer_activity_queries.go | 93 ++++ migrations/20250804162708-base.sql | 3 +- 21 files changed, 550 insertions(+), 441 deletions(-) create mode 100644 internal/handlers/errors.go create mode 100644 internal/handlers/utils.go create mode 100644 internal/handlers/wrapper.go create mode 100644 internal/handlers_test/db.go create mode 100644 internal/handlers_test/gamer_activity_test.go create mode 100644 internal/models/active_pc.go create mode 100644 internal/models/gamer_activity.go create mode 100644 internal/models/gamer_activity_with_name.go create mode 100644 internal/models/handler.go create mode 100644 internal/models/membership_result.go create mode 100644 internal/models/update_gamer_activity_request.go create mode 100644 internal/repositories/gamer_activity_queries.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 4d15dbb..4a00e86 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,8 +15,13 @@ func main() { config.LoadEnv(".env") cfg := config.LoadConfig() - // Initialize activity handlers with config - ah := &handlers.Handler{Config: cfg} + database.Init() + defer database.Close() + + ah := &handlers.Handler{ + Config: cfg, + DB: database.DB, + } // Initialize database connection database.Init() @@ -28,7 +33,7 @@ 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}", ah.GetGamerActivityByStudent) + mux.HandleFunc("/activity/{student_number}", handlers.Wrap(ah.GetGamerActivityByStudent)) 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 6c5ec7c..8e8b2be 100644 --- a/config/config.go +++ b/config/config.go @@ -28,17 +28,10 @@ func LoadEnv(path string) { } type Config struct { - Schema string Location *time.Location } func LoadConfig() *Config { - // Pick schema based on environment - schema := os.Getenv("LIVE_SCHEMA") - if os.Getenv("NODE_ENV") == "test" { - schema = os.Getenv("TEST_SCHEMA") - } - // Load timezone once loc, err := time.LoadLocation("America/Los_Angeles") if err != nil { @@ -46,7 +39,6 @@ func LoadConfig() *Config { } return &Config{ - Schema: schema, Location: loc, } } diff --git a/go.mod b/go.mod index eff4f30..f5442f6 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,12 @@ 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/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 +15,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 +23,15 @@ 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/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/google/uuid v1.3.0 // 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 dce5569..dfe3257 100644 --- a/internal/handlers/activity.go +++ b/internal/handlers/activity.go @@ -1,484 +1,152 @@ package handlers import ( - "database/sql" - "encoding/json" "fmt" "net/http" - "strconv" - "strings" "time" - "github.com/lib/pq" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/ubcesports/echo-base/config" - "github.com/ubcesports/echo-base/internal/database" + "github.com/ubcesports/echo-base/internal/models" + "github.com/ubcesports/echo-base/internal/repositories" ) -const FK_VIOLATION = "23503" - type Handler struct { Config *config.Config + DB *pgxpool.Pool } -type GamerActivity struct { - StudentNumber string `json:"student_number"` - PCNumber int `json:"pc_number"` - Game string `json:"game"` - StartedAt time.Time `json:"started_at"` - EndedAt *time.Time `json:"ended_at,omitempty"` // optional - ExecName *string `json:"exec_name,omitempty"` // optional -} - -type GamerActivityWithName struct { - GamerActivity - FirstName string `json:"first_name"` - LastName string `json:"last_name"` -} - -type ActivePC struct { - GamerActivity - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - MembershipTier int `json:"membership_tier"` - Banned bool `json:"banned"` - Notes string `json:"notes"` - CreatedAt time.Time `json:"created_at"` -} - -type UpdateGamerActivityRequest struct { - PCNumber int `json:"pc_number"` - ExecName string `json:"exec_name"` -} - -func ScanGamerActivity(rows *sql.Rows) ([]GamerActivity, error) { - var result []GamerActivity - for rows.Next() { - var a GamerActivity - if err := rows.Scan(&a.StudentNumber, &a.PCNumber, &a.Game, &a.StartedAt, &a.EndedAt, &a.ExecName); err != nil { - return nil, err - } - result = append(result, a) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return result, nil -} - -func ScanGamerActivityWithName(rows *sql.Rows) ([]GamerActivityWithName, error) { - var result []GamerActivityWithName - for rows.Next() { - var a GamerActivityWithName - if err := rows.Scan(&a.StudentNumber, &a.PCNumber, &a.Game, &a.StartedAt, &a.EndedAt, &a.ExecName, &a.FirstName, &a.LastName); err != nil { - return nil, err - } - result = append(result, a) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return result, nil -} - -func ScanActivePC(rows *sql.Rows) ([]ActivePC, error) { - var result []ActivePC - for rows.Next() { - var a ActivePC - if err := rows.Scan(&a.StudentNumber, &a.PCNumber, &a.Game, &a.StartedAt, &a.EndedAt, &a.ExecName, &a.FirstName, &a.LastName, &a.MembershipTier, &a.Banned, &a.Notes, &a.CreatedAt); err != nil { - return nil, err - } - result = append(result, a) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return result, 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 -} - -func WriteJSON(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} - -func WriteError(w http.ResponseWriter, status int, msg string, err error) { - http.Error(w, fmt.Sprintf("%s: %v", msg, err), status) -} - -// 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 -} - -/** - * @api {get} /activity/:student_number Get Gamer Activity for specific student - * @apiName GetGamerActivityByStudent - * @apiGroup Activity - * - * @apiParam {String} student_number Student's unique number. - * - * @apiSuccess {Object} gamer_activity Gamer activity object. - * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. - * @apiSuccess {Number} gamer_activity.pc_number PC number. - * @apiSuccess {String} gamer_activity.game Game name. - * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. - * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. - * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. - * - * @apiError {String} 500 Server error. - */ - -func (h *Handler) GetGamerActivityByStudent(w http.ResponseWriter, r *http.Request) { - - // Extract dynamic path param +func (h *Handler) GetGamerActivityByStudent(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() studentNumber := r.PathValue("student_number") - // Build query - query := fmt.Sprintf(` - SELECT * - FROM %[1]s.gamer_activity - WHERE student_number = $1 - `, h.Config.Schema) - - // Execute query and close connection - rows, err := database.DB.Query(query, studentNumber) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error finding gamer activity: ", err) - return - } - defer rows.Close() + var activities []models.GamerActivity + query := repositories.BuildGamerActivityByStudentQuery() - // Scan results - response, err := ScanGamerActivity(rows) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) - return + if err := pgxscan.Select(ctx, h.DB, &activities, query, studentNumber); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) } - if len(response) == 0 { - WriteError(w, http.StatusNotFound, "Student not found", nil) - return + if len(activities) == 0 { + return NewHTTPError(http.StatusNotFound, "student not found") } - WriteJSON(w, http.StatusOK, response) + return WriteJSON(w, http.StatusOK, activities) } -/** - * @api {get} /activity/today/:student_number Get Gamer Tier One Member Activity today for specific student - * @apiName GetGamerActivityByTierOneStudentToday - * @apiGroup Activity - * - * @apiParam {String} student_number Student's unique number. - * - * @apiSuccess {Object} gamer_activity Gamer activity object. - * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. - * @apiSuccess {Number} gamer_activity.pc_number PC number. - * @apiSuccess {String} gamer_activity.game Game name. - * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. - * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. - * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. - * - * @apiError {String} 500 Server error. - */ -func (h *Handler) GetGamerActivityByTierOneStudentToday(w http.ResponseWriter, r *http.Request) { +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) - query := fmt.Sprintf(` - SELECT ga.* - FROM %[1]s.gamer_activity ga - JOIN %[1]s.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::timestamp) = DATE($2::timestamp) - `, h.Config.Schema) - - rows, err := database.DB.Query(query, studentNumber, now) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error checking Tier one member sign in: ", err) - return - } - defer rows.Close() + var activities []models.GamerActivity + query := repositories.BuildGamerActivityByTierOneStudentTodayQuery() - response, err := ScanGamerActivity(rows) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) - return + if err := pgxscan.Select(ctx, h.DB, &activities, query, studentNumber, now); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) } - WriteJSON(w, http.StatusOK, response) + return WriteJSON(w, http.StatusOK, activities) } -// Gamer Activity with Profile info -/** - * @api {get} /activity/all/recent Get Gamer Activity - * @apiName GetGamerActivity - * @apiGroup Activity - * - * @apiParam {Number} page Page number. - * @apiParam {Number} limit Limit Number of results per page. - * @apiParam {String} search Search query. - * - * @apiSuccess {Object} gamer_activity Gamer activty object with additional profile fields. - * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. - * @apiSuccess {Number} gamer_activity.pc_number PC number. - * @apiSuccess {String} gamer_activity.game Game name. - * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. - * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. - * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. - * - * @apiError {String} 500 Server error. - */ -func (h *Handler) GetGamerActivity(w http.ResponseWriter, r *http.Request) { +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 - // Base query - query := fmt.Sprintf(` - SELECT ga.*, gp.first_name, gp.last_name - FROM %[1]s.gamer_activity ga - JOIN %[1]s.gamer_profile gp - ON ga.student_number = gp.student_number - `, h.Config.Schema) - - args := []interface{}{limit, offset} - - if search != "" { - query += ` - 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+"%") - } - query += `ORDER BY ga.started_at DESC NULLS LAST LIMIT $1 OFFSET $2` - - rows, err := database.DB.Query(query, args...) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error finding recent activity: ", err) - return - } - defer rows.Close() + var activities []models.GamerActivityWithName + query, args := repositories.BuildGamerActivityRecentQuery(limit, offset, search) - response, err := ScanGamerActivityWithName(rows) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) - return + if err := pgxscan.Select(ctx, h.DB, &activities, query, args...); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) } - WriteJSON(w, http.StatusOK, response) + return WriteJSON(w, http.StatusOK, activities) } -//Gamer Activity only -/** - * @api {post} /activity Add Gamer Activity - * @apiName AddGamerActivity - * @apiGroup Activity - * - * @apiParam {String} student_number Student number, 8 digit integer. - * @apiParam {String} pc_number PC number. - * @apiParam {String} game Game name. - * @apiParam {Number} started_at Date when the activity started. - * - * @apiSuccess {Object} gamer_activity Gamer activity object. - * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. - * @apiSuccess {Number} gamer_activity.pc_number PC number. - * @apiSuccess {String} gamer_activity.game Game name. - * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. - * @apiSuccess {Null} gamer_activity.ended_at Datetime will be null. - * @apiSuccess {Null} gamer_activity.exec_name will be null. - * - * @apiError {String} 500 Server error. - * @apiError {String} 404 Foreign key not found. - */ -func (h *Handler) AddGamerActivity(w http.ResponseWriter, r *http.Request) { +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) { - http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) - return + return NewHTTPError(http.StatusBadRequest, "invalid content type") } - var ga GamerActivity + var ga models.GamerActivityWithName if err := DecodeJSONBody(r, &ga); err != nil { - WriteError(w, http.StatusBadRequest, "Invalid request body", err) - return + return NewHTTPError(http.StatusBadRequest, "invalid request body", err) } - now := time.Now().In(h.Config.Location) + var m models.MembershipResult + checkMemberQuery := repositories.BuildCheckMemberQuery() + if err := pgxscan.Select(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) + } - query := fmt.Sprintf(` - INSERT INTO %[1]s.gamer_activity - (student_number, pc_number, game, started_at) - VALUES ($1, $2, $3, $4) - RETURNING *`, h.Config.Schema) - - rows, err := database.DB.Query(query, ga.StudentNumber, ga.PCNumber, ga.Game, now) - if err != nil { - if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == FK_VIOLATION { - WriteError(w, http.StatusNotFound, "Foreign key violation: Gamer profile not found", err) - return - } - WriteError(w, http.StatusInternalServerError, "Error creating activity", err) - return + 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) } - defer rows.Close() - activities, err := ScanGamerActivity(rows) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) - return + 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) } - WriteJSON(w, http.StatusCreated, activities[0]) + return WriteJSON(w, http.StatusCreated, gamerActivities[0]) } -//Gamer Activity only -/** - * @api {patch} /activity/update/:student_number Update Gamer Activity End Time - * @apiName UpdateGamerActivity - * @apiGroup Activity - * - * @apiParam {String} student_number Student number, 8 digit integer. - * - * @apiSuccess {Object} gamer_activity Gamer activity object. - * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. - * @apiSuccess {Number} gamer_activity.pc_number PC number. - * @apiSuccess {String} gamer_activity.game Game name. - * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. - * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. - * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. - * - * @apiError {String} 500 Internal server error. - */ -func (h *Handler) UpdateGamerActivity(w http.ResponseWriter, r *http.Request) { - now := time.Now().In(h.Config.Location) +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) { - http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) - return + return NewHTTPError(http.StatusBadRequest, "invalid content type") } - var rb UpdateGamerActivityRequest + var rb models.UpdateGamerActivityRequest if err := DecodeJSONBody(r, &rb); err != nil { - WriteError(w, http.StatusBadRequest, "Invalid request body", err) - return + return NewHTTPError(http.StatusBadRequest, "invalid request body", err) } - query := fmt.Sprintf(` - UPDATE %[1]s.gamer_activity - SET ended_at = $1, exec_name = $4 - WHERE student_number = $2 AND pc_number = $3 AND ended_at IS NULL - RETURNING * - `, h.Config.Schema) - - rows, err := database.DB.Query(query, now, studentNumber, rb.PCNumber, rb.ExecName) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error finding recent activity: ", err) - return - } - defer rows.Close() - - activities, err := ScanGamerActivity(rows) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) - return + 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 { - WriteError(w, http.StatusNotFound, "Student not active", nil) - return + return NewHTTPError(http.StatusNotFound, "student not active") } - WriteJSON(w, http.StatusOK, activities[0]) + return WriteJSON(w, http.StatusCreated, activities[0]) } -/** - * @api {get} /activity/get-active-pcs Gets all active PCs - * @apiName GetAllActivePCs - * @apiGroup Activity - * - * @apiParam {Null} No parameters required. - * - * @apiSuccess {Object} gamer_activity Gamer activty object with additional profile fields. - * @apiSuccess {String} gamer_activity.student_number Student number, 8 digit integer. - * @apiSuccess {Number} gamer_activity.pc_number PC number. - * @apiSuccess {String} gamer_activity.game Game name. - * @apiSuccess {String} gamer_activity.started_at Datetime when the activity started. - * @apiSuccess {String} gamer_activity.ended_at Datetime when the activity ended. - * @apiSuccess {string} gamer_activity.exec_name Exec that ended the activity. - * - * @apiError {String} 500 Internal server error. - */ -func (h *Handler) GetAllActivePCs(w http.ResponseWriter, r *http.Request) { - query := fmt.Sprintf(` - SELECT ga.*, gp.first_name, gp.last_name, gp.membership_tier, - gp.banned, gp.notes, gp.created_at - FROM %[1]s.gamer_activity ga - JOIN %[1]s.gamer_profile gp - ON ga.student_number = gp.student_number - WHERE ga.ended_at IS NULL - `, h.Config.Schema) - - rows, err := database.DB.Query(query) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error finding recent activity: ", err) - return - } - defer rows.Close() +func (h *Handler) GetAllActivePCs(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + query := repositories.BuildGetAllActivePCsQuery() + var activePCs []models.ActivePC - response, err := ScanActivePC(rows) - if err != nil { - WriteError(w, http.StatusInternalServerError, "Error scanning rows", err) - return + if err := pgxscan.Select(ctx, h.DB, &activePCs, query); err != nil { + return NewHTTPError(http.StatusInternalServerError, "database query failed", err) } - WriteJSON(w, http.StatusOK, response) + return WriteJSON(w, http.StatusOK, activePCs) } 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/keygeneration.go b/internal/handlers/keygeneration.go index e37f510..7f3e04f 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" @@ -92,7 +93,7 @@ func storeAPIKey(req GenerateKeyRequest, keyID string, hashedSecret []byte) erro INSERT INTO application (app_name, key_id, hashed_key) VALUES ($1, $2, $3) ` - _, err := database.DB.Exec(query, req.AppName, keyID, hashedSecret) + _, err := database.DB.Exec(context.Background(), query, req.AppName, keyID, hashedSecret) if err != nil { return fmt.Errorf("database storage failed") 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/db.go b/internal/handlers_test/db.go new file mode 100644 index 0000000..a2aedfd --- /dev/null +++ b/internal/handlers_test/db.go @@ -0,0 +1,64 @@ +package handlers_test + +import ( + "context" + "log" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func InitTestDB() *pgxpool.Pool { + dsn := os.Getenv("EB_DSN") + if dsn == "" { + log.Fatal("EB_DSN environment variable must be set") + } + dsn = dsn + "&search_path=test" + + ctx := context.Background() + dbpool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("Failed to connect to test DB: %v\n", err) + } + + err = dbpool.Ping(ctx) + if err != nil { + log.Fatal("Failed to ping test DB: %v\n", err) + } + + return dbpool +} + +func CloseTestDB(dbpool *pgxpool.Pool) { + if dbpool != nil { + dbpool.Close() + } +} + +func BeforeEachTest(t *testing.T, dbpool *pgxpool.Pool) { + ctx := context.Background() + + // Truncate tables + _, err := dbpool.Exec(ctx, "TRUNCATE TABLE gamer_activity;") + require.NoError(t, err) + + _, err = dbpool.Exec(ctx, "TRUNCATE TABLE gamer_profile CASCADE;") + require.NoError(t, err) + + // Seed test users + _, err = dbpool.Exec(ctx, ` + INSERT INTO gamer_profile (first_name, last_name, student_number, membership_tier) + VALUES + ('John','Doe','11223344',1), + ('Jane','Doe','87654321',2); + `) + require.NoError(t, err) +} + +func AfterEachTest(t *testing.T, dbpool *pgxpool.Pool) { + ctx := context.Background() + _, err := dbpool.Exec(ctx, "TRUNCATE TABLE gamer_activity;") + require.NoError(t, 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..efa8b97 --- /dev/null +++ b/internal/handlers_test/gamer_activity_test.go @@ -0,0 +1,76 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + "github.com/ubcesports/echo-base/config" + "github.com/ubcesports/echo-base/internal/handlers" + "github.com/ubcesports/echo-base/internal/models" +) + +var ( + testDB *pgxpool.Pool + testRouter *http.ServeMux + testHandler *handlers.Handler +) + +func TestMain(m *testing.M) { + testDB = InitTestDB() + testConfig := config.LoadConfig() + testHandler = &handlers.Handler{DB: testDB, Config: testConfig} + + testRouter = http.NewServeMux() + testRouter.HandleFunc("/activity/{student_number}", handlers.Wrap(testHandler.GetGamerActivityByStudent)) + testRouter.HandleFunc("/activity/today/{student_number}", handlers.Wrap(testHandler.GetGamerActivityByTierOneStudentToday)) + testRouter.HandleFunc("/activity/all/recent", handlers.Wrap(testHandler.GetGamerActivity)) + testRouter.HandleFunc("/activity", handlers.Wrap(testHandler.AddGamerActivity)) + testRouter.HandleFunc("/activity/update/{student_number}", handlers.Wrap(testHandler.UpdateGamerActivity)) + + exitCode := m.Run() + CloseTestDB(testDB) + os.Exit(exitCode) +} + +func TestAddGamerActivity(t *testing.T) { + BeforeEachTest(t, testDB) + t.Cleanup(func() { AfterEachTest(t, testDB) }) + + payload := map[string]interface{}{ + "student_number": "11223344", + "pc_number": 1, + "game": "Valorant", + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/activity", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + testRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + + } + res := w.Result() + defer res.Body.Close() + + require.Equal(t, http.StatusCreated, res.StatusCode) + + var activity models.GamerActivityWithName + err = json.NewDecoder(res.Body).Decode(&activity) + require.NoError(t, err) + + require.Equal(t, "11223344", activity.StudentNumber) + require.Equal(t, 1, activity.PCNumber) + require.Equal(t, "Valorant", activity.Game) + require.Nil(t, activity.ExecName) +} 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/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..0ed1400 --- /dev/null +++ b/internal/models/gamer_activity.go @@ -0,0 +1,12 @@ +package models + +import "time" + +type GamerActivity struct { + 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..c0b272a --- /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) = DATA($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/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