|
|
|
|
@@ -257,9 +257,23 @@ func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.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 {
|
|
|
|
|
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
|
|
|
|
return err
|
|
|
|
|
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 ============
|
|
|
|
|
@@ -410,15 +424,15 @@ func (s *Store) ApifyUsageMonth(ctx context.Context) (int, error) {
|
|
|
|
|
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,
|
|
|
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
`,
|
|
|
|
|
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
|
|
|
|
|
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)),
|
|
|
|
|
@@ -571,14 +585,29 @@ func (s *Store) GetItem(ctx context.Context, id int64) (*models.Item, error) {
|
|
|
|
|
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 {
|
|
|
|
|
@@ -609,6 +638,25 @@ func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]mo
|
|
|
|
|
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 {
|
|
|
|
|
@@ -654,7 +702,7 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const itemSelect = `
|
|
|
|
|
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
|
|
|
|
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,
|
|
|
|
|
@@ -678,9 +726,10 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
|
|
|
|
targetPrice, minPrice, bestPrice sql.NullFloat64
|
|
|
|
|
includeOOS, usePC, active int
|
|
|
|
|
lastPolledAt sql.NullTime
|
|
|
|
|
userID sql.NullInt64
|
|
|
|
|
)
|
|
|
|
|
if err := r.Scan(
|
|
|
|
|
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
|
|
|
|
|
&it.ID, &userID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
|
|
|
|
|
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
|
|
|
|
|
&listingType, &condition, ®ion,
|
|
|
|
|
&actorA, &actorS, &actorP, &usePC,
|
|
|
|
|
@@ -689,6 +738,7 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
|
|
|
|
); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
it.UserID = userID.Int64
|
|
|
|
|
it.ExcludeKeywords = excludeKw.String
|
|
|
|
|
it.MinPrice = ptrFloat(minPrice)
|
|
|
|
|
it.SearchQuery = searchQuery.String
|
|
|
|
|
@@ -783,8 +833,11 @@ func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr s
|
|
|
|
|
|
|
|
|
|
type ResultsQuery struct {
|
|
|
|
|
ItemID int64 // 0 = all items
|
|
|
|
|
Limit int
|
|
|
|
|
Offset int
|
|
|
|
|
// 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.
|
|
|
|
|
@@ -815,6 +868,10 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
|
|
|
|
|
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())
|
|
|
|
|
@@ -874,7 +931,7 @@ type EndingSoon struct {
|
|
|
|
|
// 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) {
|
|
|
|
|
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
|
|
|
|
|
@@ -885,6 +942,10 @@ func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.
|
|
|
|
|
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 (
|
|
|
|
|
@@ -1021,28 +1082,55 @@ type DashboardStats struct {
|
|
|
|
|
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{}
|
|
|
|
|
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,
|
|
|
|
|
// 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}
|
|
|
|
|
}
|
|
|
|
|
for q, dst := range queries {
|
|
|
|
|
if err := s.DB.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
|
|
|
|
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
|
|
|
|
|
`).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil {
|
|
|
|
|
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
|
|
|
|
|
`).Scan(&d.UnpricedCount); err != nil {
|
|
|
|
|
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
|
|
|
|
|
@@ -1054,9 +1142,15 @@ func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error)
|
|
|
|
|
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')
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|