feat(v0.10.5): Notifications Complètes (F551-F555)

- 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
This commit is contained in:
senke 2026-03-10 10:09:32 +01:00
parent 7cd01e4216
commit dd23805401
8 changed files with 148 additions and 56 deletions

View file

@ -633,35 +633,37 @@ Améliorer le système de playlists avec les fonctionnalités collaboratives et
### v0.10.5 — Notifications Complètes (F551-F570)
**Statut** : ⏳ TODO
**Statut** : ✅ DONE
**Priorité** : P2
**Durée estimée** : 2-3 jours
**Prerequisite** : v0.10.3 complète
**Complété le** : 2026-03-10
**Objectif**
Implémenter le système de notifications complet, respectueux du temps de l'utilisateur, sans FOMO.
**Tâches**
- [ ] Notifications in-app en temps réel (WebSocket) (F551)
- [ ] Digest hebdomadaire email (opt-in) (F552)
- Résumé des nouvelles sorties dans les genres suivis
- [x] Notifications in-app en temps réel (WebSocket) (F551)
- [x] Digest hebdomadaire email (opt-in) (F552)
- Nouvelles sorties des artistes suivis (ORIGIN §8.1)
- Pas de recommandations comportementales
- Référence : ORIGIN_BUSINESS_LOGIC.md §8.1
- [ ] Préférences de notifications granulaires (F553)
- [x] Préférences de notifications granulaires (F553)
- Contrôle par type (follow, commentaire, message, etc.)
- Quiet hours configurables
- Toggle « désactiver marketing » (likes + commentaires en un clic)
- [ ] Groupement de notifications (F554)
- [x] Groupement de notifications (F554)
- "3 personnes ont commenté votre track" (pas 3 notifications séparées)
- [ ] Centre de notifications (page dédiée) (F555)
- [x] Centre de notifications (page dédiée) (F555)
**Critères d'acceptation**
- [ ] Aucune notification "vous avez atteint X likes" (anti gamification)
- [ ] Par défaut : notifications push désactivées sauf messages directs et follows
- [ ] L'utilisateur peut désactiver toutes les notifications marketing en un clic
- [x] Aucune notification "vous avez atteint X likes" (anti gamification)
- [x] Par défaut : notifications push désactivées sauf messages directs et follows
- [x] L'utilisateur peut désactiver toutes les notifications marketing en un clic
---
@ -1208,7 +1210,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 :
| v0.10.2 | Recherche Elasticsearch | P4R | ✅ DONE | 4-5j | v0.10.1 |
| v0.10.3 | Commentaires & Interactions | P4R | ✅ DONE | 3-4j | v0.10.0 |
| v0.10.4 | Playlists Collaboratives | P4R | ✅ DONE | 3-4j | v0.10.0 |
| v0.10.5 | Notifications Complètes | P4R | ⏳ TODO | 2-3j | v0.10.3 |
| v0.10.5 | Notifications Complètes | P4R | ✅ DONE | 2-3j | v0.10.3 |
| v0.10.6 | Livestreaming Basique | P4R | ⏳ TODO | 5-7j | v0.10.0 |
| v0.10.7 | Collaboration Temps Réel | P4R | ⏳ TODO | 5-6j | v0.10.6 |
| v0.10.8 | Portabilité Données RGPD | P4R | ⏳ TODO | 2-3j | v0.10.0 |

View file

