From 893f6171ded7ea4d9247f16e16f3a89a1b187583 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 17:41:16 +0100 Subject: [PATCH] feat: tap integration --- config/development.toml | 16 ++ .../20241114152122_add_tap_table.sql | 16 ++ db/queries/tap.sql | 43 ++++ internal/cmd/root.go | 16 ++ internal/cmd/tap.go | 41 ++++ internal/pkg/db/sqlc/models.go | 9 + internal/pkg/db/sqlc/tap.sql.go | 227 ++++++++++++++++++ internal/pkg/tap/api.go | 39 +++ internal/pkg/tap/tap.go | 109 +++++++++ pkg/util/slice.go | 21 +- 10 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 db/migrations/20241114152122_add_tap_table.sql create mode 100644 db/queries/tap.sql create mode 100644 internal/cmd/tap.go create mode 100644 internal/pkg/db/sqlc/tap.sql.go create mode 100644 internal/pkg/tap/api.go create mode 100644 internal/pkg/tap/tap.go diff --git a/config/development.toml b/config/development.toml index ebb26a0..8da9187 100644 --- a/config/development.toml +++ b/config/development.toml @@ -6,6 +6,22 @@ port = 3000 client_id = "your_client_id" client_secret = "your_client_secret" +[tap] +interval_s = 60 +beers = [ + "Schelfaut", + "Duvel", + "Fourchette", + "Jupiler", + "Karmeliet", + "Kriek", + "Chouffe", + "Maes", + "Somersby", + "Sportzot", + "Stella", +] + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241114152122_add_tap_table.sql b/db/migrations/20241114152122_add_tap_table.sql new file mode 100644 index 0000000..0492937 --- /dev/null +++ b/db/migrations/20241114152122_add_tap_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS tap ( + id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL, + order_created_at TIMESTAMP NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS tap; +-- +goose StatementEnd diff --git a/db/queries/tap.sql b/db/queries/tap.sql new file mode 100644 index 0000000..3d2e123 --- /dev/null +++ b/db/queries/tap.sql @@ -0,0 +1,43 @@ +-- CRUD + +-- name: GetAllTaps :many +SELECT * +FROM tap; + +-- name: GetTapByID :one +SELECT * +FROM tap +WHERE id = ?; + +-- name: CreateTap :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: UpdateTap :one +UPDATE tap +SET order_id = ?, order_created_at = ?, name = ?, category = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteTap :execrows +DELETE FROM tap +WHERE id = ?; + +-- Other + +-- name: GetTapByOrderID :one +SELECT * +FROM tap +WHERE order_id = ?; + +-- name: GetTapByCategory :many +SELECT * +FROM tap +WHERE category = ?; + +-- name: GetLastOrder :one +SELECT * +FROM tap +ORDER BY id DESC +LIMIT 1; diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 70b8030..f7c0368 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -4,6 +4,7 @@ package cmd import ( "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/spotify" + "github.com/zeusWPI/scc/internal/pkg/tap" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" @@ -11,26 +12,41 @@ import ( // Execute starts the entire application func Execute() { + zap.S().Info("Initializing application") + + // Config err := config.Init() if err != nil { panic(err) } + // Logger zapLogger, err := logger.New() if err != nil { panic(err) } zap.ReplaceGlobals(zapLogger) + // Database db, err := db.New() if err != nil { zap.S().Fatal("DB: Fatal error\n", err) } + // Spotify spotify, err := spotify.New(db) if err != nil { zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) } + // Tap + tap := tap.New(db) + + // Everything that needs to be initialized is done + // Time to start all parts of the application + zap.S().Info("Starting application") + + _ = tapCmd(tap) + apiCmd(db, spotify) } diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go new file mode 100644 index 0000000..87057a0 --- /dev/null +++ b/internal/cmd/tap.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/tap" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +func tapCmd(tap *tap.Tap) chan bool { + done := make(chan bool) + + go tapPeriodicUpdate(tap, done) + + return done +} + +func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { + interval := config.GetDefaultInt("tap.interval_s", 60) + zap.S().Info("Tap: Starting periodic update with an interval of ", interval, " seconds") + + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-done: + zap.S().Info("Tap: Stopping periodic update") + return + case <-ticker.C: + // Update tap + zap.S().Info("Tap: Updating tap") + err := tap.Update() + if err != nil { + zap.S().Error("Tap: Error updating tap\n", err) + } + } + } + +} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 5097850..e50215c 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -24,3 +24,12 @@ type Spotify struct { DurationMs int64 CreatedAt time.Time } + +type Tap struct { + ID int64 + OrderID int64 + OrderCreatedAt time.Time + Name string + Category string + CreatedAt time.Time +} diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go new file mode 100644 index 0000000..209c882 --- /dev/null +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -0,0 +1,227 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: tap.sql + +package sqlc + +import ( + "context" + "time" +) + +const createTap = `-- name: CreateTap :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES (?, ?, ?, ?) +RETURNING id, order_id, order_created_at, name, category, created_at +` + +type CreateTapParams struct { + OrderID int64 + OrderCreatedAt time.Time + Name string + Category string +} + +func (q *Queries) CreateTap(ctx context.Context, arg CreateTapParams) (Tap, error) { + row := q.db.QueryRowContext(ctx, createTap, + arg.OrderID, + arg.OrderCreatedAt, + arg.Name, + arg.Category, + ) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ) + return i, err +} + +const deleteTap = `-- name: DeleteTap :execrows +DELETE FROM tap +WHERE id = ? +` + +func (q *Queries) DeleteTap(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteTap, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAllTaps = `-- name: GetAllTaps :many + +SELECT id, order_id, order_created_at, name, category, created_at +FROM tap +` + +// CRUD +func (q *Queries) GetAllTaps(ctx context.Context) ([]Tap, error) { + rows, err := q.db.QueryContext(ctx, getAllTaps) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Tap + for rows.Next() { + var i Tap + if err := rows.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ); 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 getLastOrder = `-- name: GetLastOrder :one +SELECT id, order_id, order_created_at, name, category, created_at +FROM tap +ORDER BY id DESC +LIMIT 1 +` + +func (q *Queries) GetLastOrder(ctx context.Context) (Tap, error) { + row := q.db.QueryRowContext(ctx, getLastOrder) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ) + return i, err +} + +const getTapByCategory = `-- name: GetTapByCategory :many +SELECT id, order_id, order_created_at, name, category, created_at +FROM tap +WHERE category = ? +` + +func (q *Queries) GetTapByCategory(ctx context.Context, category string) ([]Tap, error) { + rows, err := q.db.QueryContext(ctx, getTapByCategory, category) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Tap + for rows.Next() { + var i Tap + if err := rows.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ); 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 getTapByID = `-- name: GetTapByID :one +SELECT id, order_id, order_created_at, name, category, created_at +FROM tap +WHERE id = ? +` + +func (q *Queries) GetTapByID(ctx context.Context, id int64) (Tap, error) { + row := q.db.QueryRowContext(ctx, getTapByID, id) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ) + return i, err +} + +const getTapByOrderID = `-- name: GetTapByOrderID :one + +SELECT id, order_id, order_created_at, name, category, created_at +FROM tap +WHERE order_id = ? +` + +// Other +func (q *Queries) GetTapByOrderID(ctx context.Context, orderID int64) (Tap, error) { + row := q.db.QueryRowContext(ctx, getTapByOrderID, orderID) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ) + return i, err +} + +const updateTap = `-- name: UpdateTap :one +UPDATE tap +SET order_id = ?, order_created_at = ?, name = ?, category = ? +WHERE id = ? +RETURNING id, order_id, order_created_at, name, category, created_at +` + +type UpdateTapParams struct { + OrderID int64 + OrderCreatedAt time.Time + Name string + Category string + ID int64 +} + +func (q *Queries) UpdateTap(ctx context.Context, arg UpdateTapParams) (Tap, error) { + row := q.db.QueryRowContext(ctx, updateTap, + arg.OrderID, + arg.OrderCreatedAt, + arg.Name, + arg.Category, + arg.ID, + ) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go new file mode 100644 index 0000000..6613e19 --- /dev/null +++ b/internal/pkg/tap/api.go @@ -0,0 +1,39 @@ +package tap + +import ( + "errors" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +const apiURL = "https://tap.zeus.gent/recent" + +type orderResponseItem struct { + OrderID int64 `json:"order_id"` + OrderCreatedAt time.Time `json:"order_created_at"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` +} + +type orderResponse struct { + Orders []orderResponseItem `json:"orders"` +} + +func (t *Tap) getOrders() ([]orderResponseItem, error) { + zap.S().Info("Tap: Getting orders") + + req := fiber.Get(apiURL) + + res := new(orderResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + if status != fiber.StatusOK { + return nil, errors.New("error getting orders") + } + + return res.Orders, nil +} diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go new file mode 100644 index 0000000..a4d50f8 --- /dev/null +++ b/internal/pkg/tap/tap.go @@ -0,0 +1,109 @@ +// Package tap provides all tap related logic +package tap + +import ( + "context" + "database/sql" + "errors" + "slices" + "strings" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/util" +) + +// Tap represents a tap instance +type Tap struct { + db *db.DB + beers []string +} + +var defaultBeers = []string{ + "Schelfaut", + "Duvel", + "Fourchette", + "Jupiler", + "Karmeliet", + "Kriek", + "Chouffe", + "Maes", + "Somersby", + "Sportzot", + "Stella", +} + +// New creates a new tap instance +func New(db *db.DB) *Tap { + beers := config.GetDefaultStringSlice("tap.beers", defaultBeers) + + return &Tap{db: db, beers: beers} +} + +// Update gets all new orders from tap +func (t *Tap) Update() error { + // Get latest order + lastOrder, err := t.db.Queries.GetLastOrder(context.Background()) + if err != nil { + if err != sql.ErrNoRows { + return err + } + + lastOrder = sqlc.Tap{OrderID: -1} + } + + // Get all orders + allOrders, err := t.getOrders() + if err != nil { + return err + } + + // Only keep the new orders + orders := util.SliceFilter(allOrders, func(o orderResponseItem) bool { return o.OrderID > lastOrder.OrderID }) + + if len(orders) == 0 { + return nil + } + + // Adjust categories + t.adjustCategories(orders) + + // Insert all new orders + errs := make([]error, 0) + for _, order := range orders { + _, err := t.db.Queries.CreateTap(context.Background(), sqlc.CreateTapParams{ + OrderID: order.OrderID, + OrderCreatedAt: order.OrderCreatedAt, + Name: order.ProductName, + Category: order.ProductCategory, + }) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// adjustCategories changes the categories of the orders to the custom ones +func (t *Tap) adjustCategories(orders []orderResponseItem) { + for i := range orders { + order := &orders[i] // Take a pointer to the struct to modify it directly + switch order.ProductCategory { + case "food": + order.ProductCategory = "Food" + case "other": + order.ProductCategory = "Other" + case "beverages": + // Atm only beverages get special categories + if strings.Contains(order.ProductName, "Mate") { + order.ProductCategory = "Mate" + } else if slices.ContainsFunc(t.beers, func(beer string) bool { return strings.Contains(order.ProductName, beer) }) { + order.ProductCategory = "Beer" + } else { + order.ProductCategory = "Soft" + } + } + } +} diff --git a/pkg/util/slice.go b/pkg/util/slice.go index 5e10700..1840f51 100644 --- a/pkg/util/slice.go +++ b/pkg/util/slice.go @@ -1,8 +1,6 @@ // Package util provides utility functions package util -import "strings" - // SliceMap maps a slice of type T to a slice of type U func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { v := make([]U, len(input)) @@ -14,9 +12,20 @@ func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { // SliceStringJoin joins a slice of type T to a string with a separator func SliceStringJoin[T any](input []T, sep string, mapFunc func(T) string) string { - v := make([]string, len(input)) - for i, item := range input { - v[i] = mapFunc(item) + str := "" + for _, item := range input { + str += mapFunc(item) + sep } - return strings.Join(v, sep) + return str[:len(str)-len(sep)] +} + +// SliceFilter filters a slice of type T based on a filter function +func SliceFilter[T any](input []T, filterFunc func(T) bool) []T { + v := make([]T, 0) + for _, item := range input { + if filterFunc(item) { + v = append(v, item) + } + } + return v }