veza/veza-backend-api/internal/services/notification_digest_worker.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02: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
}