Files
veola/internal/db/queries.go
prosolis 1ae2c50b9a Add eBay Browse API integration with daily call quota
eBay marketplaces are now polled through eBay's official Buy > Browse API (client-credentials OAuth2) instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari. Browse API calls are tracked per day in a new ebay_api_usage table and capped (default 5000, configurable) on eBay's Pacific-time reset clock, so polling halts before the limit is hit. Credentials live in config.toml [ebay] and are overridable via /settings, which also surfaces the day's running call count.

Also carries the server.secure_cookies config plumbing (field, accessor, example) consumed by the following commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:10:39 -07:00

802 lines
23 KiB
Go

package db
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"veola/internal/crypto"
"veola/internal/models"
)
// Store wraps a *sql.DB with the encryption key used for column-level crypto.
type Store struct {
DB *sql.DB
Key []byte
}
func NewStore(db *sql.DB, key []byte) *Store {
return &Store{DB: db, Key: key}
}
// enc encrypts plaintext, logging and returning empty string on failure.
func (s *Store) enc(plain string) string {
if plain == "" {
return ""
}
v, err := crypto.Encrypt(s.Key, plain)
if err != nil {
slog.Error("encrypt failed", "err", err)
return ""
}
return v
}
// dec decrypts a value; on failure returns "" per spec line 333.
func (s *Store) dec(v string) string {
if v == "" {
return ""
}
out, err := crypto.Decrypt(s.Key, v)
if err != nil {
slog.Error("decrypt failed", "err", err)
return ""
}
return out
}
func nullStr(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
func nullFloat(f *float64) sql.NullFloat64 {
if f == nil {
return sql.NullFloat64{}
}
return sql.NullFloat64{Float64: *f, Valid: true}
}
func nullTime(t *time.Time) sql.NullTime {
if t == nil {
return sql.NullTime{}
}
return sql.NullTime{Time: *t, Valid: true}
}
func ptrFloat(f sql.NullFloat64) *float64 {
if !f.Valid {
return nil
}
v := f.Float64
return &v
}
func ptrTime(t sql.NullTime) *time.Time {
if !t.Valid {
return nil
}
v := t.Time
return &v
}
// ============ users ============
func (s *Store) UserCount(ctx context.Context) (int, error) {
var n int
err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) {
res, err := s.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
username, hash, string(role))
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
row := s.DB.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`,
username)
var u models.User
var role string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
u.Role = models.Role(role)
return &u, nil
}
func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
row := s.DB.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?`, id)
var u models.User
var role string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
u.Role = models.Role(role)
return &u, nil
}
func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
rows, err := s.DB.QueryContext(ctx,
`SELECT id, username, password_hash, role, created_at FROM users ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
var u models.User
var role string
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = models.Role(role)
out = append(out, u)
}
return out, rows.Err()
}
func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error {
_, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
return err
}
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
return err
}
// ============ settings ============
func (s *Store) GetSetting(ctx context.Context, key string) (string, error) {
var v sql.NullString
err := s.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if !v.Valid {
return "", nil
}
return s.dec(v.String), nil
}
func (s *Store) GetAllSettings(ctx context.Context) (map[string]string, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var k string
var v sql.NullString
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
if v.Valid {
out[k] = s.dec(v.String)
} else {
out[k] = ""
}
}
return out, rows.Err()
}
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
enc := s.enc(value)
_, err := s.DB.ExecContext(ctx, `
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`, key, enc)
return err
}
// ============ ebay api usage ============
// ebayResetLoc is the timezone eBay's API rate limits reset in: midnight
// Pacific (it observes US DST). If the zone database is somehow unavailable
// we fall back to UTC rather than failing — main.go embeds time/tzdata so in
// practice the lookup always succeeds.
var ebayResetLoc = func() *time.Location {
loc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
return time.UTC
}
return loc
}()
// ebayUsageDay returns the date key used to bucket eBay API calls, aligned to
// eBay's own quota reset (midnight US Pacific).
func ebayUsageDay() string {
return time.Now().In(ebayResetLoc).Format("2006-01-02")
}
// EbayUsageToday returns the number of eBay Browse API calls recorded for the
// current UTC day. A missing row counts as zero.
func (s *Store) EbayUsageToday(ctx context.Context) (int, error) {
var n int
err := s.DB.QueryRowContext(ctx,
`SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, ebayUsageDay()).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return n, nil
}
// IncrementEbayUsage records one eBay Browse API call against the current UTC
// day and returns the new running total.
func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) {
day := ebayUsageDay()
_, err := s.DB.ExecContext(ctx, `
INSERT INTO ebay_api_usage (usage_date, call_count) VALUES (?, 1)
ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1
`, day)
if err != nil {
return 0, err
}
var n int
if err := s.DB.QueryRowContext(ctx,
`SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, day).Scan(&n); err != nil {
return 0, err
}
return n, nil
}
// ============ items ============
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
res, err := s.DB.ExecContext(ctx, `
INSERT INTO items (
name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
listing_type,
actor_active, actor_sold, actor_price_compare, use_price_comparison,
active, best_price, best_price_store, best_price_url, best_price_image_url,
best_price_title, last_polled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
nullStr(it.ListingType),
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
nullFloat(it.BestPrice), nullStr(it.BestPriceStore),
nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)),
nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt),
)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if err := s.SetItemMarketplaces(ctx, id, it.Marketplaces); err != nil {
return 0, err
}
return id, nil
}
func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
if _, err := s.DB.ExecContext(ctx, `
UPDATE items SET
name = ?, search_query = ?, url = ?, category = ?, target_price = ?,
ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?,
include_out_of_stock = ?, min_price = ?, exclude_keywords = ?,
listing_type = ?,
actor_active = ?, actor_sold = ?, actor_price_compare = ?,
use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
nullStr(it.ListingType),
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
it.ID,
); err != nil {
return err
}
return s.SetItemMarketplaces(ctx, it.ID, it.Marketplaces)
}
// SetItemMarketplaces replaces the marketplace list for an item. Order is
// preserved via the `position` column.
func (s *Store) SetItemMarketplaces(ctx context.Context, itemID int64, markets []string) error {
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM item_marketplaces WHERE item_id = ?`, itemID); err != nil {
return err
}
for i, m := range markets {
if m == "" {
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO item_marketplaces (item_id, position, marketplace) VALUES (?, ?, ?)`,
itemID, i, m); err != nil {
return err
}
}
return tx.Commit()
}
// getItemMarketplaces returns the ordered marketplace list for one item.
func (s *Store) getItemMarketplaces(ctx context.Context, itemID int64) ([]string, error) {
rows, err := s.DB.QueryContext(ctx,
`SELECT marketplace FROM item_marketplaces WHERE item_id = ? ORDER BY position`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var m string
if err := rows.Scan(&m); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// loadMarketplacesForItems bulk-loads marketplaces for a list of items in one
// query. Returns a map keyed by item ID.
func (s *Store) loadMarketplacesForItems(ctx context.Context, ids []int64) (map[int64][]string, error) {
out := make(map[int64][]string, len(ids))
if len(ids) == 0 {
return out, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
q := `SELECT item_id, marketplace FROM item_marketplaces WHERE item_id IN (` +
strings.Join(placeholders, ",") + `) ORDER BY item_id, position`
rows, err := s.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int64
var m string
if err := rows.Scan(&id, &m); err != nil {
return nil, err
}
out[id] = append(out[id], m)
}
return out, rows.Err()
}
func (s *Store) SetItemActive(ctx context.Context, id int64, active bool) error {
_, err := s.DB.ExecContext(ctx,
`UPDATE items SET active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
boolToInt(active), id)
return err
}
func (s *Store) DeleteItem(ctx context.Context, id int64) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id)
return err
}
func (s *Store) GetItem(ctx context.Context, id int64) (*models.Item, error) {
row := s.DB.QueryRowContext(ctx, itemSelect+` WHERE id = ?`, id)
it, err := scanItem(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
s.decryptItem(it)
markets, err := s.getItemMarketplaces(ctx, id)
if err != nil {
return nil, err
}
it.Marketplaces = markets
return it, nil
}
func (s *Store) ListItems(ctx context.Context) ([]models.Item, error) {
return s.listItemsWhere(ctx, itemSelect+` ORDER BY name COLLATE NOCASE`)
}
func (s *Store) ListActiveItems(ctx context.Context) ([]models.Item, error) {
return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 ORDER BY id`)
}
func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]models.Item, error) {
rows, err := s.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Item
var ids []int64
for rows.Next() {
it, err := scanItem(rows)
if err != nil {
return nil, err
}
s.decryptItem(it)
out = append(out, *it)
ids = append(ids, it.ID)
}
if err := rows.Err(); err != nil {
return nil, err
}
markets, err := s.loadMarketplacesForItems(ctx, ids)
if err != nil {
return nil, err
}
for i := range out {
out[i].Marketplaces = markets[out[i].ID]
}
return out, nil
}
func (s *Store) ListCategories(ctx context.Context) ([]string, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT DISTINCT category FROM items WHERE category IS NOT NULL AND category != '' ORDER BY category COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error.
func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error {
var (
bestPrice sql.NullFloat64
bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
)
if best != nil {
bestPrice = nullFloat(best.BestPrice)
bestStore = nullStr(best.BestPriceStore)
bestURL = nullStr(s.enc(best.BestPriceURL))
bestImage = nullStr(s.enc(best.BestPriceImageURL))
bestTitle = nullStr(s.enc(best.BestPriceTitle))
}
if errMsg != "" {
errField = nullStr(s.enc(errMsg))
}
_, err := s.DB.ExecContext(ctx, `
UPDATE items SET
best_price = ?, best_price_store = ?, best_price_url = ?,
best_price_image_url = ?, best_price_title = ?,
last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ?
WHERE id = ?
`, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id)
return err
}
const itemSelect = `
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
listing_type,
actor_active, actor_sold, actor_price_compare, use_price_comparison,
active, last_polled_at, last_poll_error, best_price, best_price_store,
best_price_url, best_price_image_url, best_price_title, created_at, updated_at
FROM items
`
type rowScanner interface {
Scan(dest ...any) error
}
func scanItem(r rowScanner) (*models.Item, error) {
var (
it models.Item
searchQuery, urlS, category, listingType sql.NullString
excludeKw sql.NullString
actorA, actorS, actorP sql.NullString
ntfyTopic, lastPollErr sql.NullString
bestStore, bestURL, bestImage, bestTitle sql.NullString
targetPrice, minPrice, bestPrice sql.NullFloat64
includeOOS, usePC, active int
lastPolledAt sql.NullTime
)
if err := r.Scan(
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
&listingType,
&actorA, &actorS, &actorP, &usePC,
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore,
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
it.ExcludeKeywords = excludeKw.String
it.MinPrice = ptrFloat(minPrice)
it.SearchQuery = searchQuery.String
it.URL = urlS.String
it.Category = category.String
it.ListingType = listingType.String
it.ActorActive = actorA.String
it.ActorSold = actorS.String
it.ActorPriceCompare = actorP.String
it.NtfyTopic = ntfyTopic.String
it.LastPollError = lastPollErr.String
it.BestPriceStore = bestStore.String
it.BestPriceURL = bestURL.String
it.BestPriceImageURL = bestImage.String
it.BestPriceTitle = bestTitle.String
it.TargetPrice = ptrFloat(targetPrice)
it.BestPrice = ptrFloat(bestPrice)
it.IncludeOutOfStock = includeOOS != 0
it.UsePriceComparison = usePC != 0
it.Active = active != 0
it.LastPolledAt = ptrTime(lastPolledAt)
return &it, nil
}
func (s *Store) decryptItem(it *models.Item) *models.Item {
it.SearchQuery = s.dec(it.SearchQuery)
it.ExcludeKeywords = s.dec(it.ExcludeKeywords)
it.NtfyTopic = s.dec(it.NtfyTopic)
it.LastPollError = s.dec(it.LastPollError)
it.BestPriceURL = s.dec(it.BestPriceURL)
it.BestPriceImageURL = s.dec(it.BestPriceImageURL)
it.BestPriceTitle = s.dec(it.BestPriceTitle)
return it
}
// ============ results ============
func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) {
res, err := s.DB.ExecContext(ctx, `
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`,
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
nullStr(s.enc(r.MatchedQuery)),
boolToInt(r.Alerted),
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// ResultExists returns true if a row with this item_id and url already exists.
// URL is stored as plaintext per agreed deviation #1, so equality works.
func (s *Store) ResultExists(ctx context.Context, itemID int64, url string) (bool, error) {
if url == "" {
return false, nil
}
var n int
err := s.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM results WHERE item_id = ? AND url = ?`, itemID, url,
).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil
}
func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error {
_, err := s.DB.ExecContext(ctx, `UPDATE results SET alerted = 1 WHERE id = ?`, id)
return err
}
type ResultsQuery struct {
ItemID int64 // 0 = all items
Limit int
Offset int
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
}
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
order := `found_at DESC`
switch q.Order {
case "price_asc":
order = `price ASC NULLS LAST`
case "price_desc":
order = `price DESC NULLS LAST`
case "found_asc":
order = `found_at ASC`
}
limit := q.Limit
if limit <= 0 {
limit = 20
}
args := []any{}
where := ""
if q.ItemID != 0 {
where = `WHERE item_id = ?`
args = append(args, q.ItemID)
}
args = append(args, limit, q.Offset)
rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(`
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at
FROM results %s ORDER BY %s LIMIT ? OFFSET ?
`, where, order), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Result
for rows.Next() {
var (
r models.Result
title, urlS, source, imageS, matchQ sql.NullString
price sql.NullFloat64
alerted int
)
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil {
return nil, err
}
r.Title = s.dec(title.String)
r.URL = urlS.String
r.Source = source.String
r.ImageURL = s.dec(imageS.String)
r.MatchedQuery = s.dec(matchQ.String)
r.Price = ptrFloat(price)
r.Alerted = alerted != 0
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) {
var n int
q := `SELECT COUNT(*) FROM results`
args := []any{}
if itemID != 0 {
q += ` WHERE item_id = ?`
args = append(args, itemID)
}
err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n)
return n, err
}
// ============ price_history ============
func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) error {
if p.PolledAt.IsZero() {
_, err := s.DB.ExecContext(ctx,
`INSERT INTO price_history (item_id, price, store) VALUES (?, ?, ?)`,
p.ItemID, p.Price, s.enc(p.Store))
return err
}
_, err := s.DB.ExecContext(ctx,
`INSERT INTO price_history (item_id, price, store, polled_at) VALUES (?, ?, ?, ?)`,
p.ItemID, p.Price, s.enc(p.Store), p.PolledAt)
return err
}
func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) {
rows, err := s.DB.QueryContext(ctx,
`SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`,
itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.PricePoint
for rows.Next() {
var p models.PricePoint
var store sql.NullString
if err := rows.Scan(&p.ID, &p.ItemID, &p.Price, &store, &p.PolledAt); err != nil {
return nil, err
}
p.Store = s.dec(store.String)
out = append(out, p)
}
return out, rows.Err()
}
// ============ stats ============
type DashboardStats struct {
TotalItems int
ActiveItems int
ResultsToday int
AlertsToday int
PotentialSpend float64
PricedItemCount int
UnpricedCount int
MoneySaved float64
SavedItemCount int
}
func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
d := &DashboardStats{}
queries := map[string]any{
`SELECT COUNT(*) FROM items`: &d.TotalItems,
`SELECT COUNT(*) FROM items WHERE active = 1`: &d.ActiveItems,
`SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')`: &d.ResultsToday,
`SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')`: &d.AlertsToday,
}
for q, dst := range queries {
if err := s.DB.QueryRowContext(ctx, q).Scan(dst); err != nil {
return nil, err
}
}
if err := s.DB.QueryRowContext(ctx, `
SELECT COALESCE(SUM(best_price), 0), COUNT(*)
FROM items WHERE active = 1 AND best_price IS NOT NULL
`).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil {
return nil, err
}
if err := s.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL
`).Scan(&d.UnpricedCount); err != nil {
return nil, err
}
rows, err := s.DB.QueryContext(ctx, `
SELECT i.best_price, AVG(p.price) AS avg_price
FROM items i
JOIN price_history p ON p.item_id = i.id
WHERE i.active = 1 AND i.best_price IS NOT NULL
GROUP BY i.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var bp, avg float64
if err := rows.Scan(&bp, &avg); err != nil {
return nil, err
}
if bp < avg {
d.MoneySaved += avg - bp
d.SavedItemCount++
}
}
return d, rows.Err()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}