diff --git a/apps/web/src/components/notifications/notification-menu/NotificationMenuItem.tsx b/apps/web/src/components/notifications/notification-menu/NotificationMenuItem.tsx
index e48fc1e5d..297251d5f 100644
--- a/apps/web/src/components/notifications/notification-menu/NotificationMenuItem.tsx
+++ b/apps/web/src/components/notifications/notification-menu/NotificationMenuItem.tsx
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import type { Notification } from '@/features/notifications/services/notificationService';
+import { getNotificationDisplayContent } from '@/features/notifications/utils/formatNotificationContent';
interface NotificationMenuItemProps {
notification: Notification;
@@ -49,9 +50,9 @@ function NotificationMenuItemInner({
{notification.title}
- {notification.content && (
+ {(notification.content || (notification.actor_count ?? 1) > 1) && (
- {notification.content}
+ {getNotificationDisplayContent(notification)}
)}
diff --git a/apps/web/src/features/notifications/components/notifications-page/NotificationsPageItem.tsx b/apps/web/src/features/notifications/components/notifications-page/NotificationsPageItem.tsx
index 123520cc2..a1c6129e5 100644
--- a/apps/web/src/features/notifications/components/notifications-page/NotificationsPageItem.tsx
+++ b/apps/web/src/features/notifications/components/notifications-page/NotificationsPageItem.tsx
@@ -5,6 +5,7 @@ import { Bell, Check, MessageCircle, Music, UserPlus, Settings } from 'lucide-re
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import type { Notification } from '../../services/notificationService';
+import { getNotificationDisplayContent } from '../../utils/formatNotificationContent';
import { NOTIFICATION_TYPE_LABELS } from './types';
const NOTIFICATION_TYPE_ICONS: Record = {
@@ -51,7 +52,7 @@ export function NotificationsPageItem({
- {notification.content}
+ {getNotificationDisplayContent(notification)}
{formatDistanceToNow(new Date(notification.created_at), {
diff --git a/apps/web/src/features/notifications/services/notificationService.ts b/apps/web/src/features/notifications/services/notificationService.ts
index d67fe0b42..3ceb543ca 100644
--- a/apps/web/src/features/notifications/services/notificationService.ts
+++ b/apps/web/src/features/notifications/services/notificationService.ts
@@ -24,6 +24,9 @@ export interface Notification {
read: boolean;
created_at: string;
metadata?: Record; // Additional data for specific notification types
+ /** v0.10.5 F554: Grouping — when > 1, display "N people liked your track" */
+ actor_count?: number;
+ group_key?: string;
}
export interface GetNotificationsParams {
@@ -219,6 +222,8 @@ export interface NotificationPreferences {
quiet_hours_enabled?: boolean;
quiet_hours_start?: string; // "22:00"
quiet_hours_end?: string; // "08:00"
+ /** v0.10.5 F552: Receive weekly email digest of unread notifications */
+ weekly_digest_enabled?: boolean;
}
/**
diff --git a/apps/web/src/features/notifications/utils/formatNotificationContent.ts b/apps/web/src/features/notifications/utils/formatNotificationContent.ts
new file mode 100644
index 000000000..007fcfc7a
--- /dev/null
+++ b/apps/web/src/features/notifications/utils/formatNotificationContent.ts
@@ -0,0 +1,17 @@
+/**
+ * v0.10.5 F554: Format notification content for display, including grouped notifications
+ */
+import type { Notification } from '../services/notificationService';
+
+const GROUPED_LABELS: Record = {
+ like: 'personne(s) ont aimé votre track',
+ comment: 'personne(s) ont commenté votre track',
+};
+
+export function getNotificationDisplayContent(notification: Notification): string {
+ const count = notification.actor_count ?? 1;
+ if (count > 1 && notification.type in GROUPED_LABELS) {
+ return `${count} ${GROUPED_LABELS[notification.type]}`;
+ }
+ return notification.content;
+}
diff --git a/apps/web/src/features/settings/components/PushPreferencesSection.tsx b/apps/web/src/features/settings/components/PushPreferencesSection.tsx
index 745b996c9..4cec713e4 100644
--- a/apps/web/src/features/settings/components/PushPreferencesSection.tsx
+++ b/apps/web/src/features/settings/components/PushPreferencesSection.tsx
@@ -196,6 +196,23 @@ export function PushPreferencesSection() {
)}
+ {/* v0.10.5 F552: Weekly digest opt-in */}
+
+
Digest hebdomadaire
+
+ Recevoir un récapitulatif par email des notifications non lues chaque dimanche
+
+
+
+
+ handleChange('weekly_digest_enabled', checked === true)
+ }
+ />
+
+
);
}
diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts
index d3e812346..00a097991 100644
--- a/apps/web/src/mocks/handlers-misc.ts
+++ b/apps/web/src/mocks/handlers-misc.ts
@@ -335,8 +335,8 @@ export const handlersMisc = [
const page = Number(url.searchParams.get('page')) || 1;
const limit = Number(url.searchParams.get('limit')) || 20;
const notifications = [
- { id: 'notif-1', user_id: 'user-1', type: 'new_message', title: 'New message', content: 'Someone sent you a message', read: false, created_at: '2024-01-04T00:00:00Z', link: '/chat/1' },
- { id: 'notif-2', user_id: 'user-1', type: 'track_uploaded', title: 'New track', content: 'A creator you follow uploaded a track', read: true, created_at: '2024-01-03T12:00:00Z' },
+ { id: 'notif-1', user_id: 'user-1', type: 'new_message', title: 'New message', content: 'Someone sent you a message', read: false, created_at: '2024-01-04T00:00:00Z', link: '/chat/1', actor_count: 1 },
+ { id: 'notif-2', user_id: 'user-1', type: 'like', title: 'New like', content: 'Someone liked your track', read: true, created_at: '2024-01-03T12:00:00Z', actor_count: 3 },
];
const total = notifications.length;
return HttpResponse.json({
@@ -372,6 +372,7 @@ export const handlersMisc = [
quiet_hours_enabled: false,
quiet_hours_start: '22:00',
quiet_hours_end: '08:00',
+ weekly_digest_enabled: false,
},
});
}),
diff --git a/veza-backend-api/cmd/api/main.go b/veza-backend-api/cmd/api/main.go
index 4c9b89598..a14d19684 100644
--- a/veza-backend-api/cmd/api/main.go
+++ b/veza-backend-api/cmd/api/main.go
@@ -195,6 +195,19 @@ func main() {
return nil
}))
+ // v0.10.5 F552: Weekly notification digest (runs on Sunday)
+ if cfg.JobWorker != nil {
+ digestWorker := services.NewNotificationDigestWorker(db.GormDB, cfg.JobWorker, logger)
+ digestCtx, digestCancel := context.WithCancel(context.Background())
+ go digestWorker.Start(digestCtx)
+ logger.Info("Notification digest worker started (weekly on Sunday)")
+
+ shutdownManager.Register(shutdown.NewShutdownFunc("notification_digest_worker", func(ctx context.Context) error {
+ digestCancel()
+ return nil
+ }))
+ }
+
// Configuration du mode Gin
// Correction: Utilisation directe de la variable d'env car non exposée dans Config
appEnv := os.Getenv("APP_ENV")
diff --git a/veza-backend-api/internal/core/track/track_social_handler.go b/veza-backend-api/internal/core/track/track_social_handler.go
index 3e8718302..b828de8f4 100644
--- a/veza-backend-api/internal/core/track/track_social_handler.go
+++ b/veza-backend-api/internal/core/track/track_social_handler.go
@@ -52,12 +52,12 @@ func (h *TrackHandler) LikeTrack(c *gin.Context) {
return
}
- // Phase 2.2: Create notification for track creator (skip if user likes own track)
+ // Phase 2.2: Create notification for track creator (skip if user likes own track). F554: grouping by track
if h.notificationService != nil {
track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID)
if err == nil && track.UserID != userID {
link := "/tracks/" + trackID.String()
- if err := h.notificationService.CreateNotification(track.UserID, "like", "New like", "Someone liked your track", link); err != nil {
+ if err := h.notificationService.CreateNotificationWithGroup(track.UserID, "like", "New like", "Someone liked your track", link, "like:track:"+trackID.String(), userID); err != nil {
// Log but don't fail the request
}
}
diff --git a/veza-backend-api/internal/handlers/comment_handler.go b/veza-backend-api/internal/handlers/comment_handler.go
index 7272e0976..eff3f6e3a 100644
--- a/veza-backend-api/internal/handlers/comment_handler.go
+++ b/veza-backend-api/internal/handlers/comment_handler.go
@@ -147,12 +147,12 @@ func (h *CommentHandler) CreateComment(c *gin.Context) {
return
}
- // Phase 2.2: Create notification for track creator (skip if user comments on own track)
+ // Phase 2.2: Create notification for track creator (skip if user comments on own track). F554: grouping by track
if h.notificationService != nil {
creatorID, err := h.commentService.GetTrackCreatorID(c.Request.Context(), trackID)
if err == nil && creatorID != userID {
link := "/tracks/" + trackID.String()
- if err := h.notificationService.CreateNotification(creatorID, "comment", "New comment", "Someone commented on your track", link); err != nil {
+ if err := h.notificationService.CreateNotificationWithGroup(creatorID, "comment", "New comment", "Someone commented on your track", link, "comment:track:"+trackID.String(), userID); err != nil {
h.commonHandler.logger.Warn("failed to create comment notification", zap.Error(err))
}
}
diff --git a/veza-backend-api/internal/handlers/notification_handlers.go b/veza-backend-api/internal/handlers/notification_handlers.go
index 4a72e1b66..c926f35c2 100644
--- a/veza-backend-api/internal/handlers/notification_handlers.go
+++ b/veza-backend-api/internal/handlers/notification_handlers.go
@@ -23,7 +23,7 @@ type NotificationServiceInterface interface {
DeleteNotification(userID uuid.UUID, notificationID uuid.UUID) error
DeleteAllNotifications(userID uuid.UUID) error
GetPreferences(userID uuid.UUID) (*services.NotificationPrefs, error)
- UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool, quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string) error
+ UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool, quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string, weeklyDigestEnabled *bool) error
}
type NotificationHandlers struct {
@@ -239,9 +239,10 @@ func (nh *NotificationHandlers) GetPreferences(c *gin.Context) {
"push_comment": prefs.PushComment,
"push_message": prefs.PushMessage,
"push_mention": prefs.PushMention,
- "quiet_hours_enabled": prefs.QuietHoursEnabled,
- "quiet_hours_start": prefs.QuietHoursStart,
- "quiet_hours_end": prefs.QuietHoursEnd,
+ "quiet_hours_enabled": prefs.QuietHoursEnabled,
+ "quiet_hours_start": prefs.QuietHoursStart,
+ "quiet_hours_end": prefs.QuietHoursEnd,
+ "weekly_digest_enabled": prefs.WeeklyDigestEnabled,
})
}
@@ -252,9 +253,10 @@ type UpdatePreferencesRequest struct {
PushComment *bool `json:"push_comment"`
PushMessage *bool `json:"push_message"`
PushMention *bool `json:"push_mention"`
- QuietHoursEnabled *bool `json:"quiet_hours_enabled"`
- QuietHoursStart *string `json:"quiet_hours_start"` // "22:00"
- QuietHoursEnd *string `json:"quiet_hours_end"` // "08:00"
+ QuietHoursEnabled *bool `json:"quiet_hours_enabled"`
+ QuietHoursStart *string `json:"quiet_hours_start"` // "22:00"
+ QuietHoursEnd *string `json:"quiet_hours_end"` // "08:00"
+ WeeklyDigestEnabled *bool `json:"weekly_digest_enabled"`
}
// UpdatePreferences updates notification preferences (N1.3)
@@ -271,7 +273,7 @@ func (nh *NotificationHandlers) UpdatePreferences(c *gin.Context) {
}
if err := nh.notificationService.UpdatePreferences(userID, req.PushFollow, req.PushLike, req.PushComment, req.PushMessage, req.PushMention,
- req.QuietHoursEnabled, req.QuietHoursStart, req.QuietHoursEnd); err != nil {
+ req.QuietHoursEnabled, req.QuietHoursStart, req.QuietHoursEnd, req.WeeklyDigestEnabled); err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update preferences", err))
return
}
diff --git a/veza-backend-api/internal/handlers/notification_handlers_test.go b/veza-backend-api/internal/handlers/notification_handlers_test.go
index c6259c8c9..3dc05ca97 100644
--- a/veza-backend-api/internal/handlers/notification_handlers_test.go
+++ b/veza-backend-api/internal/handlers/notification_handlers_test.go
@@ -60,8 +60,8 @@ func (m *MockNotificationService) GetPreferences(userID uuid.UUID) (*services.No
return args.Get(0).(*services.NotificationPrefs), args.Error(1)
}
-func (m *MockNotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool, quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string) error {
- args := m.Called(userID, pushFollow, pushLike, pushComment, pushMessage, pushMention, quietHoursEnabled, quietHoursStart, quietHoursEnd)
+func (m *MockNotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool, quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string, weeklyDigestEnabled *bool) error {
+ args := m.Called(userID, pushFollow, pushLike, pushComment, pushMessage, pushMention, quietHoursEnabled, quietHoursStart, quietHoursEnd, weeklyDigestEnabled)
return args.Error(0)
}
diff --git a/veza-backend-api/internal/services/notification_digest_worker.go b/veza-backend-api/internal/services/notification_digest_worker.go
new file mode 100644
index 000000000..b678e02fa
--- /dev/null
+++ b/veza-backend-api/internal/services/notification_digest_worker.go
@@ -0,0 +1,136 @@
+// Package services - v0.10.5 F552: Weekly notification digest worker
+package services
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+// 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"
+ }
+ notificationsURL := baseURL + "/notifications"
+
+ for _, u := range users {
+ var notifs []struct {
+ Title string
+ Content string
+ Link string
+ Type string
+ }
+ err := w.db.WithContext(ctx).Raw(`
+ SELECT title, content, COALESCE(link, '') as link, type
+ FROM notifications
+ WHERE user_id = ? AND read = FALSE AND created_at > NOW() - INTERVAL '7 days'
+ ORDER BY created_at DESC
+ LIMIT 50
+ `, u.UserID).Scan(¬ifs).Error
+ if err != nil {
+ w.logger.Warn("Failed to get notifications for digest", zap.String("user_id", u.UserID.String()), zap.Error(err))
+ continue
+ }
+
+ if len(notifs) == 0 {
+ continue
+ }
+
+ notifList := make([]map[string]string, len(notifs))
+ for i, n := range notifs {
+ notifList[i] = map[string]string{
+ "Title": n.Title,
+ "Content": n.Content,
+ "Link": n.Link,
+ "Type": n.Type,
+ }
+ }
+
+ templateData := map[string]interface{}{
+ "Username": u.Username,
+ "Notifications": notifList,
+ "BaseURL": baseURL,
+ "NotificationsURL": notificationsURL,
+ }
+
+ w.jobEnqueuer.EnqueueEmailJobWithTemplate(
+ u.Email,
+ "Your Veza weekly digest",
+ "notification_digest",
+ templateData,
+ )
+ w.logger.Info("Digest queued", zap.String("user_id", u.UserID.String()), zap.Int("count", len(notifs)))
+ }
+
+ return nil
+}
diff --git a/veza-backend-api/internal/services/notification_service.go b/veza-backend-api/internal/services/notification_service.go
index eca00681d..5fe351047 100644
--- a/veza-backend-api/internal/services/notification_service.go
+++ b/veza-backend-api/internal/services/notification_service.go
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"regexp"
- "strings"
"time"
"github.com/google/uuid"
@@ -144,15 +143,19 @@ func (ns *NotificationService) CreateNotificationWithGroup(userID uuid.UUID, not
}
actorCount := 1
- metadata := "{}"
+ metadata := "[]"
if actorID != uuid.Nil {
actorIDsJSON, _ := json.Marshal([]string{actorID.String()})
metadata = string(actorIDsJSON)
}
+ var gk interface{} = groupKey
+ if groupKey == "" {
+ gk = nil
+ }
_, err := ns.db.ExecContext(ctx, `
INSERT INTO notifications (user_id, type, title, content, link, group_key, actor_count, metadata)
- VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), $7, $8::jsonb)
- `, userID, notificationType, title, content, link, groupKey, actorCount, metadata)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
+ `, userID, notificationType, title, content, link, gk, actorCount, metadata)
if err != nil {
return fmt.Errorf("failed to create notification: %w", err)
@@ -267,7 +270,8 @@ func (ns *NotificationService) GetNotifications(userID uuid.UUID, params GetNoti
}
selectQuery := fmt.Sprintf(`
- SELECT id, user_id, type, title, content, link, read, created_at
+ SELECT id, user_id, type, title, content, link, read, created_at,
+ COALESCE(group_key, ''), COALESCE(actor_count, 1), COALESCE(CAST(metadata AS TEXT), '')
FROM notifications %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, where, argIdx, argIdx+1)
selectArgs := append(whereArgs, params.Limit, offset)
@@ -289,6 +293,9 @@ func (ns *NotificationService) GetNotifications(userID uuid.UUID, params GetNoti
¬ification.Link,
¬ification.Read,
¬ification.CreatedAt,
+ ¬ification.GroupKey,
+ ¬ification.ActorCount,
+ ¬ification.Metadata,
); err != nil {
continue
}
@@ -394,16 +401,17 @@ func (ns *NotificationService) DeleteAllNotifications(userID uuid.UUID) error {
return nil
}
-// NotificationPrefs represents notification preferences (N1.3, F553)
+// NotificationPrefs represents notification preferences (N1.3, F553, F552)
type NotificationPrefs struct {
- PushFollow bool `json:"push_follow"`
- PushLike bool `json:"push_like"`
- PushComment bool `json:"push_comment"`
- PushMessage bool `json:"push_message"`
- PushMention bool `json:"push_mention"`
- QuietHoursEnabled bool `json:"quiet_hours_enabled"`
- QuietHoursStart string `json:"quiet_hours_start"` // "22:00"
- QuietHoursEnd string `json:"quiet_hours_end"` // "08:00"
+ PushFollow bool `json:"push_follow"`
+ PushLike bool `json:"push_like"`
+ PushComment bool `json:"push_comment"`
+ PushMessage bool `json:"push_message"`
+ PushMention bool `json:"push_mention"`
+ QuietHoursEnabled bool `json:"quiet_hours_enabled"`
+ QuietHoursStart string `json:"quiet_hours_start"` // "22:00"
+ QuietHoursEnd string `json:"quiet_hours_end"` // "08:00"
+ WeeklyDigestEnabled bool `json:"weekly_digest_enabled"`
}
// GetPreferences returns notification preferences for a user
@@ -416,11 +424,12 @@ func (ns *NotificationService) GetPreferences(userID uuid.UUID) (*NotificationPr
err := ns.db.QueryRowContext(ctx, `
SELECT push_follow, push_like, push_comment, push_message, push_mention,
COALESCE(quiet_hours_enabled, false),
- quiet_hours_start::text, quiet_hours_end::text
+ quiet_hours_start::text, quiet_hours_end::text,
+ COALESCE(weekly_digest_enabled, false)
FROM notification_preferences
WHERE user_id = $1
`, userID).Scan(&prefs.PushFollow, &prefs.PushLike, &prefs.PushComment, &prefs.PushMessage, &prefs.PushMention,
- &prefs.QuietHoursEnabled, &startNullable, &endNullable)
+ &prefs.QuietHoursEnabled, &startNullable, &endNullable, &prefs.WeeklyDigestEnabled)
if err == nil {
if startNullable.Valid {
prefs.QuietHoursStart = startNullable.String
@@ -440,18 +449,18 @@ func (ns *NotificationService) GetPreferences(userID uuid.UUID) (*NotificationPr
return prefs, nil
}
-// UpdatePreferences updates notification preferences (F553: quiet hours)
+// UpdatePreferences updates notification preferences (F553: quiet hours, F552: digest)
func (ns *NotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool,
- quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string) error {
+ quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string, weeklyDigestEnabled *bool) error {
ctx := context.Background()
qhStart := nullIfEmpty(quietHoursStart)
qhEnd := nullIfEmpty(quietHoursEnd)
_, err := ns.db.ExecContext(ctx, `
INSERT INTO notification_preferences (user_id, push_follow, push_like, push_comment, push_message, push_mention,
- quiet_hours_enabled, quiet_hours_start, quiet_hours_end, updated_at)
+ quiet_hours_enabled, quiet_hours_start, quiet_hours_end, weekly_digest_enabled, updated_at)
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), COALESCE($5, true), COALESCE($6, true),
- COALESCE($7, false), $8::time, $9::time, NOW())
+ COALESCE($7, false), $8::time, $9::time, COALESCE($10, false), NOW())
ON CONFLICT (user_id) DO UPDATE SET
push_follow = CASE WHEN $2 IS NOT NULL THEN $2 ELSE notification_preferences.push_follow END,
push_like = CASE WHEN $3 IS NOT NULL THEN $3 ELSE notification_preferences.push_like END,
@@ -461,9 +470,10 @@ func (ns *NotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, p
quiet_hours_enabled = CASE WHEN $7 IS NOT NULL THEN $7 ELSE notification_preferences.quiet_hours_enabled END,
quiet_hours_start = CASE WHEN $8::text IS NOT NULL AND $8::text != '' THEN $8::time ELSE notification_preferences.quiet_hours_start END,
quiet_hours_end = CASE WHEN $9::text IS NOT NULL AND $9::text != '' THEN $9::time ELSE notification_preferences.quiet_hours_end END,
+ weekly_digest_enabled = CASE WHEN $10 IS NOT NULL THEN $10 ELSE notification_preferences.weekly_digest_enabled END,
updated_at = NOW()
`, userID, pushFollow, pushLike, pushComment, pushMessage, pushMention,
- quietHoursEnabled, qhStart, qhEnd)
+ quietHoursEnabled, qhStart, qhEnd, weeklyDigestEnabled)
return err
}
diff --git a/veza-backend-api/internal/services/notification_service_test.go b/veza-backend-api/internal/services/notification_service_test.go
index e50d11478..6d856eaf3 100644
--- a/veza-backend-api/internal/services/notification_service_test.go
+++ b/veza-backend-api/internal/services/notification_service_test.go
@@ -19,7 +19,7 @@ func setupTestNotificationService(t *testing.T) (*NotificationService, *gorm.DB,
require.NoError(t, err)
db.Exec("PRAGMA foreign_keys = ON")
- // Create notifications table manually
+ // Create notifications table manually (incl. F554 grouping columns)
err = db.Exec(`
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
@@ -29,7 +29,10 @@ func setupTestNotificationService(t *testing.T) (*NotificationService, *gorm.DB,
content TEXT NOT NULL,
link TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ group_key TEXT,
+ actor_count INTEGER DEFAULT 1,
+ metadata TEXT
)
`).Error
require.NoError(t, err)
diff --git a/veza-backend-api/migrations/134_weekly_digest_prefs.sql b/veza-backend-api/migrations/134_weekly_digest_prefs.sql
new file mode 100644
index 000000000..1a4016034
--- /dev/null
+++ b/veza-backend-api/migrations/134_weekly_digest_prefs.sql
@@ -0,0 +1,7 @@
+-- Migration 134: Weekly digest opt-in (v0.10.5 F552)
+-- Users can receive a weekly email digest of unread notifications
+
+ALTER TABLE notification_preferences
+ADD COLUMN IF NOT EXISTS weekly_digest_enabled BOOLEAN NOT NULL DEFAULT false;
+
+COMMENT ON COLUMN notification_preferences.weekly_digest_enabled IS 'If true, send weekly email digest of unread notifications';
diff --git a/veza-backend-api/migrations/134_weekly_digest_prefs_down.sql b/veza-backend-api/migrations/134_weekly_digest_prefs_down.sql
new file mode 100644
index 000000000..85a370f14
--- /dev/null
+++ b/veza-backend-api/migrations/134_weekly_digest_prefs_down.sql
@@ -0,0 +1,2 @@
+-- Rollback 134: Weekly digest
+ALTER TABLE notification_preferences DROP COLUMN IF EXISTS weekly_digest_enabled;
diff --git a/veza-backend-api/templates/email/notification_digest.html b/veza-backend-api/templates/email/notification_digest.html
new file mode 100644
index 000000000..755ac00ed
--- /dev/null
+++ b/veza-backend-api/templates/email/notification_digest.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Your Veza weekly digest
+
+
+
+
Your weekly digest
+
Hello {{.Username}},
+
Here are your unread notifications from the past week:
+
+ {{range .Notifications}}
+ -
+ {{.Title}}
+ {{.Content}}
+ {{if .Link}}
+
View →
+ {{end}}
+
+ {{end}}
+
+
+
+
+ You received this because you enabled weekly digest. Manage preferences in your account settings.
+
+
+
+