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() } // DeleteUser removes a user and all items they own. Owned items are deleted // explicitly (rather than relying on an ON DELETE CASCADE that exists only on // fresh databases) so results, price history, and marketplace rows cascade off // the always-present items foreign keys. func (s *Store) DeleteUser(ctx context.Context, id int64) error { tx, err := s.DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE user_id = ?`, id); err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id); err != nil { return err } return tx.Commit() } // ============ 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 ( user_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, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url, best_price_title, last_polled_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, it.UserID, 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 } // ListItems returns every item regardless of owner. Reserved for system-level // callers (e.g. the scheduler); user-facing handlers must use ListItemsForUser. func (s *Store) ListItems(ctx context.Context) ([]models.Item, error) { return s.listItemsWhere(ctx, itemSelect+` ORDER BY name COLLATE NOCASE`) } // ListItemsForUser returns only the items owned by the given user. func (s *Store) ListItemsForUser(ctx context.Context, userID int64) ([]models.Item, error) { return s.listItemsWhere(ctx, itemSelect+` WHERE user_id = ? ORDER BY name COLLATE NOCASE`, userID) } // ListActiveItems returns every active item across all owners. The scheduler // polls these on a global cadence; ownership only governs who can see and edit // an item, not whether it is polled. func (s *Store) ListActiveItems(ctx context.Context) ([]models.Item, error) { return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 ORDER BY id`) } // ListActiveItemsForUser returns one user's active items, for per-user digests. func (s *Store) ListActiveItemsForUser(ctx context.Context, userID int64) ([]models.Item, error) { return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 AND user_id = ? ORDER BY id`, userID) } 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 } // ListCategoriesForUser returns the distinct categories used across one user's // items, for the category filter and the add-item datalist. func (s *Store) ListCategoriesForUser(ctx context.Context, userID int64) ([]string, error) { rows, err := s.DB.QueryContext(ctx, `SELECT DISTINCT category FROM items WHERE user_id = ? AND category IS NOT NULL AND category != '' ORDER BY category COLLATE NOCASE`, userID) 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() } 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, user_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 userID sql.NullInt64 ) if err := r.Scan( &it.ID, &userID, &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.UserID = userID.Int64 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 // OwnerID, when non-zero, restricts results to items owned by that user. // Combine with ItemID=0 to scope "all of my items". OwnerID int64 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 // OnlyAlerted restricts to rows that triggered a deal alert. OnlyAlerted 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.OwnerID != 0 { conds = append(conds, `item_id IN (SELECT id FROM items WHERE user_id = ?)`) args = append(args, q.OwnerID) } if q.ExcludeEnded { conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`) args = append(args, time.Now().UTC()) } if q.OnlyAlerted { conds = append(conds, `alerted = 1`) } 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, ownerID 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) } if ownerID != 0 { q += ` AND i.user_id = ?` args = append(args, ownerID) } 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 } // GetDashboardStatsForUser returns dashboard stats scoped to one user's items. func (s *Store) GetDashboardStatsForUser(ctx context.Context, userID int64) (*DashboardStats, error) { return s.dashboardStats(ctx, userID) } // GetDashboardStats returns stats across all owners. Reserved for system-level // callers; user-facing views must use GetDashboardStatsForUser. func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { return s.dashboardStats(ctx, 0) } // dashboardStats computes the dashboard aggregates, optionally restricted to a // single owner. When ownerID is 0 it spans every item. func (s *Store) dashboardStats(ctx context.Context, ownerID int64) (*DashboardStats, error) { d := &DashboardStats{} // itemOwner is an AND-able predicate on the items table; resultOwner is the // equivalent for the results table (scoped via the item it belongs to). itemOwner, resultOwner := "", "" var ia, ra []any if ownerID != 0 { itemOwner = ` AND user_id = ?` ia = []any{ownerID} resultOwner = ` AND item_id IN (SELECT id FROM items WHERE user_id = ?)` ra = []any{ownerID} } countQueries := []struct { q string dst *int arg []any }{ {`SELECT COUNT(*) FROM items WHERE 1=1` + itemOwner, &d.TotalItems, ia}, {`SELECT COUNT(*) FROM items WHERE active = 1` + itemOwner, &d.ActiveItems, ia}, {`SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')` + resultOwner, &d.ResultsToday, ra}, {`SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')` + resultOwner, &d.AlertsToday, ra}, } for _, cq := range countQueries { if err := s.DB.QueryRowContext(ctx, cq.q, cq.arg...).Scan(cq.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`+itemOwner, ia...).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`+itemOwner, ia...).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')`+ func() string { if ownerID != 0 { return ` AND i.user_id = ?` } return `` }()+` GROUP BY i.id `, ia...) 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 }