Skip to content

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éristiqueDétail
Port3005
StackTypeScript, Express.js, Prisma, AMQP Consumer
Base de donnéesMongoDB (roomee_notification) + roomee_members
ArchitectureModerne (TS + Event Consumer)
Complexité⭐⭐⭐⭐

Responsabilités

mermaid
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 --> SENDGRID

Structure 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

mermaid
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 counters

Modèles de Données

Notification

prisma
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

prisma
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

prisma
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

prisma
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

prisma
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

prisma
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

typescript
// 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

typescript
// 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

typescript
// 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 lu
  • type : Filtrer par type
  • page, limit : Pagination

Response :

json
{
  "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 :

json
{
  "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 :

json
{
  "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 :

json
{
  "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 :

json
{
  "duration": 3600 // Secondes (1 heure)
}

📱 Subscriptions (/api/v1/subscriptions)

POST /api/v1/subscriptions

Enregistrer un device pour les push notifications.

Request :

json
{
  "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 :

json
{
  "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

typescript
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

bash
# 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:3001

Providers

OneSignal

typescript
// 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

typescript
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

typescript
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

typescript
const filteredReceivers = await notificationProcessor.filterReceivers(
  receiverIds,
  type,
  category,
  channels
)

2. Enregistrer toutes les tentatives

typescript
await prisma.delivery_attempt.create({
  data: {
    notificationId,
    memberId,
    channel,
    status: 'success',
    provider: 'onesignal'
  }
})

3. Gérer les erreurs gracefully

typescript
try {
  await sendPushNotification(...)
} catch (error) {
  // Ne pas bloquer les autres canaux
  logger.error('Push failed', { error })
}

4. Utiliser les retry avec backoff

typescript
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

Documentation technique Roomee Services