Files
veola/internal/db/queries.go
2026-06-20 11:57:54 -07:00

1078 lines
33 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
}
const userSelect = `SELECT id, username, password_hash, role, email, auth_source,
email_deal_alerts, email_weekly_digest, created_at FROM users`
func scanUser(r rowScanner) (*models.User, error) {
var (
u models.User
role, authSource string
email sql.NullString
dealAlerts, digest int
)
if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &email, &authSource,
&dealAlerts, &digest, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = models.Role(role)
u.Email = email.String
u.AuthSource = authSource
u.EmailDealAlerts = dealAlerts != 0
u.EmailWeeklyDigest = digest != 0
return &u, nil
}
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) {
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return u, err
}
func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return u, err
}
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
if email == "" {
return nil, nil
}
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE email = ?`, email))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return u, err
}
func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
rows, err := s.DB.QueryContext(ctx, userSelect+` ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// UpsertForwardUser find-or-creates a user keyed by email for forward-auth
// (Authentik). On an existing row it re-syncs role + username so the IdP stays
// the source of truth. Returns the resolved user. New rows are marked
// auth_source='forward' and carry an unusable password hash (forward-auth
// users never log in with a local password).
func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, role models.Role) (*models.User, error) {
if email == "" {
return nil, errors.New("forward-auth requires an email")
}
existing, err := s.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
if existing != nil {
if existing.Role != role || (username != "" && existing.Username != username) {
newName := existing.Username
if username != "" {
newName = username
}
if _, err := s.DB.ExecContext(ctx,
`UPDATE users SET role = ?, username = ? WHERE id = ?`,
string(role), newName, existing.ID); err != nil {
return nil, err
}
existing.Role = role
existing.Username = newName
}
return existing, nil
}
if username == "" {
username = email
}
res, err := s.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role, email, auth_source) VALUES (?, ?, ?, ?, 'forward')`,
username, "!forward-auth", string(role), email)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
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
}
// UpdateUserEmailPrefs sets a user's notification email and per-channel email
// opt-ins. Email "" clears the address (and silences email for that user).
func (s *Store) UpdateUserEmailPrefs(ctx context.Context, id int64, email string, dealAlerts, weeklyDigest bool) error {
_, err := s.DB.ExecContext(ctx,
`UPDATE users SET email = ?, email_deal_alerts = ?, email_weekly_digest = ? WHERE id = ?`,
nullStr(email), boolToInt(dealAlerts), boolToInt(weeklyDigest), id)
return err
}
// UsersWithDealAlerts returns users who opted into deal-alert email and have a
// destination address. UsersWithWeeklyDigest is the digest equivalent.
func (s *Store) UsersWithDealAlerts(ctx context.Context) ([]models.User, error) {
return s.usersWhereEmailPref(ctx, "email_deal_alerts")
}
func (s *Store) UsersWithWeeklyDigest(ctx context.Context) ([]models.User, error) {
return s.usersWhereEmailPref(ctx, "email_weekly_digest")
}
func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.User, error) {
rows, err := s.DB.QueryContext(ctx,
userSelect+` WHERE `+col+` = 1 AND email IS NOT NULL AND email != '' ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.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
}
// ============ apify api usage ============
// apifyUsageDay buckets Apify runs by UTC calendar day. Unlike eBay (which has
// its own Pacific-time quota reset) Apify billing is a soft estimate here, so a
// plain UTC day keeps the month math simple.
func apifyUsageDay() string {
return time.Now().UTC().Format("2006-01-02")
}
// IncrementApifyUsage records one Apify actor run against the current UTC day.
func (s *Store) IncrementApifyUsage(ctx context.Context) error {
_, err := s.DB.ExecContext(ctx, `
INSERT INTO apify_api_usage (usage_date, call_count) VALUES (?, 1)
ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1
`, apifyUsageDay())
return err
}
// ApifyUsageToday returns Apify runs recorded for the current UTC day.
func (s *Store) ApifyUsageToday(ctx context.Context) (int, error) {
var n int
err := s.DB.QueryRowContext(ctx,
`SELECT call_count FROM apify_api_usage WHERE usage_date = ?`, apifyUsageDay()).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return n, err
}
// ApifyUsageMonth returns total Apify runs in the current UTC calendar month.
func (s *Store) ApifyUsageMonth(ctx context.Context) (int, error) {
prefix := time.Now().UTC().Format("2006-01") + "-%"
var n sql.NullInt64
err := s.DB.QueryRowContext(ctx,
`SELECT SUM(call_count) FROM apify_api_usage WHERE usage_date LIKE ?`, prefix).Scan(&n)
if err != nil {
return 0, err
}
return int(n.Int64), 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.
// Exception: yahoo-auctions-jp is auction-only — NULL ends_at there means
// the actor's ending_date didn't parse, and those rows are also dropped.
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 AND source != 'yahoo-auctions-jp') 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 AND source != 'yahoo-auctions-jp') 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
}
// LoadRecentPriceHistory bulk-loads the last `perItem` price points (oldest →
// newest) for each item id. Returns a map keyed by item id; items with no
// history are absent from the map. Used by the items list page to render a
// sparkline per row without N+1 queries.
func (s *Store) LoadRecentPriceHistory(ctx context.Context, ids []int64, perItem int) (map[int64][]models.PricePoint, error) {
out := make(map[int64][]models.PricePoint, len(ids))
if len(ids) == 0 || perItem <= 0 {
return out, nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
args = append(args, perItem)
q := `
WITH ranked AS (
SELECT item_id, price, store, polled_at,
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY polled_at DESC) AS rn
FROM price_history
WHERE item_id IN (` + strings.Join(placeholders, ",") + `)
)
SELECT item_id, price, store, polled_at FROM ranked WHERE rn <= ?
ORDER BY item_id, polled_at ASC
`
rows, err := s.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p models.PricePoint
var store sql.NullString
if err := rows.Scan(&p.ItemID, &p.Price, &store, &p.PolledAt); err != nil {
return nil, err
}
p.Store = s.dec(store.String)
out[p.ItemID] = append(out[p.ItemID], p)
}
return out, rows.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
}
// "Money saved" compares each item's current best price against its recent
// market average. The average is windowed to the last 30 days so stale
// historical prices don't anchor the baseline; items whose only price
// history predates the window simply don't contribute an estimate.
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
AND p.polled_at >= datetime('now', '-30 day')
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
}