// Package services - v0.10.5 F552: Weekly digest of new tracks from followed users (ORIGIN §8.1) package services import ( "context" "fmt" "os" "time" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // digestTrackItem represents a track for the weekly digest (new releases from followed artists) type digestTrackItem struct { TrackID string Title string Artist string Link string } // NotificationDigestWorker sends weekly digest emails to users with weekly_digest_enabled type NotificationDigestWorker struct { db *gorm.DB jobEnqueuer JobEnqueuer logger *zap.Logger interval time.Duration } // NewNotificationDigestWorker creates a new digest worker func NewNotificationDigestWorker(db *gorm.DB, jobEnqueuer JobEnqueuer, logger *zap.Logger) *NotificationDigestWorker { return &NotificationDigestWorker{ db: db, jobEnqueuer: jobEnqueuer, logger: logger, interval: 24 * time.Hour, // Check daily, process on Sunday } } // Start runs the worker loop func (w *NotificationDigestWorker) Start(ctx context.Context) { if w.jobEnqueuer == nil { w.logger.Info("Notification digest worker: JobEnqueuer not configured, skipping") return } ticker := time.NewTicker(w.interval) defer ticker.Stop() w.logger.Info("Notification digest worker started", zap.Duration("interval", w.interval)) for { select { case <-ctx.Done(): return case <-ticker.C: // Run only on Sunday (weekly digest day) if time.Now().Weekday() == time.Sunday { if err := w.runDigest(ctx); err != nil { w.logger.Error("Notification digest failed", zap.Error(err)) } } } } } func (w *NotificationDigestWorker) runDigest(ctx context.Context) error { type userWithEmail struct { UserID uuid.UUID Email string Username string } var users []userWithEmail err := w.db.WithContext(ctx).Raw(` SELECT u.id as user_id, u.email, u.username FROM users u JOIN notification_preferences np ON np.user_id = u.id WHERE np.weekly_digest_enabled = true AND u.email IS NOT NULL AND u.email != '' `).Scan(&users).Error if err != nil { return fmt.Errorf("query digest users: %w", err) } baseURL := os.Getenv("FRONTEND_URL") if baseURL == "" { baseURL = "http://localhost:5173" } for _, u := range users { // ORIGIN §8.1: Weekly digest = new tracks from followed users (not unread notifications) var tracks []struct { TrackID string Title string Artist string } err := w.db.WithContext(ctx).Raw(` SELECT t.id::text as track_id, t.title, COALESCE(NULLIF(TRIM(t.artist), ''), up.display_name, u.username) as artist FROM tracks t INNER JOIN follows f ON f.followed_id = t.creator_id AND f.follower_id = ? LEFT JOIN users u ON u.id = t.creator_id LEFT JOIN user_profiles up ON up.user_id = t.creator_id WHERE t.status = 'completed' AND t.is_public = true AND t.created_at > NOW() - INTERVAL '7 days' ORDER BY t.created_at DESC LIMIT 50 `, u.UserID).Scan(&tracks).Error if err != nil { w.logger.Warn("Failed to get feed tracks for digest", zap.String("user_id", u.UserID.String()), zap.Error(err)) continue } if len(tracks) == 0 { continue } trackList := make([]digestTrackItem, len(tracks)) for i, t := range tracks { trackList[i] = digestTrackItem{ TrackID: t.TrackID, Title: t.Title, Artist: t.Artist, Link: "/tracks/" + t.TrackID, } } // Template expects []map[string]string for range trackMaps := make([]map[string]string, len(trackList)) for i, t := range trackList { trackMaps[i] = map[string]string{ "TrackID": t.TrackID, "Title": t.Title, "Artist": t.Artist, "Link": t.Link, } } templateData := map[string]interface{}{ "Username": u.Username, "Tracks": trackMaps, "BaseURL": baseURL, "FeedURL": baseURL + "/feed", "TrackCount": len(tracks), } w.jobEnqueuer.EnqueueEmailJobWithTemplate( u.Email, "New releases from artists you follow — Veza", "notification_digest", templateData, ) w.logger.Info("Digest queued", zap.String("user_id", u.UserID.String()), zap.Int("count", len(tracks))) } return nil }