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:

+ +
+ + View all notifications + +
+
+

+ You received this because you enabled weekly digest. Manage preferences in your account settings. +

+
+ +