Add per-user item ownership and isolation
Items are now private to their owner. Add items.user_id (migrated in place, existing items backfilled to the first admin) and scope every item, results, and dashboard view to the signed-in user via owner-scoped queries plus an ownedItem 404 guard. Admins get strict isolation too; elevation stays limited to settings and user management. Alert routing follows ownership: deal emails go only to the item owner and the weekly digest is built per-recipient from their own items. The scheduler still polls every active item; the Apify/eBay budget stays a shared pool visible to all. Add TestItemsArePrivatePerUser and seed owners in db tests.
This commit is contained in:
@@ -53,6 +53,30 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Per-user item ownership. On a migrated DB the column is added without a
|
||||||
|
// REFERENCES clause (ALTER TABLE ADD COLUMN can't carry one cleanly), then
|
||||||
|
// orphaned items are assigned to the first admin (or, failing that, the
|
||||||
|
// first user) so the operator keeps their existing watchlist. Fresh DBs get
|
||||||
|
// the FK from schema.sql and have no rows to backfill.
|
||||||
|
if err := addColumnIfMissing(conn, "items", "user_id", "INTEGER"); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(`
|
||||||
|
UPDATE items SET user_id = (
|
||||||
|
SELECT id FROM users
|
||||||
|
ORDER BY (role = 'admin') DESC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE user_id IS NULL AND EXISTS (SELECT 1 FROM users)
|
||||||
|
`); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("backfill item ownership: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_items_user ON items(user_id)`); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("create items user index: %w", err)
|
||||||
|
}
|
||||||
// Multi-user / email columns (Authentik forward-auth + Resend mail).
|
// Multi-user / email columns (Authentik forward-auth + Resend mail).
|
||||||
for _, c := range []struct{ col, typ string }{
|
for _, c := range []struct{ col, typ string }{
|
||||||
{"email", "TEXT"},
|
{"email", "TEXT"},
|
||||||
|
|||||||
@@ -23,14 +23,26 @@ func newTestStore(t *testing.T) *Store {
|
|||||||
return NewStore(conn, key)
|
return NewStore(conn, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedUser creates an owner so item rows satisfy the items.user_id foreign key.
|
||||||
|
func seedUser(t *testing.T, s *Store) int64 {
|
||||||
|
t.Helper()
|
||||||
|
id, err := s.CreateUser(context.Background(), "tester", "!x", models.RoleAdmin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
func TestDedupByItemAndURL(t *testing.T) {
|
func TestDedupByItemAndURL(t *testing.T) {
|
||||||
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
||||||
t.Skip()
|
t.Skip()
|
||||||
}
|
}
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
uid := seedUser(t, s)
|
||||||
|
|
||||||
id, err := s.CreateItem(ctx, &models.Item{
|
id, err := s.CreateItem(ctx, &models.Item{
|
||||||
|
UserID: uid,
|
||||||
Name: "TwinBee", NtfyTopic: "veola", Active: true,
|
Name: "TwinBee", NtfyTopic: "veola", Active: true,
|
||||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||||
})
|
})
|
||||||
@@ -62,7 +74,7 @@ func TestDedupByItemAndURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Different item, same URL should not collide.
|
// Different item, same URL should not collide.
|
||||||
id2, _ := s.CreateItem(ctx, &models.Item{Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"})
|
id2, _ := s.CreateItem(ctx, &models.Item{UserID: uid, Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"})
|
||||||
other, _ := s.ResultExists(ctx, id2, "https://example.com/listing/1")
|
other, _ := s.ResultExists(ctx, id2, "https://example.com/listing/1")
|
||||||
if other {
|
if other {
|
||||||
t.Error("dedup should be scoped to item_id")
|
t.Error("dedup should be scoped to item_id")
|
||||||
@@ -81,9 +93,11 @@ func TestDashboardMoneySavedWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
uid := seedUser(t, s)
|
||||||
|
|
||||||
bp := 100.0
|
bp := 100.0
|
||||||
id, err := s.CreateItem(ctx, &models.Item{
|
id, err := s.CreateItem(ctx, &models.Item{
|
||||||
|
UserID: uid,
|
||||||
Name: "Windowed", NtfyTopic: "veola", Active: true,
|
Name: "Windowed", NtfyTopic: "veola", Active: true,
|
||||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||||
BestPrice: &bp,
|
BestPrice: &bp,
|
||||||
@@ -123,7 +137,9 @@ func TestDashboardMoneySavedWindow(t *testing.T) {
|
|||||||
func TestCJKRoundTrip(t *testing.T) {
|
func TestCJKRoundTrip(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
uid := seedUser(t, s)
|
||||||
id, err := s.CreateItem(ctx, &models.Item{
|
id, err := s.CreateItem(ctx, &models.Item{
|
||||||
|
UserID: uid,
|
||||||
Name: "ツインビー",
|
Name: "ツインビー",
|
||||||
SearchQuery: "ツインビー グラディウス パロディウス",
|
SearchQuery: "ツインビー グラディウス パロディウス",
|
||||||
NtfyTopic: "veola",
|
NtfyTopic: "veola",
|
||||||
|
|||||||
@@ -257,9 +257,23 @@ func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.U
|
|||||||
return out, rows.Err()
|
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 {
|
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
tx, err := s.DB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
return err
|
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 ============
|
// ============ 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) {
|
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
|
||||||
res, err := s.DB.ExecContext(ctx, `
|
res, err := s.DB.ExecContext(ctx, `
|
||||||
INSERT INTO items (
|
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,
|
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||||
listing_type, condition, region,
|
listing_type, condition, region,
|
||||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
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,
|
active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url,
|
||||||
best_price_title, last_polled_at
|
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,
|
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
||||||
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
||||||
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
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
|
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) {
|
func (s *Store) ListItems(ctx context.Context) ([]models.Item, error) {
|
||||||
return s.listItemsWhere(ctx, itemSelect+` ORDER BY name COLLATE NOCASE`)
|
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) {
|
func (s *Store) ListActiveItems(ctx context.Context) ([]models.Item, error) {
|
||||||
return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 ORDER BY id`)
|
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) {
|
func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]models.Item, error) {
|
||||||
rows, err := s.DB.QueryContext(ctx, q, args...)
|
rows, err := s.DB.QueryContext(ctx, q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -609,6 +638,25 @@ func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]mo
|
|||||||
return out, nil
|
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) {
|
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`)
|
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 {
|
if err != nil {
|
||||||
@@ -654,7 +702,7 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemSelect = `
|
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,
|
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||||
listing_type, condition, region,
|
listing_type, condition, region,
|
||||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
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
|
targetPrice, minPrice, bestPrice sql.NullFloat64
|
||||||
includeOOS, usePC, active int
|
includeOOS, usePC, active int
|
||||||
lastPolledAt sql.NullTime
|
lastPolledAt sql.NullTime
|
||||||
|
userID sql.NullInt64
|
||||||
)
|
)
|
||||||
if err := r.Scan(
|
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,
|
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
|
||||||
&listingType, &condition, ®ion,
|
&listingType, &condition, ®ion,
|
||||||
&actorA, &actorS, &actorP, &usePC,
|
&actorA, &actorS, &actorP, &usePC,
|
||||||
@@ -689,6 +738,7 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
it.UserID = userID.Int64
|
||||||
it.ExcludeKeywords = excludeKw.String
|
it.ExcludeKeywords = excludeKw.String
|
||||||
it.MinPrice = ptrFloat(minPrice)
|
it.MinPrice = ptrFloat(minPrice)
|
||||||
it.SearchQuery = searchQuery.String
|
it.SearchQuery = searchQuery.String
|
||||||
@@ -783,6 +833,9 @@ func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr s
|
|||||||
|
|
||||||
type ResultsQuery struct {
|
type ResultsQuery struct {
|
||||||
ItemID int64 // 0 = all items
|
ItemID int64 // 0 = all items
|
||||||
|
// OwnerID, when non-zero, restricts results to items owned by that user.
|
||||||
|
// Combine with ItemID=0 to scope "all of my items".
|
||||||
|
OwnerID int64
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
|
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
|
||||||
@@ -815,6 +868,10 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
|
|||||||
conds = append(conds, `item_id = ?`)
|
conds = append(conds, `item_id = ?`)
|
||||||
args = append(args, q.ItemID)
|
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 {
|
if q.ExcludeEnded {
|
||||||
conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`)
|
conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`)
|
||||||
args = append(args, time.Now().UTC())
|
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
|
// 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
|
// window (now, now+within]. If itemID is 0 the search spans all items; nil is
|
||||||
// returned when no auction falls inside the window.
|
// 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()
|
now := time.Now().UTC()
|
||||||
cutoff := now.Add(within)
|
cutoff := now.Add(within)
|
||||||
q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name
|
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 = ?`
|
q += ` AND r.item_id = ?`
|
||||||
args = append(args, itemID)
|
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`
|
q += ` ORDER BY r.ends_at ASC LIMIT 1`
|
||||||
row := s.DB.QueryRowContext(ctx, q, args...)
|
row := s.DB.QueryRowContext(ctx, q, args...)
|
||||||
var (
|
var (
|
||||||
@@ -1021,28 +1082,55 @@ type DashboardStats struct {
|
|||||||
SavedItemCount int
|
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) {
|
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{}
|
d := &DashboardStats{}
|
||||||
queries := map[string]any{
|
// itemOwner is an AND-able predicate on the items table; resultOwner is the
|
||||||
`SELECT COUNT(*) FROM items`: &d.TotalItems,
|
// equivalent for the results table (scoped via the item it belongs to).
|
||||||
`SELECT COUNT(*) FROM items WHERE active = 1`: &d.ActiveItems,
|
itemOwner, resultOwner := "", ""
|
||||||
`SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')`: &d.ResultsToday,
|
var ia, ra []any
|
||||||
`SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')`: &d.AlertsToday,
|
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 {
|
countQueries := []struct {
|
||||||
if err := s.DB.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.DB.QueryRowContext(ctx, `
|
if err := s.DB.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(SUM(best_price), 0), COUNT(*)
|
SELECT COALESCE(SUM(best_price), 0), COUNT(*)
|
||||||
FROM items WHERE active = 1 AND best_price IS NOT NULL
|
FROM items WHERE active = 1 AND best_price IS NOT NULL`+itemOwner,
|
||||||
`).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil {
|
ia...).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.DB.QueryRowContext(ctx, `
|
if err := s.DB.QueryRowContext(ctx, `
|
||||||
SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL
|
SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL`+itemOwner,
|
||||||
`).Scan(&d.UnpricedCount); err != nil {
|
ia...).Scan(&d.UnpricedCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// "Money saved" compares each item's current best price against its recent
|
// "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
|
FROM items i
|
||||||
JOIN price_history p ON p.item_id = i.id
|
JOIN price_history p ON p.item_id = i.id
|
||||||
WHERE i.active = 1 AND i.best_price IS NOT NULL
|
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
|
GROUP BY i.id
|
||||||
`)
|
`, ia...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
-- user_id is the owning user. Items are private to their owner; the
|
||||||
|
-- scheduler still polls every active item regardless of owner. On existing
|
||||||
|
-- databases the column is added by addColumnIfMissing in db.go (without a
|
||||||
|
-- REFERENCES clause) and backfilled to the first admin, so user deletion
|
||||||
|
-- removes owned items explicitly in Store.DeleteUser rather than relying on
|
||||||
|
-- the cascade below.
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
search_query TEXT,
|
search_query TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
@@ -53,6 +60,10 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_active ON items(active);
|
CREATE INDEX IF NOT EXISTS idx_items_active ON items(active);
|
||||||
|
-- idx_items_user is created in db.go AFTER addColumnIfMissing adds user_id, so
|
||||||
|
-- it works on databases whose items table predates the column (this CREATE
|
||||||
|
-- INDEX would otherwise fail on them, since IF NOT EXISTS on the table is a
|
||||||
|
-- no-op and the old table lacks user_id).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_marketplaces (
|
CREATE TABLE IF NOT EXISTS item_marketplaces (
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -30,16 +30,17 @@ func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||||
stats, err := a.Store.GetDashboardStats(r.Context())
|
uid := a.userID(r)
|
||||||
|
stats, err := a.Store.GetDashboardStatsForUser(r.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return templates.DashboardData{}, err
|
return templates.DashboardData{}, err
|
||||||
}
|
}
|
||||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 8, ExcludeEnded: true})
|
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OwnerID: uid, Limit: 8, ExcludeEnded: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return templates.DashboardData{}, err
|
return templates.DashboardData{}, err
|
||||||
}
|
}
|
||||||
itemNames := map[int64]string{}
|
itemNames := map[int64]string{}
|
||||||
all, _ := a.Store.ListItems(r.Context())
|
all, _ := a.Store.ListItemsForUser(r.Context(), uid)
|
||||||
for _, it := range all {
|
for _, it := range all {
|
||||||
itemNames[it.ID] = it.Name
|
itemNames[it.ID] = it.Name
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) {
|
func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) {
|
||||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OnlyAlerted: true, Limit: 5})
|
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OwnerID: a.userID(r), OnlyAlerted: true, Limit: 5})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,15 @@ func (a *App) page(r *http.Request, title, active string) templates.Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userID returns the signed-in user's id. Handlers in this group all run under
|
||||||
|
// RequireAuth, so a user is always present; 0 is only returned defensively.
|
||||||
|
func (a *App) userID(r *http.Request) int64 {
|
||||||
|
if u := auth.CurrentUserFromRequest(r); u != nil {
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// noListFS wraps an http.FileSystem and refuses to open directories, which
|
// noListFS wraps an http.FileSystem and refuses to open directories, which
|
||||||
// stops http.FileServer from emitting an auto-generated directory listing.
|
// stops http.FileServer from emitting an auto-generated directory listing.
|
||||||
type noListFS struct{ fs http.FileSystem }
|
type noListFS struct{ fs http.FileSystem }
|
||||||
|
|||||||
@@ -324,6 +324,74 @@ func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestItemsArePrivatePerUser(t *testing.T) {
|
||||||
|
app, h := newTestApp(t)
|
||||||
|
enableForwardAuth(t, app)
|
||||||
|
|
||||||
|
// Two distinct forward-auth identities behind the trusted proxy.
|
||||||
|
alice := newClient(h, "10.0.0.5:5555", map[string]string{
|
||||||
|
hdrEmail: "alice@example.com", hdrGroups: "users",
|
||||||
|
})
|
||||||
|
bob := newClient(h, "10.0.0.6:5555", map[string]string{
|
||||||
|
hdrEmail: "bob@example.com", hdrGroups: "users",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Alice creates an item. Pull a CSRF token from the add-item form first.
|
||||||
|
rec := alice.do(http.MethodGet, "/items/new", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /items/new = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
token := extractCSRF(t, rec.Body.String())
|
||||||
|
form := url.Values{
|
||||||
|
"csrf_token": {token},
|
||||||
|
"name": {"Alice TwinBee"},
|
||||||
|
"search_query": {"twinbee"},
|
||||||
|
"marketplace": {"ebay.com"},
|
||||||
|
"poll_interval_minutes": {"720"},
|
||||||
|
"ntfy_priority": {"default"},
|
||||||
|
}
|
||||||
|
rec = alice.do(http.MethodPost, "/items", form)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST /items = %d, want 303; body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
loc := rec.Header().Get("Location") // /items/{id}/results
|
||||||
|
if !strings.HasPrefix(loc, "/items/") {
|
||||||
|
t.Fatalf("Location = %q, want /items/{id}/results", loc)
|
||||||
|
}
|
||||||
|
itemPath := strings.TrimSuffix(loc, "/results") // /items/{id}
|
||||||
|
|
||||||
|
// Alice sees her item; Bob's list does not.
|
||||||
|
if rec := alice.do(http.MethodGet, "/items", nil); !strings.Contains(rec.Body.String(), "Alice TwinBee") {
|
||||||
|
t.Errorf("Alice's /items missing her own item")
|
||||||
|
}
|
||||||
|
if rec := bob.do(http.MethodGet, "/items", nil); strings.Contains(rec.Body.String(), "Alice TwinBee") {
|
||||||
|
t.Errorf("Bob's /items leaked Alice's item")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob cannot reach Alice's item by direct id: results, edit, error, run,
|
||||||
|
// toggle, and delete all 404 rather than acting on someone else's item.
|
||||||
|
if rec := bob.do(http.MethodGet, loc, nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Bob GET %s = %d, want 404", loc, rec.Code)
|
||||||
|
}
|
||||||
|
if rec := bob.do(http.MethodGet, itemPath+"/edit", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Bob GET %s/edit = %d, want 404", itemPath, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A state-changing attempt needs Bob's own CSRF token (from any rendered
|
||||||
|
// form); it must still 404 on ownership, not act.
|
||||||
|
br := bob.do(http.MethodGet, "/settings", nil)
|
||||||
|
btoken := extractCSRF(t, br.Body.String())
|
||||||
|
del := url.Values{"csrf_token": {btoken}}
|
||||||
|
if rec := bob.do(http.MethodPost, itemPath+"/delete", del); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Bob POST %s/delete = %d, want 404", itemPath, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice's item still exists after Bob's delete attempt.
|
||||||
|
if rec := alice.do(http.MethodGet, loc, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("Alice GET %s after Bob's delete attempt = %d, want 200", loc, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func containsEmail(us []models.User, email string) bool {
|
func containsEmail(us []models.User, email string) bool {
|
||||||
for _, u := range us {
|
for _, u := range us {
|
||||||
if u.Email == email {
|
if u.Email == email {
|
||||||
|
|||||||
@@ -20,9 +20,23 @@ import (
|
|||||||
// form: 12 hours, chosen to keep Apify spend low by default.
|
// form: 12 hours, chosen to keep Apify spend low by default.
|
||||||
const defaultPollIntervalMinutes = 720
|
const defaultPollIntervalMinutes = 720
|
||||||
|
|
||||||
|
// ownedItem fetches an item and confirms the current user owns it. It returns
|
||||||
|
// nil when the item is missing or owned by someone else, so callers treat both
|
||||||
|
// cases as a 404 and never leak another user's items.
|
||||||
|
func (a *App) ownedItem(r *http.Request, id int64) (*models.Item, error) {
|
||||||
|
it, err := a.Store.GetItem(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if it == nil || it.UserID != a.userID(r) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return it, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||||
cat := r.URL.Query().Get("category")
|
cat := r.URL.Query().Get("category")
|
||||||
all, err := a.Store.ListItems(r.Context())
|
all, err := a.Store.ListItemsForUser(r.Context(), a.userID(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -33,7 +47,7 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
|||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cats, _ := a.Store.ListCategories(r.Context())
|
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||||
|
|
||||||
// Bulk-load recent price history so each row can render a sparkline
|
// Bulk-load recent price history so each row can render a sparkline
|
||||||
// without N+1 queries. 20 points is enough for a meaningful trend line
|
// without N+1 queries. 20 points is enough for a meaningful trend line
|
||||||
@@ -54,7 +68,7 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
||||||
cats, _ := a.Store.ListCategories(r.Context())
|
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||||
Page: a.page(r, "Add Item", "items"),
|
Page: a.page(r, "Add Item", "items"),
|
||||||
IsEdit: false,
|
IsEdit: false,
|
||||||
@@ -73,12 +87,12 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
it, err := a.Store.GetItem(r.Context(), id)
|
it, err := a.ownedItem(r, id)
|
||||||
if err != nil || it == nil {
|
if err != nil || it == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cats, _ := a.Store.ListCategories(r.Context())
|
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||||
Page: a.page(r, "Edit "+it.Name, "items"),
|
Page: a.page(r, "Edit "+it.Name, "items"),
|
||||||
IsEdit: true,
|
IsEdit: true,
|
||||||
@@ -341,6 +355,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest)
|
http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
it.UserID = a.userID(r)
|
||||||
id, err := a.Store.CreateItem(r.Context(), &it)
|
id, err := a.Store.CreateItem(r.Context(), &it)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, err)
|
a.serverError(w, r, err)
|
||||||
@@ -364,14 +379,14 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
existing, err := a.Store.GetItem(r.Context(), id)
|
existing, err := a.ownedItem(r, id)
|
||||||
if err != nil || existing == nil {
|
if err != nil || existing == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated, errs := parseItemForm(r)
|
updated, errs := parseItemForm(r)
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
cats, _ := a.Store.ListCategories(r.Context())
|
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||||
updated.ID = id
|
updated.ID = id
|
||||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||||
Page: a.page(r, "Edit "+updated.Name, "items"),
|
Page: a.page(r, "Edit "+updated.Name, "items"),
|
||||||
@@ -394,7 +409,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
it, err := a.Store.GetItem(r.Context(), id)
|
it, err := a.ownedItem(r, id)
|
||||||
if err != nil || it == nil {
|
if err != nil || it == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -411,6 +426,11 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
|
it, err := a.ownedItem(r, id)
|
||||||
|
if err != nil || it == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
|
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
|
||||||
a.serverError(w, r, err)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -421,7 +441,7 @@ func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
it, err := a.Store.GetItem(r.Context(), id)
|
it, err := a.ownedItem(r, id)
|
||||||
if err != nil || it == nil {
|
if err != nil || it == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -455,7 +475,7 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
it, err := a.Store.GetItem(r.Context(), id)
|
it, err := a.ownedItem(r, id)
|
||||||
if err != nil || it == nil {
|
if err != nil || it == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const resultsPerPage = 20
|
|||||||
|
|
||||||
func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
it, err := a.Store.GetItem(r.Context(), id)
|
it, err := a.ownedItem(r, id)
|
||||||
if err != nil || it == nil {
|
if err != nil || it == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -71,7 +71,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
|
|||||||
|
|
||||||
// 24h surface for the "ending soon" strip — beyond that, a static
|
// 24h surface for the "ending soon" strip — beyond that, a static
|
||||||
// "ends in 4 days" in the per-row cell carries enough signal on its own.
|
// "ends in 4 days" in the per-row cell carries enough signal on its own.
|
||||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour)
|
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 0, 24*time.Hour)
|
||||||
|
|
||||||
return templates.ItemResultsData{
|
return templates.ItemResultsData{
|
||||||
Page: a.page(r, it.Name, "items"),
|
Page: a.page(r, it.Name, "items"),
|
||||||
@@ -105,7 +105,8 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
from := strings.TrimSpace(q.Get("from"))
|
from := strings.TrimSpace(q.Get("from"))
|
||||||
to := strings.TrimSpace(q.Get("to"))
|
to := strings.TrimSpace(q.Get("to"))
|
||||||
|
|
||||||
items, err := a.Store.ListItems(r.Context())
|
uid := a.userID(r)
|
||||||
|
items, err := a.Store.ListItemsForUser(r.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, err)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -117,6 +118,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||||
ItemID: itemID,
|
ItemID: itemID,
|
||||||
|
OwnerID: uid,
|
||||||
Limit: 200,
|
Limit: 200,
|
||||||
ExcludeEnded: true,
|
ExcludeEnded: true,
|
||||||
})
|
})
|
||||||
@@ -145,7 +147,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour)
|
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, uid, 24*time.Hour)
|
||||||
|
|
||||||
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
||||||
Page: a.page(r, "Results", "results"),
|
Page: a.page(r, "Results", "results"),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type User struct {
|
|||||||
|
|
||||||
type Item struct {
|
type Item struct {
|
||||||
ID int64
|
ID int64
|
||||||
|
UserID int64
|
||||||
Name string
|
Name string
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
URL string
|
URL string
|
||||||
|
|||||||
@@ -28,20 +28,24 @@ func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
|
|||||||
return email.New(apiKey, from)
|
return email.New(apiKey, from)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendDealEmails mails a deal alert to every user who opted into deal-alert
|
// sendDealEmails mails a deal alert to the item's owner, if that user opted
|
||||||
// email. Entirely best-effort: an unconfigured Resend account or a per-user
|
// into deal-alert email and has a destination address. Items are private, so a
|
||||||
// send failure is logged and swallowed.
|
// deal only ever notifies the person who is watching it. Entirely best-effort:
|
||||||
|
// an unconfigured Resend account or a send failure is logged and swallowed.
|
||||||
func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) {
|
func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) {
|
||||||
client := s.emailClient(ctx)
|
client := s.emailClient(ctx)
|
||||||
if !client.Configured() {
|
if !client.Configured() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recipients, err := s.store.UsersWithDealAlerts(ctx)
|
if it.UserID == 0 {
|
||||||
if err != nil {
|
|
||||||
slog.Error("load deal-alert recipients failed", "err", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(recipients) == 0 {
|
owner, err := s.store.GetUserByID(ctx, it.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load item owner failed", "item_id", it.ID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if owner == nil || !owner.EmailDealAlerts || owner.Email == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
subject := fmt.Sprintf("Veola deal: %s", it.Name)
|
subject := fmt.Sprintf("Veola deal: %s", it.Name)
|
||||||
@@ -52,12 +56,10 @@ func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.
|
|||||||
}
|
}
|
||||||
body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL)
|
body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL)
|
||||||
text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL)
|
text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL)
|
||||||
for _, u := range recipients {
|
|
||||||
if err := client.Send(ctx, email.Message{
|
if err := client.Send(ctx, email.Message{
|
||||||
To: u.Email, Subject: subject, HTML: body, Text: text,
|
To: owner.Email, Subject: subject, HTML: body, Text: text,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("deal email send failed", "to", u.Email, "err", err)
|
slog.Error("deal email send failed", "to", owner.Email, "err", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,27 +78,34 @@ func (s *Scheduler) RunWeeklyDigest(ctx context.Context) {
|
|||||||
if len(recipients) == 0 {
|
if len(recipients) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := s.store.ListActiveItems(ctx)
|
// Each digest is built from only the recipient's own items and stats, since
|
||||||
|
// items are private. A user watching nothing is skipped rather than mailed
|
||||||
|
// an empty table.
|
||||||
|
sent := 0
|
||||||
|
for _, u := range recipients {
|
||||||
|
items, err := s.store.ListActiveItemsForUser(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("digest item load failed", "err", err)
|
slog.Error("digest item load failed", "user_id", u.ID, "err", err)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
stats, err := s.store.GetDashboardStats(ctx)
|
if len(items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats, err := s.store.GetDashboardStatsForUser(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("digest stats load failed", "err", err)
|
slog.Error("digest stats load failed", "user_id", u.ID, "err", err)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items))
|
subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items))
|
||||||
body := digestHTML(items, stats)
|
|
||||||
text := digestText(items, stats)
|
|
||||||
for _, u := range recipients {
|
|
||||||
if err := client.Send(ctx, email.Message{
|
if err := client.Send(ctx, email.Message{
|
||||||
To: u.Email, Subject: subject, HTML: body, Text: text,
|
To: u.Email, Subject: subject, HTML: digestHTML(items, stats), Text: digestText(items, stats),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("digest email send failed", "to", u.Email, "err", err)
|
slog.Error("digest email send failed", "to", u.Email, "err", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
sent++
|
||||||
}
|
}
|
||||||
slog.Info("weekly digest sent", "recipients", len(recipients), "items", len(items))
|
slog.Info("weekly digest sent", "recipients", sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dealEmailHTML(itemName, title, store, price, target, url string) string {
|
func dealEmailHTML(itemName, title, store, price, target, url string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user