From d707b6ec73e2502c7487b4d05447fc5abecdf9e1 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 25 Nov 2024 14:51:42 +0100 Subject: [PATCH] feat: add gamification integration --- config/development.toml | 4 + .../20241125113707_add_gamification_table.sql | 14 +++ db/queries/gamification.sql | 24 ++++ internal/cmd/gamification.go | 50 ++++++++ internal/cmd/tap.go | 8 +- internal/cmd/tui.go | 1 + internal/cmd/zess.go | 14 +++ internal/pkg/db/dto/gamification.go | 38 ++++++ internal/pkg/db/sqlc/gamification.sql.go | 109 ++++++++++++++++++ internal/pkg/db/sqlc/models.go | 7 ++ internal/pkg/gamification/api.go | 61 ++++++++++ internal/pkg/gamification/gamification.go | 75 ++++++++++++ internal/pkg/tap/api.go | 2 +- internal/pkg/zess/api.go | 5 +- 14 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 db/migrations/20241125113707_add_gamification_table.sql create mode 100644 db/queries/gamification.sql create mode 100644 internal/cmd/gamification.go create mode 100644 internal/pkg/db/dto/gamification.go create mode 100644 internal/pkg/db/sqlc/gamification.sql.go create mode 100644 internal/pkg/gamification/api.go create mode 100644 internal/pkg/gamification/gamification.go diff --git a/config/development.toml b/config/development.toml index a0416f7..86d60d1 100644 --- a/config/development.toml +++ b/config/development.toml @@ -28,6 +28,10 @@ api = "http://localhost:4000/api" interval_season_s = 300 interval_scan_s = 60 +[gamification] +api = "https://gamification.zeus.gent" +interval_s = 3600 + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241125113707_add_gamification_table.sql b/db/migrations/20241125113707_add_gamification_table.sql new file mode 100644 index 0000000..7cef662 --- /dev/null +++ b/db/migrations/20241125113707_add_gamification_table.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS gamification ( + id INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL, + score INTEGER NOT NULL, + avatar VARCHAR(255) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS gamification; +-- +goose StatementEnd diff --git a/db/queries/gamification.sql b/db/queries/gamification.sql new file mode 100644 index 0000000..18e76b6 --- /dev/null +++ b/db/queries/gamification.sql @@ -0,0 +1,24 @@ +-- CRUD + +-- name: GetAllGamification :many +SELECT * +FROM gamification; + +-- name: CreateGamification :one +INSERT INTO gamification (name, score, avatar) +VALUES (?, ?, ?) +RETURNING *; + +-- name: DeleteGamification :execrows +DELETE FROM gamification +WHERE id = ?; + + +-- Other + + +-- name: UpdateGamificationScore :one +UPDATE gamification +SET score = ? +WHERE id = ? +RETURNING *; diff --git a/internal/cmd/gamification.go b/internal/cmd/gamification.go new file mode 100644 index 0000000..550f857 --- /dev/null +++ b/internal/cmd/gamification.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/gamification" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +// Gamification starts the gamification instance +func Gamification(db *db.DB) (*gamification.Gamification, chan bool) { + gam := gamification.New(db) + done := make(chan bool) + + go gamificationPeriodicUpdate(gam, done) + + return gam, done +} + +func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool) { + interval := config.GetDefaultInt("gamification.interval_s", 3600) + zap.S().Info("Gamification: Starting periodic leaderboard update with an interval of ", interval, " seconds") + + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + // Run immediatly once + zap.S().Info("Gamification: Updating leaderboard") + err := gam.Update() + if err != nil { + zap.S().Error("gamification: Error updating leaderboard\n", err) + } + + for { + select { + case <-done: + zap.S().Info("Gamification: Stopping periodic leaderboard update") + return + case <-ticker.C: + // Update leaderboard + zap.S().Info("Gamification: Updating leaderboard") + err := gam.Update() + if err != nil { + zap.S().Error("gamification: Error updating leaderboard\n", err) + } + } + } +} diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 015ffa8..39a18d6 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -26,6 +26,13 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() + // Run immediatly once + zap.S().Info("Tap: Updating tap") + err := tap.Update() + if err != nil { + zap.S().Error("Tap: Error updating tap\n", err) + } + for { select { case <-done: @@ -40,5 +47,4 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { } } } - } diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index c5a936c..850af6f 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -17,6 +17,7 @@ import ( var screens = map[string]func(*db.DB) screen.Screen{ "cammie": screen.NewCammie, + "test": screen.NewTest, } // TUI starts the terminal user interface diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index 55c5247..f80a529 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -28,6 +28,13 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() + // Run immediatly once + zap.S().Info("Zess: Updating seasons") + err := zess.UpdateSeasons() + if err != nil { + zap.S().Error("Zess: Error updating seasons\n", err) + } + for { select { case <-done: @@ -51,6 +58,13 @@ func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() + // Run immediatly once + zap.S().Info("Zess: Updating scans") + err := zess.UpdateScans() + if err != nil { + zap.S().Error("Zess: Error updating scans\n", err) + } + for { select { case <-done: diff --git a/internal/pkg/db/dto/gamification.go b/internal/pkg/db/dto/gamification.go new file mode 100644 index 0000000..8aa6484 --- /dev/null +++ b/internal/pkg/db/dto/gamification.go @@ -0,0 +1,38 @@ +package dto + +import "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + +// Gamification represents the DTO object for gamification +type Gamification struct { + ID int64 `json:"id"` + Name string `json:"github_name"` + Score int64 `json:"score"` + Avatar string `json:"avatar_url"` +} + +// GamificationDTO converts a sqlc Gamification object to a DTO gamification +func GamificationDTO(gam sqlc.Gamification) *Gamification { + return &Gamification{ + ID: gam.ID, + Name: gam.Name, + Score: gam.Score, + Avatar: gam.Avatar, + } +} + +// CreateParams converts a Gamification DTO to a sqlc CreateGamificationParams object +func (g *Gamification) CreateParams() sqlc.CreateGamificationParams { + return sqlc.CreateGamificationParams{ + Name: g.Name, + Score: g.Score, + Avatar: g.Avatar, + } +} + +// UpdateScoreParams converts a Gamification DTO to a sqlc UpdateScoreParams object +func (g *Gamification) UpdateScoreParams() sqlc.UpdateGamificationScoreParams { + return sqlc.UpdateGamificationScoreParams{ + ID: g.ID, + Score: g.Score, + } +} diff --git a/internal/pkg/db/sqlc/gamification.sql.go b/internal/pkg/db/sqlc/gamification.sql.go new file mode 100644 index 0000000..c83ef13 --- /dev/null +++ b/internal/pkg/db/sqlc/gamification.sql.go @@ -0,0 +1,109 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: gamification.sql + +package sqlc + +import ( + "context" +) + +const createGamification = `-- name: CreateGamification :one +INSERT INTO gamification (name, score, avatar) +VALUES (?, ?, ?) +RETURNING id, name, score, avatar +` + +type CreateGamificationParams struct { + Name string + Score int64 + Avatar string +} + +func (q *Queries) CreateGamification(ctx context.Context, arg CreateGamificationParams) (Gamification, error) { + row := q.db.QueryRowContext(ctx, createGamification, arg.Name, arg.Score, arg.Avatar) + var i Gamification + err := row.Scan( + &i.ID, + &i.Name, + &i.Score, + &i.Avatar, + ) + return i, err +} + +const deleteGamification = `-- name: DeleteGamification :execrows +DELETE FROM gamification +WHERE id = ? +` + +func (q *Queries) DeleteGamification(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteGamification, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAllGamification = `-- name: GetAllGamification :many + +SELECT id, name, score, avatar +FROM gamification +` + +// CRUD +func (q *Queries) GetAllGamification(ctx context.Context) ([]Gamification, error) { + rows, err := q.db.QueryContext(ctx, getAllGamification) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Gamification + for rows.Next() { + var i Gamification + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Score, + &i.Avatar, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateGamificationScore = `-- name: UpdateGamificationScore :one + + +UPDATE gamification +SET score = ? +WHERE id = ? +RETURNING id, name, score, avatar +` + +type UpdateGamificationScoreParams struct { + Score int64 + ID int64 +} + +// Other +func (q *Queries) UpdateGamificationScore(ctx context.Context, arg UpdateGamificationScoreParams) (Gamification, error) { + row := q.db.QueryRowContext(ctx, updateGamificationScore, arg.Score, arg.ID) + var i Gamification + err := row.Scan( + &i.ID, + &i.Name, + &i.Score, + &i.Avatar, + ) + return i, err +} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 3a7e15f..a2d2dfc 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -8,6 +8,13 @@ import ( "time" ) +type Gamification struct { + ID int64 + Name string + Score int64 + Avatar string +} + type Message struct { ID int64 Name string diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go new file mode 100644 index 0000000..f1f7272 --- /dev/null +++ b/internal/pkg/gamification/api.go @@ -0,0 +1,61 @@ +package gamification + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "go.uber.org/zap" +) + +func (g *Gamification) getLeaderboard() (*[]*dto.Gamification, error) { + zap.S().Info("Gamification: Getting leaderboard") + + req := fiber.Get(g.api+"/top4").Set("Accept", "application/json") + + res := new([]*dto.Gamification) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return nil, errors.Join(append(errs, errors.New("Gamification: Leaderboard API request failed"))...) + } + if status != fiber.StatusOK { + return nil, fmt.Errorf("Gamification: Leaderboard API request returned bad status code %d", status) + } + + errs = make([]error, 0) + for _, gam := range *res { + if err := dto.Validate.Struct(gam); err != nil { + errs = append(errs, err) + } + } + + return res, errors.Join(errs...) +} + +func downloadAvatar(gam dto.Gamification) (string, error) { + req := fiber.Get(gam.Avatar) + status, body, errs := req.Bytes() + if errs != nil { + return "", errors.Join(append(errs, errors.New("Gamification: Download avatar request failed"))...) + } + if status != fiber.StatusOK { + return "", fmt.Errorf("Gamification: Download avatar returned bad status code %d", status) + } + + location := fmt.Sprintf("public/%s.png", gam.Name) + out, err := os.Create(location) + if err != nil && err != os.ErrExist { + return "", err + } + defer func() { + _ = out.Close() + }() + + _, err = io.Copy(out, bytes.NewReader(body)) + + return location, err +} diff --git a/internal/pkg/gamification/gamification.go b/internal/pkg/gamification/gamification.go new file mode 100644 index 0000000..c8d95a7 --- /dev/null +++ b/internal/pkg/gamification/gamification.go @@ -0,0 +1,75 @@ +// Package gamification provides all gamification related logic +package gamification + +import ( + "context" + "database/sql" + "errors" + "os" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/config" +) + +// Gamification represents a gamification instance +type Gamification struct { + db *db.DB + api string +} + +// New creates a new gamification instance +func New(db *db.DB) *Gamification { + api := config.GetDefaultString("gamification.api", "https://gamification.zeus.gent") + + return &Gamification{db: db, api: api} +} + +// Update gets the current leaderboard from gamification +func (g *Gamification) Update() error { + leaderboard, err := g.db.Queries.GetAllGamification(context.Background()) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + + gamLeaderboard, err := g.getLeaderboard() + if err != nil { + return err + } + + // Delete old + var errs []error + var errsOS []error // OS specific errors. + for _, l := range leaderboard { + // Remove picture + if err := os.Remove(l.Avatar); err != nil && err != os.ErrNotExist { + errsOS = append(errsOS, err) + } + + // Remove DB entry + if _, err = g.db.Queries.DeleteGamification(context.Background(), l.ID); err != nil { + errs = append(errs, err) + } + } + + // Don't quit if the only error(s) are os related + if errs != nil { + return errors.Join(append(errs, errsOS...)...) + } + + // Insert new ones + for _, gamL := range *gamLeaderboard { + location, err := downloadAvatar(*gamL) + if err != nil { + errs = append(errs, err) + } + gamL.Avatar = location + + if _, err = g.db.Queries.CreateGamification(context.Background(), gamL.CreateParams()); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go index 7bdc80d..8dd1cca 100644 --- a/internal/pkg/tap/api.go +++ b/internal/pkg/tap/api.go @@ -27,7 +27,7 @@ func (t *Tap) getOrders() ([]orderResponseItem, error) { res := new(orderResponse) status, _, errs := req.Struct(res) if len(errs) > 0 { - return nil, errors.Join(append([]error{errors.New("Tap: Order API request failed")}, errs...)...) + return nil, errors.Join(append(errs, errors.New("Tap: Order API request failed"))...) } if status != fiber.StatusOK { return nil, errors.New("error getting orders") diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go index 770e0dd..3b26ff1 100644 --- a/internal/pkg/zess/api.go +++ b/internal/pkg/zess/api.go @@ -2,6 +2,7 @@ package zess import ( "errors" + "fmt" "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" @@ -40,10 +41,10 @@ func (z *Zess) getScans() (*[]*dto.Scan, error) { res := new([]*dto.Scan) status, _, errs := req.Struct(res) if len(errs) > 0 { - return nil, errors.Join(append([]error{errors.New("Zess: Scan API request failed")}, errs...)...) + return nil, errors.Join(append(errs, errors.New("Zess: Scan API request failed"))...) } if status != fiber.StatusOK { - return nil, errors.New("error getting scans") + return nil, fmt.Errorf("Zess: Scan API returned bad status code %d", status) } errs = make([]error, 0)