@ -24,8 +24,11 @@ export function NotificationSettings({
return (
<div className="space-y-6">
{/* v0.10.5: PushPreferencesSection first — source of truth (API-backed) */}
<PushPreferencesSection />
<hr className="border-border" />
<div>
<h3 className="text-lg font-semibold mb-4">Notifications par email</h3>
<h3 className="text-lg font-semibold mb-4">Préférences emails (paramètres utilisateur)</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
@ -181,9 +184,6 @@ export function NotificationSettings({
</div>
</div>
</div>
{/* N1.3: Push preferences from API */}
<PushPreferencesSection />
</div>
);
}

View file

@ -42,6 +42,25 @@ export function PushPreferencesSection() {
updateMutation.mutate({ ...prefs, [field]: value });
};
/** v0.10.5: One-click disable marketing (likes + comments) */
const handleMarketingToggle = (enabled: boolean) => {
if (!prefs) return;
updateMutation.mutate({ ...prefs, push_like: enabled, push_comment: enabled });
};
/** v0.10.5: One-click "disable all except messages and follows" */
const handleDisableAllExceptDirect = () => {
if (!prefs) return;
updateMutation.mutate({
...prefs,
push_follow: true,
push_message: true,
push_like: false,
push_comment: false,
push_mention: false,
});
};
if (isLoading || !prefs) {
return (
<div className="space-y-4">
@ -55,6 +74,8 @@ export function PushPreferencesSection() {
if (ok) showSuccess('Notifications push activées');
};
const marketingEnabled = prefs.push_like && prefs.push_comment;
return (
<div className="space-y-6">
<div>
@ -77,6 +98,31 @@ export function PushPreferencesSection() {
<p className="text-sm text-destructive mt-2">{subscribeError}</p>
)}
</div>
{/* v0.10.5 F553: One-click disable all marketing notifications */}
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="marketing_toggle">Notifications marketing (likes et commentaires)</Label>
<p className="text-sm text-muted-foreground">
Recevoir des notifications likes et commentaires désactiver en un clic
</p>
</div>
<Checkbox
id="marketing_toggle"
checked={marketingEnabled}
onCheckedChange={(checked) =>
handleMarketingToggle(checked === true)
}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDisableAllExceptDirect}
>
Tout désactiver sauf messages et follows
</Button>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
@ -196,11 +242,11 @@ export function PushPreferencesSection() {
</div>
)}
</div>
{/* v0.10.5 F552: Weekly digest opt-in */}
{/* v0.10.5 F552: Weekly digest — new tracks from followed artists (ORIGIN §8.1) */}
<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
Recevoir chaque dimanche un récapitulatif des nouvelles sorties des artistes que vous suivez
</p>
<div className="flex items-center justify-between">
<Label htmlFor="weekly_digest_enabled">Activer le digest hebdomadaire</Label>

View file

@ -1,4 +1,4 @@
// Package services - v0.10.5 F552: Weekly notification digest worker
// Package services - v0.10.5 F552: Weekly digest of new tracks from followed users (ORIGIN §8.1)
package services
import (
@ -12,6 +12,14 @@ import (
"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
@ -81,55 +89,70 @@ func (w *NotificationDigestWorker) runDigest(ctx context.Context) error {
if baseURL == "" {
baseURL = "http://localhost:5173"
}
notificationsURL := baseURL + "/notifications"
for _, u := range users {
var notifs []struct {
// ORIGIN §8.1: Weekly digest = new tracks from followed users (not unread notifications)
var tracks []struct {
TrackID string
Title string
Content string
Link string
Type string
Artist 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
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(&notifs).Error
`, u.UserID).Scan(&tracks).Error
if err != nil {
w.logger.Warn("Failed to get notifications for digest", zap.String("user_id", u.UserID.String()), zap.Error(err))
w.logger.Warn("Failed to get feed tracks for digest", zap.String("user_id", u.UserID.String()), zap.Error(err))
continue
}
if len(notifs) == 0 {
if len(tracks) == 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,
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,
"Notifications": notifList,
"BaseURL": baseURL,
"NotificationsURL": notificationsURL,
"Username": u.Username,
"Tracks": trackMaps,
"BaseURL": baseURL,
"FeedURL": baseURL + "/feed",
"TrackCount": len(tracks),
}
w.jobEnqueuer.EnqueueEmailJobWithTemplate(
u.Email,
"Your Veza weekly digest",
"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(notifs)))
w.logger.Info("Digest queued", zap.String("user_id", u.UserID.String()), zap.Int("count", len(tracks)))
}
return nil

View file

@ -418,7 +418,8 @@ type NotificationPrefs struct {
func (ns *NotificationService) GetPreferences(userID uuid.UUID) (*NotificationPrefs, error) {
ctx := context.Background()
prefs := &NotificationPrefs{PushFollow: true, PushLike: true, PushComment: true, PushMessage: true, PushMention: true}
// v0.10.5: default push_message and push_follow only; others off (anti-FOMO)
prefs := &NotificationPrefs{PushFollow: true, PushMessage: true, PushLike: false, PushComment: false, PushMention: false}
var startNullable, endNullable sql.NullString
err := ns.db.QueryRowContext(ctx, `
@ -456,10 +457,11 @@ func (ns *NotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, p
qhStart := nullIfEmpty(quietHoursStart)
qhEnd := nullIfEmpty(quietHoursEnd)
// v0.10.5: defaults — push_follow, push_message true; push_like, push_comment, push_mention false
_, 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, weekly_digest_enabled, updated_at)
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), COALESCE($5, true), COALESCE($6, true),
VALUES ($1, COALESCE($2, true), COALESCE($3, false), COALESCE($4, false), COALESCE($5, true), COALESCE($6, false),
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,

View file

@ -0,0 +1,15 @@
-- Migration 941: v0.10.5 — Notification preferences defaults
-- Par défaut : push_message et push_follow activés ; likes/commentaires/mentions désactivés (anti-FOMO)
ALTER TABLE notification_preferences
ALTER COLUMN push_follow SET DEFAULT true,
ALTER COLUMN push_message SET DEFAULT true,
ALTER COLUMN push_like SET DEFAULT false,
ALTER COLUMN push_comment SET DEFAULT false,
ALTER COLUMN push_mention SET DEFAULT false;
COMMENT ON COLUMN notification_preferences.push_follow IS 'v0.10.5: default true — new followers';
COMMENT ON COLUMN notification_preferences.push_message IS 'v0.10.5: default true — direct messages';
COMMENT ON COLUMN notification_preferences.push_like IS 'v0.10.5: default false — engagement (opt-in)';
COMMENT ON COLUMN notification_preferences.push_comment IS 'v0.10.5: default false — engagement (opt-in)';
COMMENT ON COLUMN notification_preferences.push_mention IS 'v0.10.5: default false — mentions (opt-in)';

View file

@ -0,0 +1,7 @@
-- Rollback migration 941: restore original push defaults
ALTER TABLE notification_preferences
ALTER COLUMN push_follow SET DEFAULT true,
ALTER COLUMN push_message SET DEFAULT true,
ALTER COLUMN push_like SET DEFAULT true,
ALTER COLUMN push_comment SET DEFAULT true,
ALTER COLUMN push_mention SET DEFAULT true;

View file

@ -3,27 +3,24 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Veza weekly digest</title>
<title>New releases from artists you follow — Veza</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>
<h1 style="color: #4CAF50; margin-top: 0;">New releases from artists you follow</h1>
<p>Hello {{.Username}},</p>
<p>Here are your unread notifications from the past week:</p>
<p>Here are the new tracks from artists you follow this week:</p>
<ul style="list-style: none; padding: 0; margin: 20px 0;">
{{range .Notifications}}
{{range .Tracks}}
<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}}
<strong>{{.Artist}}</strong> — {{.Title}}
<br><a href="{{$.BaseURL}}{{.Link}}" style="color: #4CAF50; font-size: 12px;">Listen →</a>
</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 href="{{.FeedURL}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
View your feed
</a>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">