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:
prosolis
2026-06-20 13:39:19 -07:00
parent 90a74967e0
commit feea126c5e
11 changed files with 327 additions and 72 deletions

View File

@@ -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 {