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:
@@ -28,20 +28,24 @@ func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
|
||||
return email.New(apiKey, from)
|
||||
}
|
||||
|
||||
// sendDealEmails mails a deal alert to every user who opted into deal-alert
|
||||
// email. Entirely best-effort: an unconfigured Resend account or a per-user
|
||||
// send failure is logged and swallowed.
|
||||
// sendDealEmails mails a deal alert to the item's owner, if that user opted
|
||||
// into deal-alert email and has a destination address. Items are private, so a
|
||||
// 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) {
|
||||
client := s.emailClient(ctx)
|
||||
if !client.Configured() {
|
||||
return
|
||||
}
|
||||
recipients, err := s.store.UsersWithDealAlerts(ctx)
|
||||
if err != nil {
|
||||
slog.Error("load deal-alert recipients failed", "err", err)
|
||||
if it.UserID == 0 {
|
||||
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
|
||||
}
|
||||
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)
|
||||
text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL)
|
||||
for _, u := range recipients {
|
||||
if err := client.Send(ctx, email.Message{
|
||||
To: u.Email, Subject: subject, HTML: body, Text: text,
|
||||
}); err != nil {
|
||||
slog.Error("deal email send failed", "to", u.Email, "err", err)
|
||||
}
|
||||
if err := client.Send(ctx, email.Message{
|
||||
To: owner.Email, Subject: subject, HTML: body, Text: text,
|
||||
}); err != nil {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListActiveItems(ctx)
|
||||
if err != nil {
|
||||
slog.Error("digest item load failed", "err", err)
|
||||
return
|
||||
}
|
||||
stats, err := s.store.GetDashboardStats(ctx)
|
||||
if err != nil {
|
||||
slog.Error("digest stats load failed", "err", err)
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items))
|
||||
body := digestHTML(items, stats)
|
||||
text := digestText(items, stats)
|
||||
// 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 {
|
||||
slog.Error("digest item load failed", "user_id", u.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
stats, err := s.store.GetDashboardStatsForUser(ctx, u.ID)
|
||||
if err != nil {
|
||||
slog.Error("digest stats load failed", "user_id", u.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items))
|
||||
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 {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user