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, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, active, best_price, best_price_currency, 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.Condition), nullStr(it.Region), nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), boolToInt(it.UsePriceComparison), boolToInt(it.Active), nullFloat(it.BestPrice), nullStr(it.BestPriceCurrency), 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 = ?, condition = ?, region = ?, 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.Condition), nullStr(it.Region), 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 bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField sql.NullString ) if best != nil { bestPrice = nullFloat(best.BestPrice) bestCurrency = nullStr(best.BestPriceCurrency) 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_currency = ?, best_price_store = ?, best_price_url = ?, best_price_image_url = ?, best_price_title = ?, last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ? WHERE id = ? `, bestPrice, bestCurrency, 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, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, active, last_polled_at, last_poll_error, best_price, best_price_currency, 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, condition, region sql.NullString actorA, actorS, actorP sql.NullString ntfyTopic, lastPollErr sql.NullString bestCurrency, 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, &condition, ®ion, &actorA, &actorS, &actorP, &usePC, &active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &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.Condition = condition.String it.Region = region.String it.ActorActive = actorA.String it.ActorSold = actorS.String it.ActorPriceCompare = actorP.String it.NtfyTopic = ntfyTopic.String it.LastPollError = lastPollErr.String it.BestPriceCurrency = bestCurrency.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, ends_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), nullTime(r.EndsAt), ) 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 } // BackfillResultEndsAt sets ends_at on an existing result row that's currently // missing one. Used during polling: when a known auction listing reappears in // a poll result, we still want its end time recorded even though the row // itself isn't being re-inserted (URL dedup). func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr string, endsAt time.Time) error { if urlStr == "" { return nil } _, err := s.DB.ExecContext(ctx, `UPDATE results SET ends_at = ? WHERE item_id = ? AND url = ? AND ends_at IS NULL`, endsAt, itemID, urlStr) 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" // ExcludeEnded drops rows whose ends_at is in the past. Fixed-price // listings (ends_at IS NULL) are kept regardless: they don't expire. ExcludeEnded bool } 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{} var conds []string if q.ItemID != 0 { conds = append(conds, `item_id = ?`) args = append(args, q.ItemID) } if q.ExcludeEnded { conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`) args = append(args, time.Now().UTC()) } where := "" if len(conds) > 0 { where = `WHERE ` + strings.Join(conds, ` AND `) } 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, ends_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 endsAt sql.NullTime ) if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt, &endsAt); 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 r.EndsAt = ptrTime(endsAt) out = append(out, r) } return out, rows.Err() } // EndingSoon is a compact projection for the "ending soon" strip: the single // nearest-to-end auction across the user's results, with enough context to // render and link to it. type EndingSoon struct { ItemID int64 ItemName string Title string URL string EndsAt time.Time } // NextEndingResult returns the soonest-ending result whose ends_at lies in the // window (now, now+within]. If itemID is 0 the search spans all items; nil is // returned when no auction falls inside the window. func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) { now := time.Now().UTC() cutoff := now.Add(within) q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name FROM results r JOIN items i ON r.item_id = i.id WHERE r.ends_at IS NOT NULL AND r.ends_at > ? AND r.ends_at <= ?` args := []any{now, cutoff} if itemID != 0 { q += ` AND r.item_id = ?` args = append(args, itemID) } q += ` ORDER BY r.ends_at ASC LIMIT 1` row := s.DB.QueryRowContext(ctx, q, args...) var ( e EndingSoon title sql.NullString urlS sql.NullString endsAt time.Time ) if err := row.Scan(&e.ItemID, &title, &urlS, &endsAt, &e.ItemName); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } e.Title = s.dec(title.String) e.URL = urlS.String e.EndsAt = endsAt return &e, nil } // CountResults returns the row count matching the same filters ListResults // applies. Pagination relies on this matching the visible list, so it must // honor ExcludeEnded too. func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded bool) (int, error) { var n int q := `SELECT COUNT(*) FROM results` args := []any{} var conds []string if itemID != 0 { conds = append(conds, `item_id = ?`) args = append(args, itemID) } if excludeEnded { conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`) args = append(args, time.Now().UTC()) } if len(conds) > 0 { q += ` WHERE ` + strings.Join(conds, ` AND `) } 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 }