- 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
159 lines
4.1 KiB
Go
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
|
|
}
|