feat(v0.10.5): Notifications complètes — F551-F555
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s

F555: Backend pagination/filter GetNotifications (type, page, limit) + frontend pagination
F551: WebSocket real-time — backend inject chat hub, send on CreateNotification; frontend useChat invalidates
F553: Quiet hours — migration 132, CreateNotification skips push/WS, UI in PushPreferencesSection
F554: Notification grouping — migration 133, group_key/actor_count for like/comment, UI format
F552: Weekly digest — migration 134, NotificationDigestWorker, email template, prefs UI

Acceptance: no gamification notif; defaults unchanged; individual toggles for marketing
This commit is contained in:
senke 2026-03-10 10:02:21 +01:00
parent 22f0c04b3f
commit 7cd01e4216
17 changed files with 292 additions and 42 deletions

View file

@ -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}
</p>
</div>
{notification.content && (
{(notification.content || (notification.actor_count ?? 1) > 1) && (
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">
{notification.content}
{getNotificationDisplayContent(notification)}
</p>
)}
<p className="text-xs text-muted-foreground">

View file

@ -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<string, React.ReactNode> = {
@ -51,7 +52,7 @@ export function NotificationsPageItem({
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-2">
{notification.content}
{getNotificationDisplayContent(notification)}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(notification.created_at), {

View file

@ -24,6 +24,9 @@ export interface Notification {
read: boolean;
created_at: string;
metadata?: Record<string, unknown>; // 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;
}
/**

View file

@ -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<string, string> = {
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;
}

View file

@ -196,6 +196,23 @@ export function PushPreferencesSection() {
</div>
)}
</div>
{/* v0.10.5 F552: Weekly digest opt-in */}
<div>
<h3 className="text-lg font-semibold mb-4">Digest hebdomadaire</h3>
<p className="text-sm text-muted-foreground mb-4">
Recevoir un récapitulatif par email des notifications non lues chaque dimanche
</p>
<div className="flex items-center justify-between">
<Label htmlFor="weekly_digest_enabled">Activer le digest hebdomadaire</Label>
<Checkbox
id="weekly_digest_enabled"
checked={prefs.weekly_digest_enabled ?? false}
onCheckedChange={(checked) =>
handleChange('weekly_digest_enabled', checked === true)
}
/>
</div>
</div>
</div>
);
}

View file

@ -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,
},
});
}),

View file

@ -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")

View file

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

View file

@ -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))
}
}

View file

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

View file

@ -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)
}

View file

@ -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(&notifs).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
}

View file

@ -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
&notification.Link,
&notification.Read,
&notification.CreatedAt,
&notification.GroupKey,
&notification.ActorCount,
&notification.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
}

View file

@ -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)

View file

@ -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';

View file

@ -0,0 +1,2 @@
-- Rollback 134: Weekly digest
ALTER TABLE notification_preferences DROP COLUMN IF EXISTS weekly_digest_enabled;

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Veza weekly digest</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 20px auto; padding: 20px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h1 style="color: #4CAF50; margin-top: 0;">Your weekly digest</h1>
<p>Hello {{.Username}},</p>
<p>Here are your unread notifications from the past week:</p>
<ul style="list-style: none; padding: 0; margin: 20px 0;">
{{range .Notifications}}
<li style="padding: 12px; margin-bottom: 8px; background-color: #f9f9f9; border-radius: 4px; border-left: 4px solid #4CAF50;">
<strong>{{.Title}}</strong><br>
<span style="color: #666;">{{.Content}}</span>
{{if .Link}}
<br><a href="{{$.BaseURL}}{{.Link}}" style="color: #4CAF50; font-size: 12px;">View →</a>
{{end}}
</li>
{{end}}
</ul>
<div style="text-align: center; margin: 30px 0;">
<a href="{{.NotificationsURL}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
View all notifications
</a>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="color: #999; font-size: 11px; text-align: center;">
You received this because you enabled weekly digest. Manage preferences in your account settings.
</p>
</div>
</body>
</html>