api-notification
Vue d'ensemble
Le service api-notification est le système de notifications multi-canal de la plateforme Roomee. Il consomme les événements AMQP de tous les autres services et dispatche les notifications via plusieurs canaux (Push, Email, In-App, Socket.IO).
| Caractéristique | Détail |
|---|---|
| Port | 3005 |
| Stack | TypeScript, Express.js, Prisma, AMQP Consumer |
| Base de données | MongoDB (roomee_notification) + roomee_members |
| Architecture | Moderne (TS + Event Consumer) |
| Complexité | ⭐⭐⭐⭐ |
Responsabilités
graph TB
NOTIF[api-notification]
subgraph "Canaux"
PUSH[Push Notifications]
EMAIL[Email]
INAPP[In-App]
SOCKET[Socket.IO]
end
subgraph "Features"
TEMPLATES[Templates]
PREFS[Préférences]
QUIET[Heures Silencieuses]
RETRY[Retry & Fallback]
STATS[Statistiques]
ARCHIVE[Archivage]
end
NOTIF --> PUSH
NOTIF --> EMAIL
NOTIF --> INAPP
NOTIF --> SOCKET
NOTIF --> TEMPLATES
NOTIF --> PREFS
NOTIF --> QUIET
NOTIF --> RETRY
NOTIF --> STATS
NOTIF --> ARCHIVE
subgraph "Providers"
ONESIGNAL[OneSignal]
FCM[Firebase FCM]
SENDGRID[SendGrid]
end
PUSH --> ONESIGNAL
PUSH --> FCM
EMAIL --> SENDGRIDStructure du Projet
api-notification/
├── src/
│ ├── app.ts
│ ├── server.ts
│ ├── core/
│ │ ├── consumer/ # AMQP Event Consumer
│ │ │ ├── eventConsumer.ts
│ │ │ └── eventHandler.ts
│ │ ├── processor/ # Notification Processor
│ │ │ ├── notificationProcessor.ts
│ │ │ └── notificationFactory.ts
│ │ └── dispatcher/ # Multi-channel Dispatcher
│ │ ├── notificationDispatcher.ts
│ │ ├── pushDispatcher.ts
│ │ ├── emailDispatcher.ts
│ │ └── socketDispatcher.ts
│ ├── modules/
│ │ ├── notification/
│ │ │ ├── controllers/
│ │ │ ├── services/
│ │ │ └── routes/
│ │ ├── preference/
│ │ ├── subscription/
│ │ ├── template/
│ │ └── analytics/
│ ├── prisma-client/
│ │ ├── notificationClient.ts
│ │ └── memberClient.ts
│ ├── config/
│ ├── middleware/
│ └── utils/
├── prisma/
│ └── schema.prisma
└── __tests/Architecture de Traitement
sequenceDiagram
participant Service as Other Service
participant AMQP as RabbitMQ
participant Consumer as EventConsumer
participant Processor as NotificationProcessor
participant Filter as PreferenceFilter
participant Dispatcher as Dispatcher
participant Channels as Channels (Push/Email/Socket)
Service->>AMQP: Emit event (page.created)
AMQP->>Consumer: Consume event
Consumer->>Processor: Process event
Processor->>Processor: Get template
Processor->>Processor: Render with data
Processor->>Filter: Filter by preferences
Filter->>Filter: Check global silence
Filter->>Filter: Check quiet hours
Filter->>Filter: Check category prefs
Filter-->>Processor: Filtered receivers
Processor->>Dispatcher: Dispatch notifications
Dispatcher->>Channels: Send to channels
Channels-->>Dispatcher: Delivery status
Dispatcher->>Processor: Save delivery attempts
Processor->>Processor: Update countersModèles de Données
Notification
model Notification {
id String @id @default(auto()) @map("_id") @db.ObjectId
// Type et catégorie
type String // "page", "comment", "member", "chat"
category String // "created", "updated", "mention", "invitation"
// Workspace
workspaceId String
ecosystemId String?
// Contenu
title String
body String
data Json? // Données additionnelles
// Acteur
actorId String? // ID du membre qui a déclenché
actorType String? // "member", "system"
// Cible
entityId String? // ID de l'entité (page, comment, etc.)
entityType String? // "page", "comment", "member"
// Canaux
channels String[] // ["push", "email", "socket", "app"]
// Priorité
priority Int @default(1) // 1=normal, 2=important, 3=urgent
// Receivers
receivers Notification_receiver[]
deliveryAttempts Delivery_attempt[]
// Statistiques
sentCount Int @default(0)
deliveredCount Int @default(0)
readCount Int @default(0)
failedCount Int @default(0)
// Archivage
isArchived Boolean @default(false)
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([workspaceId])
@@index([type, category])
@@index([actorId])
@@index([createdAt])
@@index([isArchived])
}Notification_receiver
model Notification_receiver {
id String @id @default(auto()) @map("_id") @db.ObjectId
notificationId String @db.ObjectId
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
memberId String // ID du membre
workspaceId String
// Statut
status String @default("pending") // pending, sent, delivered, read, failed
isRead Boolean @default(false)
readAt DateTime?
// Canaux utilisés
sentChannels String[] // ["push", "socket"]
// Actions
isClicked Boolean @default(false)
clickedAt DateTime?
isDismissed Boolean @default(false)
dismissedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([notificationId, memberId])
@@index([memberId])
@@index([workspaceId])
@@index([status])
@@index([isRead])
}Notification_preference
model Notification_preference {
id String @id @default(auto()) @map("_id") @db.ObjectId
memberId String @unique
workspaceId String
// Silence global
isGlobalSilent Boolean @default(false)
// Heures silencieuses
quietHours Json? // { start: "22:00", end: "08:00", timezone: "Europe/Paris", enabled: true }
// Préférences par catégorie et canal
categoryPrefs Json // { "page.created": { app: true, push: true, email: false }, ... }
// Ne pas déranger
doNotDisturb Boolean @default(false)
dndUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([memberId])
@@index([workspaceId])
}Subscription
model Subscription {
id String @id @default(auto()) @map("_id") @db.ObjectId
memberId String
workspaceId String
// Device info
deviceToken String @unique // OneSignal player ID or FCM token
deviceType String // "ios", "android", "web"
platform String // "onesignal", "fcm"
// App info
appVersion String?
deviceModel String?
osVersion String?
// Statut
isActive Boolean @default(true)
lastUsedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([memberId])
@@index([workspaceId])
@@index([deviceToken])
@@index([isActive])
}Notification_template
model Notification_template {
id String @id @default(auto()) @map("_id") @db.ObjectId
// Identifiant unique
slug String @unique // "page_created", "comment_added"
// Type
type String // "page", "comment", "member"
category String // "created", "updated"
// Contenu avec variables
title String // "{{actorName}} a publié {{pageTitle}}"
body String // "Une nouvelle page a été publiée dans {{workspaceName}}"
// Template email (HTML)
emailSubject String?
emailBody String? // Template Handlebars
// Metadata
variables String[] // ["actorName", "pageTitle", "workspaceName"]
defaultData Json? // Valeurs par défaut
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slug])
@@index([type, category])
}Delivery_attempt
model Delivery_attempt {
id String @id @default(auto()) @map("_id") @db.ObjectId
notificationId String @db.ObjectId
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
memberId String
channel String // "push", "email", "socket"
// Statut
status String // "success", "failed", "retry"
attemptNumber Int @default(1)
// Détails provider
provider String? // "onesignal", "fcm", "sendgrid"
providerId String? // ID externe du message
providerResponse Json?
// Erreur
error String?
errorCode String?
// Timing
sentAt DateTime @default(now())
deliveredAt DateTime?
createdAt DateTime @default(now())
@@index([notificationId])
@@index([memberId])
@@index([channel])
@@index([status])
@@index([sentAt])
}Event Consumer
Event Handler
// core/consumer/eventHandler.ts
class EventHandler {
async handleEvent(message: any) {
const { type, workspaceId, payload, channels, metadata } = message
try {
// 1. Déterminer le template
const template = await this.getTemplate(type)
// 2. Créer la notification
const notification = await notificationProcessor.process({
type: type.split('.')[0], // "page"
category: type.split('.')[1], // "created"
workspaceId,
title: this.renderTemplate(template.title, payload, metadata),
body: this.renderTemplate(template.body, payload, metadata),
data: payload,
actorId: metadata.actor?.id,
actorType: metadata.actor?.type,
entityId: payload.id,
entityType: type.split('.')[0],
channels,
receivers: metadata.receiverIds
})
logger.info('Notification processed', {
notificationId: notification.id,
type,
receiversCount: metadata.receiverIds.length
})
} catch (error) {
logger.error('Failed to handle event', { type, error })
throw error
}
}
private renderTemplate(template: string, payload: any, metadata: any): string {
let rendered = template
// Remplacer les variables
rendered = rendered.replace(/{{actorName}}/g, metadata.actor?.name || 'Un membre')
rendered = rendered.replace(/{{pageTitle}}/g, payload.title || '')
rendered = rendered.replace(/{{workspaceName}}/g, payload.workspaceName || '')
return rendered
}
private async getTemplate(type: string): Promise<Template> {
const slug = type.replace(/\./g, '_') // "page.created" -> "page_created"
const template = await prisma.notification_template.findUnique({
where: { slug }
})
if (!template) {
throw new Error(`Template not found: ${slug}`)
}
return template
}
}Notification Processor
// core/processor/notificationProcessor.ts
class NotificationProcessor {
async process(data: CreateNotificationInput) {
// 1. Créer la notification
const notification = await prisma.notification.create({
data: {
type: data.type,
category: data.category,
workspaceId: data.workspaceId,
title: data.title,
body: data.body,
data: data.data,
actorId: data.actorId,
entityId: data.entityId,
channels: data.channels,
priority: data.priority || 1
}
})
// 2. Filtrer les receivers par préférences
const filteredReceivers = await this.filterReceivers(
data.receivers,
data.type,
data.category,
data.channels
)
// 3. Créer les notification_receiver
await prisma.notification_receiver.createMany({
data: filteredReceivers.map(memberId => ({
notificationId: notification.id,
memberId,
workspaceId: data.workspaceId,
status: 'pending'
}))
})
// 4. Dispatcher les notifications
await notificationDispatcher.dispatch(notification, filteredReceivers)
return notification
}
private async filterReceivers(
receiverIds: string[],
type: string,
category: string,
channels: string[]
): Promise<string[]> {
const filtered: string[] = []
for (const memberId of receiverIds) {
// Récupérer les préférences
const prefs = await prisma.notification_preference.findUnique({
where: { memberId }
})
if (!prefs) {
// Pas de préférences = tout accepté
filtered.push(memberId)
continue
}
// Vérifier silence global
if (prefs.isGlobalSilent) {
continue
}
// Vérifier DND
if (prefs.doNotDisturb && prefs.dndUntil && prefs.dndUntil > new Date()) {
continue
}
// Vérifier heures silencieuses
if (this.isInQuietHours(prefs.quietHours)) {
// Autoriser seulement socket, pas push/email
if (channels.includes('push') || channels.includes('email')) {
continue
}
}
// Vérifier préférences par catégorie
const categoryKey = `${type}.${category}`
const categoryPrefs = prefs.categoryPrefs?.[categoryKey]
if (categoryPrefs) {
let hasEnabledChannel = false
for (const channel of channels) {
if (categoryPrefs[channel] === true) {
hasEnabledChannel = true
break
}
}
if (!hasEnabledChannel) {
continue
}
}
filtered.push(memberId)
}
return filtered
}
private isInQuietHours(quietHours: any): boolean {
if (!quietHours?.enabled) return false
const now = moment().tz(quietHours.timezone)
const start = moment.tz(quietHours.start, 'HH:mm', quietHours.timezone)
const end = moment.tz(quietHours.end, 'HH:mm', quietHours.timezone)
if (start.isBefore(end)) {
return now.isBetween(start, end)
} else {
// Traverse minuit
return now.isAfter(start) || now.isBefore(end)
}
}
}Multi-Channel Dispatcher
// core/dispatcher/notificationDispatcher.ts
class NotificationDispatcher {
async dispatch(notification: Notification, receiverIds: string[]) {
const results = {
push: { success: 0, failed: 0 },
email: { success: 0, failed: 0 },
socket: { success: 0, failed: 0 },
app: { success: 0, failed: 0 }
}
// Dispatch en parallèle
const promises: Promise<any>[] = []
if (notification.channels.includes('push')) {
promises.push(
this.dispatchPush(notification, receiverIds)
.then(r => results.push = r)
)
}
if (notification.channels.includes('email')) {
promises.push(
this.dispatchEmail(notification, receiverIds)
.then(r => results.email = r)
)
}
if (notification.channels.includes('socket')) {
promises.push(
this.dispatchSocket(notification, receiverIds)
.then(r => results.socket = r)
)
}
// In-app notifications (toujours envoyées)
promises.push(
this.createInApp(notification, receiverIds)
.then(r => results.app = r)
)
await Promise.allSettled(promises)
// Mettre à jour les compteurs
await this.updateCounters(notification.id, results)
return results
}
private async dispatchPush(
notification: Notification,
receiverIds: string[]
) {
let success = 0
let failed = 0
// Récupérer les subscriptions actives
const subscriptions = await prisma.subscription.findMany({
where: {
memberId: { in: receiverIds },
workspaceId: notification.workspaceId,
isActive: true
}
})
for (const sub of subscriptions) {
try {
if (sub.platform === 'onesignal') {
await pushDispatcher.sendOneSignal({
playerIds: [sub.deviceToken],
headings: { en: notification.title },
contents: { en: notification.body },
data: notification.data
})
} else if (sub.platform === 'fcm') {
await pushDispatcher.sendFCM({
token: sub.deviceToken,
notification: {
title: notification.title,
body: notification.body
},
data: notification.data
})
}
// Enregistrer la tentative
await prisma.delivery_attempt.create({
data: {
notificationId: notification.id,
memberId: sub.memberId,
channel: 'push',
status: 'success',
provider: sub.platform,
attemptNumber: 1
}
})
success++
} catch (error) {
// Enregistrer l'échec
await prisma.delivery_attempt.create({
data: {
notificationId: notification.id,
memberId: sub.memberId,
channel: 'push',
status: 'failed',
provider: sub.platform,
error: error.message,
attemptNumber: 1
}
})
failed++
}
}
return { success, failed }
}
private async dispatchEmail(
notification: Notification,
receiverIds: string[]
) {
// Récupérer les membres
const members = await memberClient.member.findMany({
where: { id: { in: receiverIds } }
})
let success = 0
let failed = 0
for (const member of members) {
try {
await emailDispatcher.send({
to: member.email,
subject: notification.title,
html: this.renderEmailTemplate(notification, member)
})
await prisma.delivery_attempt.create({
data: {
notificationId: notification.id,
memberId: member.id,
channel: 'email',
status: 'success',
provider: 'sendgrid',
attemptNumber: 1
}
})
success++
} catch (error) {
await prisma.delivery_attempt.create({
data: {
notificationId: notification.id,
memberId: member.id,
channel: 'email',
status: 'failed',
error: error.message,
attemptNumber: 1
}
})
failed++
}
}
return { success, failed }
}
private async dispatchSocket(
notification: Notification,
receiverIds: string[]
) {
let success = 0
// Émettre vers chaque membre
for (const memberId of receiverIds) {
io.to(`member:${memberId}`).emit('notification:new', {
id: notification.id,
type: notification.type,
category: notification.category,
title: notification.title,
body: notification.body,
data: notification.data,
createdAt: notification.createdAt
})
success++
}
// Aussi émettre au workspace entier
io.to(`workspace:${notification.workspaceId}`).emit('notification:count', {
workspaceId: notification.workspaceId
})
return { success, failed: 0 }
}
private async createInApp(
notification: Notification,
receiverIds: string[]
) {
// Les notification_receiver sont déjà créées
// Juste mettre à jour le compteur unread
return { success: receiverIds.length, failed: 0 }
}
}Routes API
📬 Notifications (/api/v1/notifications)
GET /api/v1/notifications
Récupérer les notifications d'un membre.
Query Params :
isRead: Filtrer par statut lu/non lutype: Filtrer par typepage,limit: Pagination
Response :
{
"data": [
{
"id": "65f1234567890abcdef12345",
"type": "page",
"category": "created",
"title": "Marie Dubois a publié Nouvelle politique RH",
"body": "Une nouvelle page a été publiée dans Grand Hotel Paris",
"actor": {
"id": "65faaaaaaaaaaaaaaaaaaaaa",
"firstName": "Marie",
"lastName": "Dubois",
"photo_url": "https://..."
},
"data": {
"pageId": "65fbbbbbbbbbbbbbbbbbbbbb",
"pageTitle": "Nouvelle politique RH"
},
"isRead": false,
"createdAt": "2025-01-21T10:00:00.000Z"
}
],
"meta": {
"total": 45,
"unread": 12,
"page": 1,
"limit": 20
}
}GET /api/v1/notifications/unread-count
Compter les notifications non lues.
Response :
{
"count": 12,
"byType": {
"page": 5,
"comment": 4,
"member": 3
}
}PATCH /api/v1/notifications/:id/read
Marquer une notification comme lue.
POST /api/v1/notifications/read-all
Marquer toutes les notifications comme lues.
DELETE /api/v1/notifications/:id
Supprimer une notification (dismiss).
⚙️ Préférences (/api/v1/preferences)
GET /api/v1/preferences
Récupérer les préférences de notifications.
Response :
{
"memberId": "65faaaaaaaaaaaaaaaaaaaaa",
"isGlobalSilent": false,
"doNotDisturb": false,
"quietHours": {
"enabled": true,
"start": "22:00",
"end": "08:00",
"timezone": "Europe/Paris"
},
"categoryPrefs": {
"page.created": {
"app": true,
"push": true,
"email": false,
"socket": true
},
"comment.created": {
"app": true,
"push": true,
"email": false,
"socket": true
},
"member.registered": {
"app": true,
"push": false,
"email": true,
"socket": false
}
}
}PATCH /api/v1/preferences
Mettre à jour les préférences.
Request :
{
"quietHours": {
"enabled": true,
"start": "23:00",
"end": "07:00",
"timezone": "Europe/Paris"
},
"categoryPrefs": {
"page.created": {
"push": false,
"email": false
}
}
}POST /api/v1/preferences/global-silence
Activer le mode silence global.
POST /api/v1/preferences/do-not-disturb
Activer le mode Ne pas déranger.
Request :
{
"duration": 3600 // Secondes (1 heure)
}📱 Subscriptions (/api/v1/subscriptions)
POST /api/v1/subscriptions
Enregistrer un device pour les push notifications.
Request :
{
"deviceToken": "onesignal-player-id-abc123",
"platform": "onesignal",
"deviceType": "ios",
"appVersion": "1.0.0",
"deviceModel": "iPhone 14 Pro",
"osVersion": "iOS 17.2",
"workspaceId": "65faaaaaaaaaaaaaaaaaaaaa"
}GET /api/v1/subscriptions
Récupérer les devices enregistrés.
DELETE /api/v1/subscriptions/:deviceToken
Désinscrire un device.
📊 Statistiques (/api/v1/analytics)
GET /api/v1/analytics/notifications
Statistiques des notifications.
Response :
{
"period": "last_7_days",
"sent": 1250,
"delivered": 1180,
"read": 890,
"clicked": 340,
"byChannel": {
"push": { "sent": 850, "delivered": 820, "rate": 96.5 },
"email": { "sent": 300, "delivered": 280, "rate": 93.3 },
"socket": { "sent": 1250, "delivered": 1250, "rate": 100 }
},
"byType": {
"page": 600,
"comment": 450,
"member": 200
},
"engagementRate": 27.2
}Templates de Notifications
Templates Prédéfinis
const DEFAULT_TEMPLATES = [
{
slug: 'page_created',
type: 'page',
category: 'created',
title: '{{actorName}} a publié {{pageTitle}}',
body: 'Une nouvelle page a été publiée dans {{workspaceName}}',
emailSubject: 'Nouvelle publication : {{pageTitle}}',
variables: ['actorName', 'pageTitle', 'workspaceName']
},
{
slug: 'comment_created',
type: 'comment',
category: 'created',
title: '{{actorName}} a commenté {{pageTitle}}',
body: '{{commentContent}}',
variables: ['actorName', 'pageTitle', 'commentContent']
},
{
slug: 'member_registered',
type: 'member',
category: 'registered',
title: '{{memberName}} a rejoint {{workspaceName}}',
body: 'Un nouveau membre vient de rejoindre votre workspace',
variables: ['memberName', 'workspaceName']
}
]Configuration
# Server
PORT=3005
# Databases
DATABASE_URL=mongodb://localhost:27017/roomee_notification
MEMBER_DATABASE_URL=mongodb://localhost:27017/roomee_members
# JWT
JWT_ACCESS_SECRET=your-jwt-secret
# AMQP
AMQP_GATEWAY_URL=amqp://localhost:5672
AMQP_EXCHANGE_NAME=roomee_events
AMQP_QUEUE_NAME=notification_queue
AMQP_ROUTING_KEYS=["roomee.notification.*"]
# OneSignal
ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_API_KEY=your-onesignal-api-key
ONESIGNAL_REST_API_KEY=your-onesignal-rest-api-key
# Firebase FCM
FCM_PROJECT_ID=your-firebase-project-id
FCM_PRIVATE_KEY=your-firebase-private-key
FCM_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
# SendGrid (Email)
SENDGRID_API_KEY=your-sendgrid-api-key
EMAIL_FROM=noreply@roomee.io
EMAIL_FROM_NAME=Roomee
# Service URLs
MEMBER_API_URL=http://localhost:3001Providers
OneSignal
// core/dispatcher/pushDispatcher.ts
import OneSignal from 'onesignal-node'
const oneSignalClient = new OneSignal.Client({
app: { appId: process.env.ONESIGNAL_APP_ID },
restKey: process.env.ONESIGNAL_REST_API_KEY
})
async sendOneSignal(data: {
playerIds: string[]
headings: { en: string }
contents: { en: string }
data: any
}) {
const notification = {
include_player_ids: data.playerIds,
headings: data.headings,
contents: data.contents,
data: data.data,
priority: 10,
ios_badgeType: 'Increase',
ios_badgeCount: 1
}
const response = await oneSignalClient.createNotification(notification)
return response
}Firebase FCM
import admin from 'firebase-admin'
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FCM_PROJECT_ID,
privateKey: process.env.FCM_PRIVATE_KEY,
clientEmail: process.env.FCM_CLIENT_EMAIL
})
})
async sendFCM(data: {
token: string
notification: { title: string; body: string }
data: any
}) {
const message = {
token: data.token,
notification: data.notification,
data: data.data,
android: {
priority: 'high' as const
},
apns: {
payload: {
aps: {
badge: 1
}
}
}
}
const response = await admin.messaging().send(message)
return response
}SendGrid
import sgMail from '@sendgrid/mail'
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
async send(data: {
to: string
subject: string
html: string
}) {
const msg = {
to: data.to,
from: {
email: process.env.EMAIL_FROM,
name: process.env.EMAIL_FROM_NAME
},
subject: data.subject,
html: data.html
}
const response = await sgMail.send(msg)
return response
}Bonnes Pratiques
1. Toujours filtrer par préférences
const filteredReceivers = await notificationProcessor.filterReceivers(
receiverIds,
type,
category,
channels
)2. Enregistrer toutes les tentatives
await prisma.delivery_attempt.create({
data: {
notificationId,
memberId,
channel,
status: 'success',
provider: 'onesignal'
}
})3. Gérer les erreurs gracefully
try {
await sendPushNotification(...)
} catch (error) {
// Ne pas bloquer les autres canaux
logger.error('Push failed', { error })
}4. Utiliser les retry avec backoff
const maxRetries = 3
const delays = [1000, 2000, 4000] // Exponentiel
for (let i = 0; i < maxRetries; i++) {
try {
await send()
break
} catch (error) {
if (i === maxRetries - 1) throw error
await sleep(delays[i])
}
}Améliorations Futures
- [ ] Notifications groupées (digest)
- [ ] A/B Testing des templates
- [ ] Rich notifications (images, actions)
- [ ] Notification center web
- [ ] Analytics avancés (conversion tracking)
- [ ] Webhooks pour les événements de livraison
- [ ] SMS notifications (Twilio)
- [ ] Voice notifications
- [ ] Notification scheduling (envoyer plus tard)
- [ ] Machine Learning pour optimiser timing