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 } // ============ 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, actor_active, actor_sold, actor_price_compare, use_price_comparison, active, best_price, 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.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), boolToInt(it.UsePriceComparison), boolToInt(it.Active), nullFloat(it.BestPrice), 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 = ?, 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.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 bestStore, bestURL, bestImage, bestTitle, errField sql.NullString ) if best != nil { bestPrice = nullFloat(best.BestPrice) 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_store = ?, best_price_url = ?, best_price_image_url = ?, best_price_title = ?, last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ? WHERE id = ? `, bestPrice, 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, actor_active, actor_sold, actor_price_compare, use_price_comparison, active, last_polled_at, last_poll_error, best_price, 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 sql.NullString actorA, actorS, actorP sql.NullString ntfyTopic, lastPollErr sql.NullString 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, &actorA, &actorS, &actorP, &usePC, &active, &lastPolledAt, &lastPollErr, &bestPrice, &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.ActorActive = actorA.String it.ActorSold = actorS.String it.ActorPriceCompare = actorP.String it.NtfyTopic = ntfyTopic.String it.LastPollError = lastPollErr.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) 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), ) 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 } type ResultsQuery struct { ItemID int64 // 0 = all items Limit int Offset int Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc" } 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{} where := "" if q.ItemID != 0 { where = `WHERE item_id = ?` args = append(args, q.ItemID) } 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 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 ) if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); 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 out = append(out, r) } return out, rows.Err() } func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) { var n int q := `SELECT COUNT(*) FROM results` args := []any{} if itemID != 0 { q += ` WHERE item_id = ?` args = append(args, itemID) } err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n) return n, err } // ============ price_history ============ func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) error { if p.PolledAt.IsZero() { _, err := s.DB.ExecContext(ctx, `INSERT INTO price_history (item_id, price, store) VALUES (?, ?, ?)`, p.ItemID, p.Price, s.enc(p.Store)) return err } _, err := s.DB.ExecContext(ctx, `INSERT INTO price_history (item_id, price, store, polled_at) VALUES (?, ?, ?, ?)`, p.ItemID, p.Price, s.enc(p.Store), p.PolledAt) return err } func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) { rows, err := s.DB.QueryContext(ctx, `SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`, itemID) if err != nil { return nil, err } defer rows.Close() var out []models.PricePoint for rows.Next() { var p models.PricePoint var store sql.NullString if err := rows.Scan(&p.ID, &p.ItemID, &p.Price, &store, &p.PolledAt); err != nil { return nil, err } p.Store = s.dec(store.String) out = append(out, p) } return out, rows.Err() } // ============ stats ============ type DashboardStats struct { TotalItems int ActiveItems int ResultsToday int AlertsToday int PotentialSpend float64 PricedItemCount int UnpricedCount int MoneySaved float64 SavedItemCount int } func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { d := &DashboardStats{} queries := map[string]any{ `SELECT COUNT(*) FROM items`: &d.TotalItems, `SELECT COUNT(*) FROM items WHERE active = 1`: &d.ActiveItems, `SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')`: &d.ResultsToday, `SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')`: &d.AlertsToday, } for q, dst := range queries { if err := s.DB.QueryRowContext(ctx, q).Scan(dst); err != nil { return nil, err } } if err := s.DB.QueryRowContext(ctx, ` SELECT COALESCE(SUM(best_price), 0), COUNT(*) FROM items WHERE active = 1 AND best_price IS NOT NULL `).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil { return nil, err } if err := s.DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL `).Scan(&d.UnpricedCount); err != nil { return nil, err } rows, err := s.DB.QueryContext(ctx, ` SELECT i.best_price, AVG(p.price) AS avg_price FROM items i JOIN price_history p ON p.item_id = i.id WHERE i.active = 1 AND i.best_price IS NOT NULL GROUP BY i.id `) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var bp, avg float64 if err := rows.Scan(&bp, &avg); err != nil { return nil, err } if bp < avg { d.MoneySaved += avg - bp d.SavedItemCount++ } } return d, rows.Err() } func boolToInt(b bool) int { if b { return 1 } return 0 }