veza/veza-backend-api/internal/services/notification_digest_worker.go
senke dd23805401 feat(v0.10.5): Notifications Complètes (F551-F555)
- Phase 1: Default prefs — push_message & push_follow only; migration 941
- Phase 2: Digest = new tracks from followed artists (ORIGIN §8.1), not unread notifications
- Phase 3: Toggle 'désactiver marketing' + button 'Tout désactiver sauf messages et follows'
- Phase 4: PushPreferencesSection first in NotificationSettings (source of truth)
- Roadmap: v0.10.5 → DONE
2026-03-10 10:09:32 +01:00

159 lines
4.1 KiB
Go

// 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
}