- Bulk-load recent price points per item and render a sparkline in the items list (new LoadRecentPriceHistory query avoids N+1). - Add retro.css visual layer and refreshed login/items/layout styling. - Swap the logo from webp to avif. - Pin htmx/Chart.js/Tailwind/templ versions in the Makefile with vendor / tools / update-deps targets; README documents the dependency-bump flow and the hardened systemd deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
933 lines
28 KiB
Go
933 lines
28 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, ®ion,
|
|
&actorA, &actorS, &actorP, &usePC,
|
|
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &bestStore,
|
|
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
it.ExcludeKeywords = excludeKw.String
|
|
it.MinPrice = ptrFloat(minPrice)
|
|
it.SearchQuery = searchQuery.String
|
|
it.URL = urlS.String
|
|
it.Category = category.String
|
|
it.ListingType = listingType.String
|
|
it.Condition = condition.String
|
|
it.Region = region.String
|
|
it.ActorActive = actorA.String
|
|
it.ActorSold = actorS.String
|
|
it.ActorPriceCompare = actorP.String
|
|
it.NtfyTopic = ntfyTopic.String
|
|
it.LastPollError = lastPollErr.String
|
|
it.BestPriceCurrency = bestCurrency.String
|
|
it.BestPriceStore = bestStore.String
|
|
it.BestPriceURL = bestURL.String
|
|
it.BestPriceImageURL = bestImage.String
|
|
it.BestPriceTitle = bestTitle.String
|
|
it.TargetPrice = ptrFloat(targetPrice)
|
|
it.BestPrice = ptrFloat(bestPrice)
|
|
it.IncludeOutOfStock = includeOOS != 0
|
|
it.UsePriceComparison = usePC != 0
|
|
it.Active = active != 0
|
|
it.LastPolledAt = ptrTime(lastPolledAt)
|
|
return &it, nil
|
|
}
|
|
|
|
func (s *Store) decryptItem(it *models.Item) *models.Item {
|
|
it.SearchQuery = s.dec(it.SearchQuery)
|
|
it.ExcludeKeywords = s.dec(it.ExcludeKeywords)
|
|
it.NtfyTopic = s.dec(it.NtfyTopic)
|
|
it.LastPollError = s.dec(it.LastPollError)
|
|
it.BestPriceURL = s.dec(it.BestPriceURL)
|
|
it.BestPriceImageURL = s.dec(it.BestPriceImageURL)
|
|
it.BestPriceTitle = s.dec(it.BestPriceTitle)
|
|
return it
|
|
}
|
|
|
|
// ============ results ============
|
|
|
|
func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) {
|
|
res, err := s.DB.ExecContext(ctx, `
|
|
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
|
|
`,
|
|
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
|
|
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
|
|
nullStr(s.enc(r.MatchedQuery)),
|
|
boolToInt(r.Alerted),
|
|
nullTime(r.EndsAt),
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// ResultExists returns true if a row with this item_id and url already exists.
|
|
// URL is stored as plaintext per agreed deviation #1, so equality works.
|
|
func (s *Store) ResultExists(ctx context.Context, itemID int64, url string) (bool, error) {
|
|
if url == "" {
|
|
return false, nil
|
|
}
|
|
var n int
|
|
err := s.DB.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM results WHERE item_id = ? AND url = ?`, itemID, url,
|
|
).Scan(&n)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error {
|
|
_, err := s.DB.ExecContext(ctx, `UPDATE results SET alerted = 1 WHERE id = ?`, id)
|
|
return err
|
|
}
|
|
|
|
// BackfillResultEndsAt sets ends_at on an existing result row that's currently
|
|
// missing one. Used during polling: when a known auction listing reappears in
|
|
// a poll result, we still want its end time recorded even though the row
|
|
// itself isn't being re-inserted (URL dedup).
|
|
func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr string, endsAt time.Time) error {
|
|
if urlStr == "" {
|
|
return nil
|
|
}
|
|
_, err := s.DB.ExecContext(ctx,
|
|
`UPDATE results SET ends_at = ? WHERE item_id = ? AND url = ? AND ends_at IS NULL`,
|
|
endsAt, itemID, urlStr)
|
|
return err
|
|
}
|
|
|
|
type ResultsQuery struct {
|
|
ItemID int64 // 0 = all items
|
|
Limit int
|
|
Offset int
|
|
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
|
|
// ExcludeEnded drops rows whose ends_at is in the past. Fixed-price
|
|
// listings (ends_at IS NULL) are kept regardless: they don't expire.
|
|
ExcludeEnded bool
|
|
}
|
|
|
|
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
|
|
order := `found_at DESC`
|
|
switch q.Order {
|
|
case "price_asc":
|
|
order = `price ASC NULLS LAST`
|
|
case "price_desc":
|
|
order = `price DESC NULLS LAST`
|
|
case "found_asc":
|
|
order = `found_at ASC`
|
|
}
|
|
limit := q.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
args := []any{}
|
|
var conds []string
|
|
if q.ItemID != 0 {
|
|
conds = append(conds, `item_id = ?`)
|
|
args = append(args, q.ItemID)
|
|
}
|
|
if q.ExcludeEnded {
|
|
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
|
|
args = append(args, time.Now().UTC())
|
|
}
|
|
where := ""
|
|
if len(conds) > 0 {
|
|
where = `WHERE ` + strings.Join(conds, ` AND `)
|
|
}
|
|
args = append(args, limit, q.Offset)
|
|
rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at
|
|
FROM results %s ORDER BY %s LIMIT ? OFFSET ?
|
|
`, where, order), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Result
|
|
for rows.Next() {
|
|
var (
|
|
r models.Result
|
|
title, urlS, source, imageS, matchQ sql.NullString
|
|
price sql.NullFloat64
|
|
alerted int
|
|
endsAt sql.NullTime
|
|
)
|
|
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt, &endsAt); err != nil {
|
|
return nil, err
|
|
}
|
|
r.Title = s.dec(title.String)
|
|
r.URL = urlS.String
|
|
r.Source = source.String
|
|
r.ImageURL = s.dec(imageS.String)
|
|
r.MatchedQuery = s.dec(matchQ.String)
|
|
r.Price = ptrFloat(price)
|
|
r.Alerted = alerted != 0
|
|
r.EndsAt = ptrTime(endsAt)
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// EndingSoon is a compact projection for the "ending soon" strip: the single
|
|
// nearest-to-end auction across the user's results, with enough context to
|
|
// render and link to it.
|
|
type EndingSoon struct {
|
|
ItemID int64
|
|
ItemName string
|
|
Title string
|
|
URL string
|
|
EndsAt time.Time
|
|
}
|
|
|
|
// NextEndingResult returns the soonest-ending result whose ends_at lies in the
|
|
// window (now, now+within]. If itemID is 0 the search spans all items; nil is
|
|
// returned when no auction falls inside the window.
|
|
func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) {
|
|
now := time.Now().UTC()
|
|
cutoff := now.Add(within)
|
|
q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name
|
|
FROM results r JOIN items i ON r.item_id = i.id
|
|
WHERE r.ends_at IS NOT NULL AND r.ends_at > ? AND r.ends_at <= ?`
|
|
args := []any{now, cutoff}
|
|
if itemID != 0 {
|
|
q += ` AND r.item_id = ?`
|
|
args = append(args, itemID)
|
|
}
|
|
q += ` ORDER BY r.ends_at ASC LIMIT 1`
|
|
row := s.DB.QueryRowContext(ctx, q, args...)
|
|
var (
|
|
e EndingSoon
|
|
title sql.NullString
|
|
urlS sql.NullString
|
|
endsAt time.Time
|
|
)
|
|
if err := row.Scan(&e.ItemID, &title, &urlS, &endsAt, &e.ItemName); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
e.Title = s.dec(title.String)
|
|
e.URL = urlS.String
|
|
e.EndsAt = endsAt
|
|
return &e, nil
|
|
}
|
|
|
|
// CountResults returns the row count matching the same filters ListResults
|
|
// applies. Pagination relies on this matching the visible list, so it must
|
|
// honor ExcludeEnded too.
|
|
func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded bool) (int, error) {
|
|
var n int
|
|
q := `SELECT COUNT(*) FROM results`
|
|
args := []any{}
|
|
var conds []string
|
|
if itemID != 0 {
|
|
conds = append(conds, `item_id = ?`)
|
|
args = append(args, itemID)
|
|
}
|
|
if excludeEnded {
|
|
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
|
|
args = append(args, time.Now().UTC())
|
|
}
|
|
if len(conds) > 0 {
|
|
q += ` WHERE ` + strings.Join(conds, ` AND `)
|
|
}
|
|
err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// ============ price_history ============
|
|
|
|
func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) error {
|
|
if p.PolledAt.IsZero() {
|
|
_, err := s.DB.ExecContext(ctx,
|
|
`INSERT INTO price_history (item_id, price, store) VALUES (?, ?, ?)`,
|
|
p.ItemID, p.Price, s.enc(p.Store))
|
|
return err
|
|
}
|
|
_, err := s.DB.ExecContext(ctx,
|
|
`INSERT INTO price_history (item_id, price, store, polled_at) VALUES (?, ?, ?, ?)`,
|
|
p.ItemID, p.Price, s.enc(p.Store), p.PolledAt)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|