Initial commit
This commit is contained in:
65
internal/db/db.go
Normal file
65
internal/db/db.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", path)
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
if _, err := conn.Exec(schemaSQL); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
if err := addColumnIfMissing(conn, "items", "min_price", "REAL"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := addColumnIfMissing(conn, "items", "exclude_keywords", "TEXT"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := addColumnIfMissing(conn, "results", "matched_query", "TEXT"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func addColumnIfMissing(conn *sql.DB, table, column, typ string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table))
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if _, err := conn.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, typ)); err != nil {
|
||||
return fmt.Errorf("add column %s.%s: %w", table, column, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
internal/db/dedup_test.go
Normal file
97
internal/db/dedup_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"veola/internal/crypto"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
conn, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
key, _ := crypto.DeriveKey([]byte("0123456789abcdef0123456789abcdef-aaa"))
|
||||
return NewStore(conn, key)
|
||||
}
|
||||
|
||||
func TestDedupByItemAndURL(t *testing.T) {
|
||||
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
||||
t.Skip()
|
||||
}
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.CreateItem(ctx, &models.Item{
|
||||
Name: "TwinBee", NtfyTopic: "veola", Active: true,
|
||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &models.Result{
|
||||
ItemID: id,
|
||||
Title: "TwinBee Famicom",
|
||||
Currency: "USD",
|
||||
URL: "https://example.com/listing/1",
|
||||
}
|
||||
if _, err := s.InsertResult(ctx, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exists, err := s.ResultExists(ctx, id, "https://example.com/listing/1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("expected result to be detected as duplicate")
|
||||
}
|
||||
|
||||
missing, _ := s.ResultExists(ctx, id, "https://example.com/listing/2")
|
||||
if missing {
|
||||
t.Error("expected unknown URL to not be flagged as duplicate")
|
||||
}
|
||||
|
||||
// Different item, same URL should not collide.
|
||||
id2, _ := s.CreateItem(ctx, &models.Item{Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"})
|
||||
other, _ := s.ResultExists(ctx, id2, "https://example.com/listing/1")
|
||||
if other {
|
||||
t.Error("dedup should be scoped to item_id")
|
||||
}
|
||||
|
||||
// Empty URL should not collide.
|
||||
emptyExists, _ := s.ResultExists(ctx, id, "")
|
||||
if emptyExists {
|
||||
t.Error("empty URL should never be flagged as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCJKRoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
id, err := s.CreateItem(ctx, &models.Item{
|
||||
Name: "ツインビー",
|
||||
SearchQuery: "ツインビー グラディウス パロディウス",
|
||||
NtfyTopic: "veola",
|
||||
Active: true,
|
||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetItem(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "ツインビー" || got.SearchQuery != "ツインビー グラディウス パロディウス" {
|
||||
t.Errorf("CJK round-trip failed: name=%q query=%q", got.Name, got.SearchQuery)
|
||||
}
|
||||
}
|
||||
747
internal/db/queries.go
Normal file
747
internal/db/queries.go
Normal file
@@ -0,0 +1,747 @@
|
||||
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
|
||||
}
|
||||
99
internal/db/schema.sql
Normal file
99
internal/db/schema.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
search_query TEXT,
|
||||
url TEXT,
|
||||
category TEXT,
|
||||
target_price REAL,
|
||||
ntfy_topic TEXT NOT NULL,
|
||||
ntfy_priority TEXT DEFAULT 'default',
|
||||
poll_interval_minutes INTEGER DEFAULT 60,
|
||||
include_out_of_stock INTEGER DEFAULT 0,
|
||||
min_price REAL,
|
||||
exclude_keywords TEXT,
|
||||
listing_type TEXT,
|
||||
actor_active TEXT,
|
||||
actor_sold TEXT,
|
||||
actor_price_compare TEXT,
|
||||
use_price_comparison INTEGER DEFAULT 0,
|
||||
active INTEGER DEFAULT 1,
|
||||
last_polled_at DATETIME,
|
||||
last_poll_error TEXT,
|
||||
best_price REAL,
|
||||
best_price_store TEXT,
|
||||
best_price_url TEXT,
|
||||
best_price_image_url TEXT,
|
||||
best_price_title TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_active ON items(active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_marketplaces (
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
marketplace TEXT NOT NULL,
|
||||
PRIMARY KEY (item_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_item_marketplaces_item ON item_marketplaces(item_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
price REAL,
|
||||
currency TEXT NOT NULL,
|
||||
url TEXT,
|
||||
source TEXT,
|
||||
image_url TEXT,
|
||||
matched_query TEXT,
|
||||
alerted INTEGER DEFAULT 0,
|
||||
found_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_dedup ON results(item_id, url);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS price_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
price REAL NOT NULL,
|
||||
store TEXT,
|
||||
polled_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_item ON price_history(item_id, polled_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('apify_api_key', ''),
|
||||
('ntfy_base_url', ''),
|
||||
('ntfy_default_topic', 'veola'),
|
||||
('global_poll_interval_minutes', '60'),
|
||||
('match_confidence_threshold', '0.6');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
data BLOB NOT NULL,
|
||||
expiry REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry);
|
||||
Reference in New Issue
Block a user