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:
parent
7cd01e4216
commit
dd23805401
8 changed files with 148 additions and 56 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(¬ifs).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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;">
|
||||
|
|
|
|||
Loading…
Reference in a new issue