Files
veola/internal/db/queries.go
prosolis edb732ee1f Auction end times, visual flair, and pre-launch cleanup
Auction handling:
- Capture itemEndDate from eBay Browse API and ending_date from ZenMarket
  (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket
  parser (multiple layouts, JST when offset missing).
- Per-row "Ends" countdown column + "Ending soon" banner on results pages,
  live-ticked by flair.js with urgent/critical tinting under 1h/5m.
- Backfill ends_at for known auctions when their URL reappears in a poll
  (dedup hit no longer drops the new end time).
- Hide ended auctions from result listings by default via
  ResultsQuery.ExcludeEnded; rows stay in the DB.

Visual flair:
- Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift.
- htmx swap fade-in via transient .v-just-swapped class.
- Count-up animation on dashboard stats. All animations gated behind
  prefers-reduced-motion.

eBay condition + region filters (auctions-style scoping):
- items.condition and items.region columns; threaded through item form,
  CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so
  cache invalidates when these change.
- ebay.SearchParams gains conditionIds and itemLocationCountry filters.

Run Now reload + countdown engine:
- Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so
  the entire results view — best price, chart, badge, last polled —
  reflects the new poll, instead of swapping just one partial.

Pre-launch hardening (P1 set):
- auth.EqualizeLoginTiming on no-such-user branch.
- (*App).serverError centralizes 500s; replaces err.Error() leaks across
  results/settings/items/users/dashboard handlers.
- main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s
  alongside the existing ReadHeaderTimeout.
- noListFS wrapper blocks static directory listings.
- Credential fields in settings no longer render value=; blank submission
  preserves the saved value, with per-field "Saved in settings / Set in
  config.toml / Not set" status indicator.

Misc:
- -debug flag wires slog to LevelDebug; raw ZenMarket items logged for
  format diagnosis.
- /healthz public endpoint for reverse-proxy probes.
- deploy/veola.service systemd unit template (hardening flags, single
  ReadWritePaths=/var/lib/veola).
- handlers_test.go covers /healthz, setup-gate redirect, auth gate, and
  /login render with httptest + in-memory sqlite.
- best_price_currency on items; templates pick the right symbol per row.
- .gitignore now excludes *.log / veola-debug.log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:47:09 -07:00

890 lines
26 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, 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, &region,
&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
}