diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 1f0d2908..d9ff4560 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -1,23 +1,26 @@
-name: Build & Push Notification Service Docker Image
+name: Ricash CI - Build & Push Docker Image
on:
+ pull_request:
+ branches: [ develop ]
push:
- branches: ["main"]
+ branches: [ develop, main ]
permissions:
contents: read
packages: write
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ricash-org/notification-service
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v3
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -29,11 +32,22 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build & Push
- uses: docker/build-push-action@v5
- with:
- context: .
- push: true
- platforms: linux/amd64,linux/arm64
- file: ./Dockerfile
- tags: ghcr.io/ricash-org/notification-service:latest
+ # ---------- PR BUILD (NO PUSH) ----------
+ - name: Build Docker image (PR)
+ if: github.event_name == 'pull_request'
+ run: |
+ docker build -t $REGISTRY/$IMAGE_NAME:pr-${{ github.event.pull_request.number }} .
+
+ # ---------- DEVELOP ----------
+ - name: Build & Push (develop)
+ if: github.ref == 'refs/heads/develop'
+ run: |
+ docker build -t $REGISTRY/$IMAGE_NAME:dev .
+ docker push $REGISTRY/$IMAGE_NAME:dev
+
+ # ---------- MAIN / PROD ----------
+ - name: Build & Push (prod)
+ if: github.ref == 'refs/heads/main'
+ run: |
+ docker build -t $REGISTRY/$IMAGE_NAME:prod .
+ docker push $REGISTRY/$IMAGE_NAME:prod
diff --git a/Docker-compose.yml b/Docker-compose.yml
index d28d901b..c33f98a1 100644
--- a/Docker-compose.yml
+++ b/Docker-compose.yml
@@ -6,13 +6,13 @@ services:
container_name: notification-service
restart: always
ports:
- - "3000:3000"
+ - "8005:8005"
env_file: .env
depends_on:
- postgres
- rabbitmq
networks:
- - notification-net
+ - ricash-net
postgres:
image: postgres:16
@@ -27,8 +27,8 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- - notification-net
-
+ - ricash-net
+
rabbitmq:
image: rabbitmq:3-management
container_name: rabbitmq
@@ -38,11 +38,12 @@ services:
environment:
RABBITMQ_DEFAULT_USER: ricash
RABBITMQ_DEFAULT_PASS: ricash123
-
-
+ networks:
+ - ricash-net
networks:
- notification-net:
+ ricash-net:
+ external: true
volumes:
pgdata:
diff --git a/README.md b/README.md
index 873e5e43..250f883a 100644
--- a/README.md
+++ b/README.md
@@ -3,125 +3,190 @@
Ce projet implémente un **service de notifications** en **Node.js**, **Express** et **TypeScript**.
Il gère deux fonctionnalités principales :
-- La génération et la vérification d’OTP (codes à usage unique).
-- L’envoi de notifications (par e-mail,SMS ou autres canaux).
-
+- La génération et la vérification d’OTP (codes à usage unique).
+- L’envoi de notifications (par e-mail,SMS ou autres canaux).
---
-## Fonctionnalités principales
+## Fonctionnalités principales
-- Génération et validation d’OTP avec expiration automatique.
-- Envoi de notifications personnalisées via des templates.
+- Génération et validation d’OTP avec expiration automatique.
+- Envoi de notifications personnalisées via des templates.
- Architecture modulaire : contrôleurs, services, entités, utilitaires.
---
-# Endpoints
+
+# Endpoints
Tous les endpoints sont accessibles sous :
/api/notifications
-
- **Envoi d’une notification**
-
- Post /api/notifications/envoyer
-
- **Body json**
-{
- "utilisateurId": "+22350087965",
- "typeNotification": "CONFIRMATION_TRANSFERT",
- "canal": "SMS",
- "context": {
- "montant": 10000,
- "destinataire": "Aisha"
- }
-}
-
-**Réponse json**
-
-{
- "id": 42,
- "utilisateurId": "+22350087965",
- "typeNotification": "CONFIRMATION_TRANSFERT",
- "canal": "SMS",
- "message": "Votre transfert de 10000 F CFA à Aisha a été confirmé.",
- "statut": "ENVOYEE",
- "createdAt": "2025-12-02T20:10:00.000Z"
-}
-
-
-**Génération d'otp**
-
-POST /api/notifications/otp/generate
-
-**Body json**
-
--Envoi par numéro de téléphone
-{
- "utilisateurId": "+22350087965",
- "canalNotification": "SMS"
-}
--Envoi par email
-{
- "utilisateurId": "youremail@gmail.com",
- "canalNotification": "EMAIL"
-}
-
-**Vérification d'un otp**
-
-POST /api/notifications/otp/verify
-**BODY JSON**
+## Fonctionnalités principales
+
+- Génération et validation d’OTP avec expiration automatique.
+- Envoi de notifications personnalisées via des templates.
+- Intégration RabbitMQ : consommation d’événements de `wallet-service` (dépôt, retrait, transfert, OTP…) et transformation en notifications.
+- Validation stricte des payloads HTTP avec **Zod** (emails et téléphones obligatoires, structure `transfer` dédiée, etc.).
+
+---
+
+## Endpoints HTTP
+
+Tous les endpoints HTTP exposés par ce service sont préfixés par :
+
+- `/api/notifications`
+
+### 1. Envoi d’une notification (HTTP direct)
+
+`POST /api/notifications/envoyer`
+
+Depuis la refonte, le service est **strictement dépendant des coordonnées fournies dans le JSON**. Deux formes sont possibles :
+
+#### a) Notification de transfert
+
+```json
{
- "utilisateurId": "+22350087965",
- "code": "1234"
+ "type": "transfer",
+ "sender": {
+ "email": "expediteur@mail.com",
+ "phone": "+22300000000"
+ },
+ "receiver": {
+ "email": "destinataire@mail.com",
+ "phone": "+22311111111"
+ },
+ "amount": 5000,
+ "content": "Transfert de 5000 FCFA réussi."
}
-**Réponse**
-{
- "success": true,
- "message": "OTP validé"
+```
+
+- Le schéma Zod impose :
+ - `type` = `"transfer"`.
+ - `sender.email` / `sender.phone` obligatoires.
+ - `receiver.email` / `receiver.phone` obligatoires.
+ - `amount > 0`.
+ - `content` non vide.
+- Le service crée **deux paires de notifications** (SMS + EMAIL) :
+ - Pour l’expéditeur (role = `SENDER`).
+ - Pour le destinataire (role = `RECEIVER`).
+- Les messages sont envoyés :
+ - par SMS via Twilio sur `phone`.
+ - par email via `mailService.sendEmail` sur `email`.
+- Le `context` des entités `Notification` contient notamment `montant` et `role`.
+
+#### b) Notification simple (autres types)
+
+```json
+{
+ "type": "ALERT_SECURITE",
+ "user": {
+ "email": "client@mail.com",
+ "phone": "+22322222222"
+ },
+ "content": "Connexion suspecte détectée."
}
+```
-**Autres réponses possibles**
+- `type` peut être l’une des valeurs de `TypeNotification` (sauf `"transfer"` qui utilise le schéma dédié).
+- `user.email` et `user.phone` sont obligatoires.
+- Le service envoie systématiquement la notification **à la fois par SMS et par email**.
-{ "success": false, "message": "Code invalide" }
-{ "success": false, "message": "Code expiré" }
-{ "success": false, "message": "Ce code a déjà été utilisé" }
+En cas de JSON invalide (champ manquant / mauvais type), le contrôleur renvoie :
----
-## Structure du projet
+```json
+{
+ "success": false,
+ "message": "Corps de requête invalide",
+ "errors": { ...détail Zod... }
+}
+```
+### 2. Génération d’OTP
-```bash
-notification-service/
-│
-├── src/
-│ ├── controllers/
-│ │ ├── notificationController.ts # Gère les requêtes liées à l’envoi de notifications
-│ │ ├── otpController.ts # Gère la génération et la vérification des OTP
-│ │
-│ ├── entities/
-│ │ ├── Notification.ts # Modèle de données pour les notifications
-│ │ ├── Otp.ts # Modèle de données pour les OTP (code, expiration, utilisateur)
-│ │
-│ ├── routes/
-│ │ ├── notificationRoutes.ts # Définition des routes Express pour les notifications et OTP
-│ │
-│ ├── services/
-│ │ ├── notificationService.ts # Logique métier liée aux notifications
-│ │ ├── otpService.ts # Logique métier liée aux OTP
-│ │
-│ ├── utils/
-│ │ ├── mailService.ts # Gère l’envoi des e-mails (transporteur, configuration…)
-│ │ ├── messageTemplates.ts # Contient les templates des messages
-│ │
-│ ├── app.ts # Configuration principale de l’application Express
-│ ├── data-source.ts # Configuration et connexion à la base de données
-│ ├── index.ts # Point d’entrée pour la déclaration des routes
-│ ├── server.ts # Lancement du serveur Express
-│
-├── .env # Variables d’environnement (PORT, DB_URL, etc.)
-├── package.json # Dépendances et scripts du projet
-├── tsconfig.json # Configuration TypeScript
+`POST /api/notifications/otp/generate`
+
+Le service génère un code OTP (4 chiffres), l’enregistre en base avec une expiration (5 minutes) puis publie un événement `otp.verification` sur RabbitMQ. Désormais, il dépend **strictement** des coordonnées envoyées dans le JSON.
+```json
+{
+ "utilisateurId": "user-otp-1",
+ "canalNotification": "SMS",
+ "email": "userotp@mail.com",
+ "phone": "+22300000000"
+}
+```
+
+- `utilisateurId`: identifiant métier (user id).
+- `canalNotification`: `"SMS"` ou `"EMAIL"`.
+- `email`: email du destinataire (obligatoire).
+- `phone`: numéro du destinataire (obligatoire).
+
+│ │ ├── Notification.ts # Modèle de données pour les notifications
+
+L’événement publié (contrat inter-services) contient :
+
+```json
+{
+ "utilisateurId": "user-otp-1",
+ "typeNotification": "VERIFICATION_TELEPHONE",
+ "canal": "SMS",
+ "context": { "code": "1234" },
+ "email": "userotp@mail.com",
+ "phone": "+22300000000",
+ "metadata": {
+ "service": "notification-service:otp",
+ "correlationId": "otp-"
+ }
+}
+```
+
+Les templates de message utilisent ce `context` pour produire des textes explicites, par exemple :
+
+- `VERIFICATION_TELEPHONE` :
+ > « Votre code OTP de vérification téléphone est : {code}. Ce code est valable 5 minutes. Ne le partagez jamais avec un tiers. »
+### 3. Vérification d’un OTP
+`POST /api/notifications/otp/verify`
+
+Body JSON :
+
+```json
+{
+ "utilisateurId": "user-otp-1",
+ "code": "1234"
+}
+```
+
+Réponses possibles :
+
+```json
+{ "success": true, "message": "OTP validé" }
+{ "success": false, "message": "Code invalide" }
+{ "success": false, "message": "Code expiré" }
+{ "success": false, "message": "Ce code a déjà été utilisé" }
+```
+
+---
+
+│ │ ├── Otp.ts # Modèle de données pour les OTP (code, expiration, utilisateur)
+│ │
+│ ├── routes/
+│ │ ├── notificationRoutes.ts # Définition des routes Express pour les notifications et OTP
+│ │
+│ ├── services/
+│ │ ├── notificationService.ts # Logique métier liée aux notifications
+│ │ ├── otpService.ts # Logique métier liée aux OTP
+│ │
+│ ├── utils/
+│ │ ├── mailService.ts # Gère l’envoi des e-mails (transporteur, configuration…)
+│ │ ├── messageTemplates.ts # Contient les templates des messages
+│ │
+│ ├── app.ts # Configuration principale de l’application Express
+│ ├── data-source.ts # Configuration et connexion à la base de données
+│ ├── index.ts # Point d’entrée pour la déclaration des routes
+│ ├── server.ts # Lancement du serveur Express
+│
+├── .env # Variables d’environnement (PORT, DB_URL, etc.)
+├── package.json # Dépendances et scripts du projet
+├── tsconfig.json # Configuration TypeScript
diff --git a/dist/app.js b/dist/app.js
new file mode 100644
index 00000000..0cd1fc7c
--- /dev/null
+++ b/dist/app.js
@@ -0,0 +1,11 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const express_1 = __importDefault(require("express"));
+const notificationRoutes_1 = __importDefault(require("./routes/notificationRoutes"));
+const app = (0, express_1.default)();
+app.use(express_1.default.json());
+app.use("/api/notifications", notificationRoutes_1.default);
+exports.default = app;
diff --git a/dist/config/rabbitmq.js b/dist/config/rabbitmq.js
new file mode 100644
index 00000000..7e34b486
--- /dev/null
+++ b/dist/config/rabbitmq.js
@@ -0,0 +1,108 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function (o) {
+ var ar = [];
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ __setModuleDefault(result, mod);
+ return result;
+ };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.QUEUE_DLQ = exports.QUEUE_RETRY = exports.RK_DLQ = exports.RK_RETRY = exports.RK_MAIN = exports.QUEUE = exports.EXCHANGE = void 0;
+exports.ensureChannel = ensureChannel;
+exports.getRabbitChannel = getRabbitChannel;
+const amqp = __importStar(require("amqplib"));
+//let connection: amqp.Connection | null = null;
+let channel = null;
+/** Variables standardisées */
+exports.EXCHANGE = process.env.RABBITMQ_EXCHANGE;
+exports.QUEUE = process.env.RABBITMQ_QUEUE;
+/** Routing keys internes */
+exports.RK_MAIN = "notification.process";
+exports.RK_RETRY = "notification.retry";
+exports.RK_DLQ = "notification.dlq";
+/** Queues dérivées (privées au service) */
+exports.QUEUE_RETRY = `${exports.QUEUE}.retry`;
+exports.QUEUE_DLQ = `${exports.QUEUE}.dlq`;
+async function ensureChannel() {
+ if (channel)
+ return channel;
+ try {
+ console.log("Tentative de connexion à RabbitMQ...");
+ let connection = await amqp.connect(process.env.RABBITMQ_URL);
+ // garder une référence locale non-nulle pour satisfaire TypeScript
+ const conn = connection;
+ conn.on("close", () => {
+ console.error("RabbitMQ fermé – reconnexion...");
+ // channel = null;
+ //connection = null;
+ setTimeout(ensureChannel, 3000);
+ });
+ conn.on("error", (err) => {
+ console.error("Erreur RabbitMQ:", err);
+ });
+ channel = await conn.createChannel();
+ const ch = channel;
+ // Exchange partagé (doit être le même que celui utilisé par wallet-service, ex: "ricash.events")
+ await ch.assertExchange(exports.EXCHANGE, "topic", { durable: true });
+ // Queue principale
+ await ch.assertQueue(exports.QUEUE, { durable: true });
+ // événements venant du wallet-service
+ await ch.bindQueue(exports.QUEUE, exports.EXCHANGE, "wallet.*");
+ await ch.bindQueue(exports.QUEUE, exports.EXCHANGE, "wallet.transfer.*");
+ // événements OTP (ex: "otp.verification")
+ await ch.bindQueue(exports.QUEUE, exports.EXCHANGE, "otp.*");
+ // routing key interne historique du service de notifications
+ await ch.bindQueue(exports.QUEUE, exports.EXCHANGE, exports.RK_MAIN);
+ // Queue retry
+ await ch.assertQueue(exports.QUEUE_RETRY, {
+ durable: true,
+ arguments: {
+ "x-message-ttl": 5000,
+ "x-dead-letter-exchange": exports.EXCHANGE,
+ "x-dead-letter-routing-key": exports.RK_MAIN,
+ },
+ });
+ await ch.bindQueue(exports.QUEUE_RETRY, exports.EXCHANGE, exports.RK_RETRY);
+ // DLQ
+ await ch.assertQueue(exports.QUEUE_DLQ, { durable: true });
+ await ch.bindQueue(exports.QUEUE_DLQ, exports.EXCHANGE, exports.RK_DLQ);
+ console.log(`RabbitMQ prêt pour la queue ${exports.QUEUE}`);
+ return ch;
+ }
+ catch (err) {
+ console.error("Erreur de connexion RabbitMQ:", err);
+ throw err;
+ }
+}
+function getRabbitChannel() {
+ if (!channel) {
+ throw new Error("RabbitMQ non initialisé !");
+ }
+ return channel;
+}
diff --git a/dist/controllers/notificationController.js b/dist/controllers/notificationController.js
new file mode 100644
index 00000000..275428a1
--- /dev/null
+++ b/dist/controllers/notificationController.js
@@ -0,0 +1,61 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.getNotifications = exports.envoyerNotification = void 0;
+exports.testRabbitMQ = testRabbitMQ;
+const zod_1 = require("zod");
+const publisher_1 = require("../messaging/publisher");
+const notificationService_1 = require("../services/notificationService");
+const service = new notificationService_1.NotificationService();
+const ContactSchema = zod_1.z.object({
+ email: zod_1.z.string().email(),
+ phone: zod_1.z.string().min(8),
+});
+const TransferNotificationSchema = zod_1.z.object({
+ type: zod_1.z.literal("transfer"),
+ sender: ContactSchema,
+ receiver: ContactSchema,
+ amount: zod_1.z.number().positive(),
+ content: zod_1.z.string().min(1),
+});
+const SimpleNotificationSchema = zod_1.z.object({
+ type: zod_1.z
+ .string()
+ .min(1)
+ .refine((value) => value !== "transfer", {
+ message: 'Utiliser le schéma "transfer" lorsque type = "transfer".',
+ }),
+ user: ContactSchema,
+ content: zod_1.z.string().min(1),
+});
+const NotificationBodySchema = zod_1.z.union([
+ TransferNotificationSchema,
+ SimpleNotificationSchema,
+]);
+const envoyerNotification = async (req, res) => {
+ try {
+ const parsed = NotificationBodySchema.safeParse(req.body);
+ if (!parsed.success) {
+ return res.status(400).json({
+ success: false,
+ message: "Corps de requête invalide",
+ errors: parsed.error.flatten(),
+ });
+ }
+ const notif = await service.envoyerNotificationFromHttp(parsed.data);
+ res.status(201).json({ success: true, data: notif });
+ }
+ catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+};
+exports.envoyerNotification = envoyerNotification;
+const getNotifications = async (req, res) => {
+ const list = await service.getAll();
+ res.json(list);
+};
+exports.getNotifications = getNotifications;
+async function testRabbitMQ(req, res) {
+ const { routingKey, message } = req.body;
+ await (0, publisher_1.publishNotification)(routingKey || "notification.process", message ?? { test: true });
+ res.json({ success: true });
+}
diff --git a/dist/controllers/optController.js b/dist/controllers/optController.js
new file mode 100644
index 00000000..ada808bc
--- /dev/null
+++ b/dist/controllers/optController.js
@@ -0,0 +1,46 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.verifyOtp = exports.generateOtp = void 0;
+const zod_1 = require("zod");
+const Notification_1 = require("../entities/Notification");
+const otpService_1 = require("../services/otpService");
+const otpService = new otpService_1.OtpService();
+const GenerateOtpSchema = zod_1.z.object({
+ utilisateurId: zod_1.z.string().min(1),
+ canalNotification: zod_1.z.enum(["SMS", "EMAIL"]),
+ email: zod_1.z.string().email(),
+ phone: zod_1.z.string().min(8),
+});
+const generateOtp = async (req, res) => {
+ try {
+ const parsed = GenerateOtpSchema.safeParse(req.body);
+ if (!parsed.success) {
+ return res.status(400).json({
+ success: false,
+ message: "Corps de requête invalide",
+ errors: parsed.error.flatten(),
+ });
+ }
+ const { utilisateurId, canalNotification, email, phone } = parsed.data;
+ const canalEnum = canalNotification === "SMS"
+ ? Notification_1.CanalNotification.SMS
+ : Notification_1.CanalNotification.EMAIL;
+ const result = await otpService.createOtp(utilisateurId, canalEnum, email, phone);
+ res.json(result);
+ }
+ catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+};
+exports.generateOtp = generateOtp;
+const verifyOtp = async (req, res) => {
+ try {
+ const { utilisateurId, code } = req.body;
+ const result = await otpService.verifyOtp(utilisateurId, code);
+ res.json(result);
+ }
+ catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+};
+exports.verifyOtp = verifyOtp;
diff --git a/dist/data-source.js b/dist/data-source.js
new file mode 100644
index 00000000..02851fef
--- /dev/null
+++ b/dist/data-source.js
@@ -0,0 +1,23 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.AppDataSource = void 0;
+require("reflect-metadata");
+const typeorm_1 = require("typeorm");
+const Notification_1 = require("./entities/Notification");
+const dotenv_1 = __importDefault(require("dotenv"));
+const Otp_1 = require("./entities/Otp");
+dotenv_1.default.config();
+exports.AppDataSource = new typeorm_1.DataSource({
+ type: "postgres",
+ host: process.env.DB_HOST,
+ port: parseInt(process.env.DB_PORT || "5432"),
+ username: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_NAME,
+ entities: [Notification_1.Notification, Otp_1.Otp],
+ synchronize: true, // auto-crée les tables
+ logging: true,
+});
diff --git a/dist/entities/Notification.js b/dist/entities/Notification.js
new file mode 100644
index 00000000..ce62a83d
--- /dev/null
+++ b/dist/entities/Notification.js
@@ -0,0 +1,89 @@
+"use strict";
+var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
+};
+var __metadata = (this && this.__metadata) || function (k, v) {
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Notification = exports.StatutNotification = exports.CanalNotification = exports.TypeNotification = void 0;
+const typeorm_1 = require("typeorm");
+var TypeNotification;
+(function (TypeNotification) {
+ TypeNotification["CONFIRMATION_TRANSFERT"] = "CONFIRMATION_TRANSFERT";
+ TypeNotification["CONFIRMATION_RETRAIT"] = "CONFIRMATION_RETRAIT";
+ TypeNotification["RETRAIT_REUSSI"] = "RETRAIT_REUSSI";
+ TypeNotification["DEPOT_REUSSI"] = "DEPOT_REUSSI";
+ TypeNotification["ALERT_SECURITE"] = "ALERT_SECURITE";
+ TypeNotification["VERIFICATION_KYC"] = "VERIFICATION_KYC";
+ TypeNotification["VERIFICATION_EMAIL"] = "VERIFICATION_EMAIL";
+ TypeNotification["VERIFICATION_TELEPHONE"] = "VERIFICATION_TELEPHONE";
+})(TypeNotification || (exports.TypeNotification = TypeNotification = {}));
+var CanalNotification;
+(function (CanalNotification) {
+ CanalNotification["SMS"] = "SMS";
+ CanalNotification["EMAIL"] = "EMAIL";
+ CanalNotification["PUSH"] = "PUSH";
+ CanalNotification["WHATSAPP"] = "WHATSAPP";
+})(CanalNotification || (exports.CanalNotification = CanalNotification = {}));
+var StatutNotification;
+(function (StatutNotification) {
+ StatutNotification["CREE"] = "CREE";
+ StatutNotification["EN_COURS"] = "EN_COURS";
+ StatutNotification["ENVOYEE"] = "ENVOYEE";
+ StatutNotification["LUE"] = "LUE";
+ StatutNotification["ECHEC"] = "ECHEC";
+})(StatutNotification || (exports.StatutNotification = StatutNotification = {}));
+let Notification = class Notification {
+};
+exports.Notification = Notification;
+__decorate([
+ (0, typeorm_1.PrimaryGeneratedColumn)("uuid"),
+ __metadata("design:type", String)
+], Notification.prototype, "id", void 0);
+__decorate([
+ (0, typeorm_1.Column)(),
+ __metadata("design:type", String)
+], Notification.prototype, "utilisateurId", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ nullable: true }),
+ __metadata("design:type", String)
+], Notification.prototype, "destinationEmail", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ nullable: true }),
+ __metadata("design:type", String)
+], Notification.prototype, "destinationPhone", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ type: "enum", enum: TypeNotification }),
+ __metadata("design:type", String)
+], Notification.prototype, "typeNotification", void 0);
+__decorate([
+ (0, typeorm_1.Column)(),
+ __metadata("design:type", String)
+], Notification.prototype, "message", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ type: "enum", enum: CanalNotification }),
+ __metadata("design:type", String)
+], Notification.prototype, "canal", void 0);
+__decorate([
+ (0, typeorm_1.Column)({
+ type: "enum",
+ enum: StatutNotification,
+ default: StatutNotification.CREE,
+ }),
+ __metadata("design:type", String)
+], Notification.prototype, "statut", void 0);
+__decorate([
+ (0, typeorm_1.CreateDateColumn)(),
+ __metadata("design:type", Date)
+], Notification.prototype, "dateEnvoi", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ type: "simple-json", nullable: true }),
+ __metadata("design:type", Object)
+], Notification.prototype, "context", void 0);
+exports.Notification = Notification = __decorate([
+ (0, typeorm_1.Entity)()
+], Notification);
diff --git a/dist/entities/Otp.js b/dist/entities/Otp.js
new file mode 100644
index 00000000..88f528c3
--- /dev/null
+++ b/dist/entities/Otp.js
@@ -0,0 +1,56 @@
+"use strict";
+var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
+};
+var __metadata = (this && this.__metadata) || function (k, v) {
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Otp = void 0;
+const typeorm_1 = require("typeorm");
+const Notification_1 = require("./Notification");
+let Otp = class Otp {
+};
+exports.Otp = Otp;
+__decorate([
+ (0, typeorm_1.PrimaryGeneratedColumn)("uuid"),
+ __metadata("design:type", String)
+], Otp.prototype, "id", void 0);
+__decorate([
+ (0, typeorm_1.Column)(),
+ __metadata("design:type", String)
+], Otp.prototype, "utilisateurId", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ nullable: true }),
+ __metadata("design:type", String)
+], Otp.prototype, "destinationEmail", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ nullable: true }),
+ __metadata("design:type", String)
+], Otp.prototype, "destinationPhone", void 0);
+__decorate([
+ (0, typeorm_1.Column)(),
+ __metadata("design:type", String)
+], Otp.prototype, "code", void 0);
+__decorate([
+ (0, typeorm_1.Column)(),
+ __metadata("design:type", String)
+], Otp.prototype, "canal", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ default: false }),
+ __metadata("design:type", Boolean)
+], Otp.prototype, "utilise", void 0);
+__decorate([
+ (0, typeorm_1.CreateDateColumn)(),
+ __metadata("design:type", Date)
+], Otp.prototype, "createdAt", void 0);
+__decorate([
+ (0, typeorm_1.Column)({ type: "timestamp" }),
+ __metadata("design:type", Date)
+], Otp.prototype, "expiration", void 0);
+exports.Otp = Otp = __decorate([
+ (0, typeorm_1.Entity)()
+], Otp);
diff --git a/dist/index.js b/dist/index.js
new file mode 100644
index 00000000..7f9d2e89
--- /dev/null
+++ b/dist/index.js
@@ -0,0 +1,23 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const express_1 = require("express");
+const optController_1 = require("./controllers/optController");
+const notificationController_1 = require("./controllers/notificationController");
+const router = (0, express_1.Router)();
+// Notifications
+router.post("/notifications/envoyer", notificationController_1.envoyerNotification);
+router.post("/rabbitmq", notificationController_1.testRabbitMQ);
+// OTP
+router.post("/otp/generate", optController_1.generateOtp);
+router.post("/otp/verify", optController_1.verifyOtp);
+exports.default = router;
+require("dotenv").config();
+const express = require("express");
+const healthRoute = require("../routes/health");
+const app = express();
+const PORT = process.env.SERVICE_PORT || 8000;
+app.use(express.json());
+app.use("/", healthRoute);
+app.listen(PORT, () => {
+ console.log(`🚀 Service running on port ${PORT}`);
+});
diff --git a/dist/messaging/consumer.js b/dist/messaging/consumer.js
new file mode 100644
index 00000000..808fc60c
--- /dev/null
+++ b/dist/messaging/consumer.js
@@ -0,0 +1,37 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.startConsumer = startConsumer;
+const rabbitmq_1 = require("../config/rabbitmq");
+const notificationService_1 = require("../services/notificationService");
+const notifService = new notificationService_1.NotificationService();
+async function startConsumer() {
+ const channel = (0, rabbitmq_1.getRabbitChannel)();
+ console.log(`Consumer interne prêt sur ${rabbitmq_1.QUEUE}`);
+ channel.consume(rabbitmq_1.QUEUE, async (msg) => {
+ if (!msg)
+ return;
+ const payload = JSON.parse(msg.content.toString());
+ try {
+ await notifService.envoyerNotification(payload);
+ channel.ack(msg);
+ }
+ catch (error) {
+ const retryCount = Number(msg.properties.headers?.["x-retries"] ?? 0);
+ if (retryCount < 3) {
+ channel.publish(rabbitmq_1.EXCHANGE, rabbitmq_1.RK_RETRY, msg.content, {
+ headers: { "x-retries": retryCount + 1 },
+ persistent: true,
+ });
+ }
+ else {
+ channel.publish(rabbitmq_1.EXCHANGE, rabbitmq_1.RK_DLQ, msg.content, {
+ headers: {
+ "x-final-error": error instanceof Error ? error.message : String(error),
+ },
+ persistent: true,
+ });
+ }
+ channel.ack(msg);
+ }
+ });
+}
diff --git a/dist/messaging/contracts/interServices.js b/dist/messaging/contracts/interServices.js
new file mode 100644
index 00000000..c8ad2e54
--- /dev/null
+++ b/dist/messaging/contracts/interServices.js
@@ -0,0 +1,2 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/dist/messaging/externalConsumer.js b/dist/messaging/externalConsumer.js
new file mode 100644
index 00000000..94aedb03
--- /dev/null
+++ b/dist/messaging/externalConsumer.js
@@ -0,0 +1,27 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.startExternalNotificationConsumer = startExternalNotificationConsumer;
+const rabbitmq_1 = require("../config/rabbitmq");
+const notificationService_1 = require("../services/notificationService");
+const notification_mapper_1 = require("./mappers/notification.mapper");
+async function startExternalNotificationConsumer() {
+ const channel = await (0, rabbitmq_1.ensureChannel)();
+ console.log("Consumer externe prêt");
+ channel.consume(rabbitmq_1.QUEUE, async (msg) => {
+ if (!msg)
+ return;
+ const payload = JSON.parse(msg.content.toString());
+ try {
+ console.log("[ExternalConsumer] Message reçu sur", rabbitmq_1.QUEUE, "payload:", payload);
+ const service = new notificationService_1.NotificationService();
+ const notification = (0, notification_mapper_1.mapInterServiceToNotification)(payload);
+ await service.envoyerNotification(notification);
+ console.log("[ExternalConsumer] Notification traitée pour utilisateurId=", notification.utilisateurId);
+ channel.ack(msg);
+ }
+ catch (error) {
+ console.error("Erreur consumer externe", error);
+ channel.nack(msg, false, false);
+ }
+ });
+}
diff --git a/dist/messaging/mappers/notification.mapper.js b/dist/messaging/mappers/notification.mapper.js
new file mode 100644
index 00000000..721b6b9a
--- /dev/null
+++ b/dist/messaging/mappers/notification.mapper.js
@@ -0,0 +1,25 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.mapInterServiceToNotification = mapInterServiceToNotification;
+function mapInterServiceToNotification(payload) {
+ // On choisit la "vraie" cible en fonction du canal :
+ // - EMAIL -> on privilégie payload.email si présent
+ // - SMS -> on privilégie payload.phone si présent
+ // - autres -> on retombe sur payload.utilisateurId
+ let utilisateurId = payload.utilisateurId;
+ if (payload.canal === "EMAIL" && payload.email) {
+ utilisateurId = payload.email;
+ }
+ else if (payload.canal === "SMS" && payload.phone) {
+ utilisateurId = payload.phone;
+ }
+ return {
+ utilisateurId,
+ typeNotification: payload.typeNotification,
+ canal: payload.canal,
+ context: payload.context,
+ // coordonnées éventuellement poussées par le producteur
+ email: payload.email ?? undefined,
+ phone: payload.phone ?? undefined,
+ };
+}
diff --git a/dist/messaging/publisher.js b/dist/messaging/publisher.js
new file mode 100644
index 00000000..756158ee
--- /dev/null
+++ b/dist/messaging/publisher.js
@@ -0,0 +1,11 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.publishNotification = publishNotification;
+const rabbitmq_1 = require("../config/rabbitmq");
+async function publishNotification(routingKey, message) {
+ const channel = await (0, rabbitmq_1.ensureChannel)();
+ channel.publish(rabbitmq_1.EXCHANGE, routingKey, Buffer.from(JSON.stringify(message)), {
+ persistent: true,
+ });
+ console.log(`Notification publiée sur ${rabbitmq_1.EXCHANGE} avec RK="${routingKey}"`);
+}
diff --git a/dist/routes/health.js b/dist/routes/health.js
new file mode 100644
index 00000000..b970fdcf
--- /dev/null
+++ b/dist/routes/health.js
@@ -0,0 +1,11 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const express_1 = __importDefault(require("express"));
+const router = express_1.default.Router();
+router.get("/health", (req, res) => {
+ res.status(200).json({ status: "OK" });
+});
+exports.default = router;
diff --git a/dist/routes/notificationRoutes.js b/dist/routes/notificationRoutes.js
new file mode 100644
index 00000000..1f44b2c2
--- /dev/null
+++ b/dist/routes/notificationRoutes.js
@@ -0,0 +1,13 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const express_1 = require("express");
+const optController_1 = require("../controllers/optController");
+const notificationController_1 = require("../controllers/notificationController");
+const router = (0, express_1.Router)();
+router.post("/envoyer", notificationController_1.envoyerNotification);
+router.get("/", notificationController_1.getNotifications);
+router.post("/rabbitmq", notificationController_1.testRabbitMQ);
+// OTP
+router.post("/otp/generate", optController_1.generateOtp);
+router.post("/otp/verify", optController_1.verifyOtp);
+exports.default = router;
diff --git a/dist/server.js b/dist/server.js
new file mode 100644
index 00000000..2449c939
--- /dev/null
+++ b/dist/server.js
@@ -0,0 +1,69 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const dotenv_1 = __importDefault(require("dotenv"));
+const express_1 = __importDefault(require("express"));
+require("reflect-metadata");
+const app_1 = __importDefault(require("./app"));
+const rabbitmq_1 = require("./config/rabbitmq");
+const data_source_1 = require("./data-source");
+const externalConsumer_1 = require("./messaging/externalConsumer");
+const health_1 = __importDefault(require("./routes/health"));
+dotenv_1.default.config();
+const PORT = process.env.SERVICE_PORT ? Number(process.env.SERVICE_PORT) : 8000;
+async function initRabbitWithRetry(delayMs = 3000) {
+ let attempt = 1;
+ // Boucle de retry infinie mais espacée : on réessaie tant que RabbitMQ n'est pas prêt.
+ // Cela évite d'abandonner définitivement si le broker démarre après le service.
+ // Dès que la connexion réussit, on démarre les consumers une seule fois.
+ // En cas d'erreur de config (mauvaise URL), les logs permettront de diagnostiquer.
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ try {
+ console.log(`Initialisation RabbitMQ (tentative ${attempt})...`);
+ await (0, rabbitmq_1.ensureChannel)();
+ await (0, externalConsumer_1.startExternalNotificationConsumer)();
+ console.log("RabbitMQ initialisé, consumers démarrés");
+ return;
+ }
+ catch (err) {
+ console.error(`Échec de l'initialisation RabbitMQ (tentative ${attempt}) :`, err);
+ attempt += 1;
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ }
+}
+// Middleware JSON + route de santé configurés immédiatement
+app_1.default.use(express_1.default.json());
+app_1.default.use("/", health_1.default);
+data_source_1.AppDataSource.initialize()
+ .then(async () => {
+ console.log("Connexion à la base PostgreSQL réussie");
+ app_1.default.listen(PORT, () => {
+ console.log(`Serveur démarré sur le port ${PORT}`);
+ });
+ // Initialisation RabbitMQ en arrière-plan avec retry infini
+ void initRabbitWithRetry();
+})
+ .catch((err) => console.error("Erreur de connexion :", err));
+/*
+async function startServer() {
+ console.log("⏳ Initialisation du service de notifications...");
+
+ try {
+ await AppDataSource.initialize();
+ console.log("Connexion PostgreSQL réussie.");
+
+ app.listen(PORT, () => {
+ console.log(`Notification-Service démarré sur le port ${PORT}`);
+ });
+ } catch (error) {
+ console.error("Erreur lors de la connexion PostgreSQL :", error);
+ console.log("Nouvelle tentative dans 5 secondes...");
+ setTimeout(startServer, 5000);
+ }
+}
+
+startServer();*/
diff --git a/dist/services/notificationService.js b/dist/services/notificationService.js
new file mode 100644
index 00000000..8b3e658b
--- /dev/null
+++ b/dist/services/notificationService.js
@@ -0,0 +1,205 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.NotificationService = void 0;
+const dotenv_1 = __importDefault(require("dotenv"));
+const twilio_1 = __importDefault(require("twilio"));
+const data_source_1 = require("../data-source");
+const Notification_1 = require("../entities/Notification");
+const mailService_1 = require("../utils/mailService");
+const messageTemplates_1 = require("../utils/messageTemplates");
+const userContactService_1 = require("./userContactService");
+dotenv_1.default.config();
+const client = (0, twilio_1.default)(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
+class NotificationService {
+ constructor() {
+ this.notifRepo = data_source_1.AppDataSource.getRepository(Notification_1.Notification);
+ }
+ // async envoyerNotification(data: Partial) {
+ // const notif = this.notifRepo.create({ ...data, statut: StatutNotification.EN_COURS });
+ // await this.notifRepo.save(notif);
+ // try {
+ // if (notif.canal === "SMS") {
+ // await client.messages.create({
+ // body: notif.message,
+ // from: process.env.TWILIO_PHONE_NUMBER,
+ // to: data.utilisateurId, // ⚠️ ici, utilisateurId = numéro tel pour simplifier
+ // });
+ // }
+ // notif.statut = StatutNotification.ENVOYEE;
+ // await this.notifRepo.save(notif);
+ // return notif;
+ // } catch (error) {
+ // notif.statut = StatutNotification.ECHEC;
+ // await this.notifRepo.save(notif);
+ // throw new Error("Erreur d'envoi : " + error);
+ // }
+ // }
+ mapStringToTypeNotification(type) {
+ switch (type) {
+ case "transfer":
+ return Notification_1.TypeNotification.CONFIRMATION_TRANSFERT;
+ case "retrait_reussi":
+ case "RETRAIT_REUSSI":
+ return Notification_1.TypeNotification.RETRAIT_REUSSI;
+ case "depot_reussi":
+ case "DEPOT_REUSSI":
+ return Notification_1.TypeNotification.DEPOT_REUSSI;
+ case "alert_securite":
+ case "ALERT_SECURITE":
+ return Notification_1.TypeNotification.ALERT_SECURITE;
+ case "verification_email":
+ case "VERIFICATION_EMAIL":
+ return Notification_1.TypeNotification.VERIFICATION_EMAIL;
+ case "verification_telephone":
+ case "VERIFICATION_TELEPHONE":
+ return Notification_1.TypeNotification.VERIFICATION_TELEPHONE;
+ case "verification_kyc":
+ case "VERIFICATION_KYC":
+ return Notification_1.TypeNotification.VERIFICATION_KYC;
+ default:
+ return Notification_1.TypeNotification.ALERT_SECURITE;
+ }
+ }
+ async sendMultiChannelToContact(contact, content, type, role, extraContext) {
+ const context = { ...(extraContext || {}), role };
+ // SMS
+ const notifSms = this.notifRepo.create({
+ utilisateurId: contact.phone,
+ typeNotification: type,
+ canal: Notification_1.CanalNotification.SMS,
+ context,
+ message: content,
+ destinationPhone: contact.phone,
+ statut: Notification_1.StatutNotification.EN_COURS,
+ });
+ await this.notifRepo.save(notifSms);
+ try {
+ await client.messages.create({
+ body: content,
+ from: process.env.TWILIO_PHONE_NUMBER,
+ to: contact.phone,
+ });
+ notifSms.statut = Notification_1.StatutNotification.ENVOYEE;
+ }
+ catch (error) {
+ notifSms.statut = Notification_1.StatutNotification.ECHEC;
+ console.error("Erreur d'envoi SMS :", error);
+ }
+ await this.notifRepo.save(notifSms);
+ // EMAIL
+ const notifEmail = this.notifRepo.create({
+ utilisateurId: contact.email,
+ typeNotification: type,
+ canal: Notification_1.CanalNotification.EMAIL,
+ context,
+ message: content,
+ destinationEmail: contact.email,
+ statut: Notification_1.StatutNotification.EN_COURS,
+ });
+ await this.notifRepo.save(notifEmail);
+ try {
+ await (0, mailService_1.sendEmail)(contact.email, "Notification", content);
+ notifEmail.statut = Notification_1.StatutNotification.ENVOYEE;
+ }
+ catch (error) {
+ notifEmail.statut = Notification_1.StatutNotification.ECHEC;
+ console.error("Erreur d'envoi email :", error);
+ }
+ await this.notifRepo.save(notifEmail);
+ return {
+ sms: notifSms,
+ email: notifEmail,
+ };
+ }
+ /**
+ * Endpoint HTTP (Postman) :
+ * - dépend UNIQUEMENT des coordonnées fournies dans le JSON
+ * - envoie systématiquement sur email ET SMS quand fournis
+ * - gère le cas spécifique type = "transfer" (sender / receiver)
+ */
+ async envoyerNotificationFromHttp(payload) {
+ if (payload.type === "transfer") {
+ const transferPayload = payload;
+ const type = this.mapStringToTypeNotification(payload.type);
+ const senderResult = await this.sendMultiChannelToContact(transferPayload.sender, transferPayload.content, type, "SENDER", { montant: transferPayload.amount });
+ const receiverResult = await this.sendMultiChannelToContact(transferPayload.receiver, transferPayload.content, type, "RECEIVER", { montant: transferPayload.amount });
+ return {
+ sender: senderResult,
+ receiver: receiverResult,
+ };
+ }
+ const simplePayload = payload;
+ const type = this.mapStringToTypeNotification(simplePayload.type);
+ const userResult = await this.sendMultiChannelToContact(simplePayload.user, simplePayload.content, type, "USER");
+ return {
+ user: userResult,
+ };
+ }
+ async envoyerNotification(data) {
+ // Génération automatique du message personnalisé
+ const message = (0, messageTemplates_1.generateMessage)(data.typeNotification, data.context || {});
+ // 1. On part des coordonnées explicitement fournies dans la requête / l'événement
+ let destinationEmail = data.email ?? undefined;
+ let destinationPhone = data.phone ?? undefined;
+ // 2. Si au moins une coordonnée manque, on essaie de la compléter via le service de contact
+ if (!destinationEmail || !destinationPhone) {
+ const contact = await userContactService_1.userContactService.getContact(data.utilisateurId);
+ if (!destinationEmail && contact.email) {
+ destinationEmail = contact.email;
+ }
+ if (!destinationPhone && contact.phone) {
+ destinationPhone = contact.phone;
+ }
+ }
+ // 3. Validation générale : au moins un des deux doit être présent
+ if (!destinationEmail && !destinationPhone) {
+ throw new Error(`Aucun contact (email ou téléphone) disponible pour l'utilisateur ${data.utilisateurId}`);
+ }
+ // 4. Validation spécifique au canal demandé
+ if (data.canal === Notification_1.CanalNotification.EMAIL && !destinationEmail) {
+ throw new Error(`Canal EMAIL demandé mais aucune adresse email valide pour l'utilisateur ${data.utilisateurId}`);
+ }
+ if (data.canal === Notification_1.CanalNotification.SMS && !destinationPhone) {
+ throw new Error(`Canal SMS demandé mais aucun numéro de téléphone valide pour l'utilisateur ${data.utilisateurId}`);
+ }
+ const notif = this.notifRepo.create({
+ utilisateurId: data.utilisateurId,
+ typeNotification: data.typeNotification,
+ canal: data.canal,
+ context: data.context,
+ message,
+ destinationEmail,
+ destinationPhone,
+ statut: Notification_1.StatutNotification.EN_COURS,
+ });
+ await this.notifRepo.save(notif);
+ try {
+ if (notif.canal === Notification_1.CanalNotification.SMS && destinationPhone) {
+ await client.messages.create({
+ body: message,
+ from: process.env.TWILIO_PHONE_NUMBER,
+ to: destinationPhone,
+ });
+ }
+ if (notif.canal === Notification_1.CanalNotification.EMAIL && destinationEmail) {
+ await (0, mailService_1.sendEmail)(destinationEmail, "RICASH NOTIFICATION", message);
+ }
+ notif.statut = Notification_1.StatutNotification.ENVOYEE;
+ await this.notifRepo.save(notif);
+ return notif;
+ }
+ catch (error) {
+ notif.statut = Notification_1.StatutNotification.ECHEC;
+ await this.notifRepo.save(notif);
+ console.error("Erreur d'envoi :", error);
+ throw new Error("Erreur d'envoi : " + error);
+ }
+ }
+ async getAll() {
+ return this.notifRepo.find();
+ }
+}
+exports.NotificationService = NotificationService;
diff --git a/dist/services/otpService.js b/dist/services/otpService.js
new file mode 100644
index 00000000..bcaa5645
--- /dev/null
+++ b/dist/services/otpService.js
@@ -0,0 +1,77 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.OtpService = void 0;
+const data_source_1 = require("../data-source");
+const Notification_1 = require("../entities/Notification");
+const Otp_1 = require("../entities/Otp");
+const publisher_1 = require("../messaging/publisher");
+class OtpService {
+ constructor() {
+ this.otpRepo = data_source_1.AppDataSource.getRepository(Otp_1.Otp);
+ this.expirationDelay = 5 * 60 * 1000; // 5 minutes
+ }
+ generateCode() {
+ return Math.floor(1000 + Math.random() * 9000).toString(); // 4chiffres
+ }
+ async createOtp(utilisateurId, canalNotification, email, phone) {
+ const code = this.generateCode();
+ const expiration = new Date(Date.now() + this.expirationDelay);
+ const destinationEmail = email;
+ const destinationPhone = phone;
+ const otp = this.otpRepo.create({
+ utilisateurId, // identifiant métier
+ canal: canalNotification,
+ code,
+ expiration,
+ destinationEmail,
+ destinationPhone,
+ });
+ await this.otpRepo.save(otp);
+ // Détermination automatique du type de notification
+ const notifType = canalNotification === "EMAIL"
+ ? Notification_1.TypeNotification.VERIFICATION_EMAIL
+ : Notification_1.TypeNotification.VERIFICATION_TELEPHONE;
+ // message standard inter-services (aligné sur InterServices / NotificationEvent)
+ const message = {
+ utilisateurId,
+ typeNotification: notifType,
+ canal: canalNotification,
+ context: { code },
+ email: destinationEmail,
+ phone: destinationPhone,
+ metadata: {
+ service: "notification-service:otp",
+ correlationId: `otp-${otp.id}`,
+ },
+ };
+ // Publication d'un événement OTP sur l'exchange partagé (ex: ricash.events)
+ // Routing key dédiée : otp.verification (captée via le binding "otp.*")
+ await (0, publisher_1.publishNotification)("otp.verification", message);
+ return { success: true, message: "OTP envoyé", expiration };
+ }
+ async verifyOtp(utilisateurId, code) {
+ const otp = await this.otpRepo.findOne({
+ where: { utilisateurId, code },
+ });
+ if (!otp) {
+ return { success: false, message: "Code invalide " };
+ }
+ if (otp.utilise) {
+ return { success: false, message: "Ce code a déjà été utilisé " };
+ }
+ if (otp.expiration < new Date()) {
+ return { success: false, message: "Code expiré " };
+ }
+ otp.utilise = true;
+ await this.otpRepo.save(otp);
+ return { success: true, message: "OTP validé " };
+ }
+ async cleanExpiredOtps() {
+ const now = new Date();
+ await this.otpRepo
+ .createQueryBuilder()
+ .delete()
+ .where("expiration < :now", { now }).execute;
+ }
+}
+exports.OtpService = OtpService;
diff --git a/dist/services/userContactService.js b/dist/services/userContactService.js
new file mode 100644
index 00000000..78735f04
--- /dev/null
+++ b/dist/services/userContactService.js
@@ -0,0 +1,32 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.userContactService = exports.UserContactService = void 0;
+/**
+ * Service responsable de récupérer les coordonnées de contact
+ * (email / téléphone) à partir d'un identifiant métier utilisateur.
+ *
+ * Implémentation actuelle : simple map en mémoire pour les tests.
+ * Elle pourra être remplacée plus tard par :
+ * - un appel HTTP vers un service utilisateur,
+ * - une lecture dans une table `Utilisateur`, etc.
+ */
+class UserContactService {
+ constructor() {
+ this.contacts = new Map([
+ // Exemple de données de test ; à adapter ou supprimer en prod
+ ["user-test-email", { email: "managerdayif@gmail.com" }],
+ ["user-test-sms", { phone: "+22379994640" }],
+ [
+ "user-test-both",
+ { email: "managerdayif@gmail.com", phone: "+22379994640" },
+ ],
+ ]);
+ }
+ async getContact(utilisateurId) {
+ const contact = this.contacts.get(utilisateurId);
+ return contact ?? {};
+ }
+}
+exports.UserContactService = UserContactService;
+// Instance par défaut réutilisable dans tout le service
+exports.userContactService = new UserContactService();
diff --git a/dist/utils/mailService.js b/dist/utils/mailService.js
new file mode 100644
index 00000000..34aa58eb
--- /dev/null
+++ b/dist/utils/mailService.js
@@ -0,0 +1,32 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.mailTransporter = void 0;
+exports.sendEmail = sendEmail;
+const nodemailer_1 = __importDefault(require("nodemailer"));
+const dotenv_1 = __importDefault(require("dotenv"));
+dotenv_1.default.config();
+exports.mailTransporter = nodemailer_1.default.createTransport({
+ service: "gmail", // tu peux aussi utiliser mailtrap, outlook, etc.
+ auth: {
+ user: process.env.MAIL_USER,
+ pass: process.env.MAIL_PASS,
+ },
+});
+async function sendEmail(to, subject, text) {
+ try {
+ await exports.mailTransporter.sendMail({
+ from: `"RICASH" <${process.env.MAIL_USER}>`,
+ to,
+ subject,
+ text,
+ });
+ console.log(`Email envoyé à ${to}`);
+ }
+ catch (error) {
+ console.error("Erreur envoi e-mail :", error);
+ throw new Error("Erreur lors de l'envoi de l'email");
+ }
+}
diff --git a/dist/utils/messageTemplates.js b/dist/utils/messageTemplates.js
new file mode 100644
index 00000000..01580c7c
--- /dev/null
+++ b/dist/utils/messageTemplates.js
@@ -0,0 +1,33 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.generateMessage = void 0;
+const Notification_1 = require("../entities/Notification");
+const generateMessage = (type, context) => {
+ switch (type) {
+ case Notification_1.TypeNotification.CONFIRMATION_TRANSFERT:
+ // Pour les transferts, on distingue l'expéditeur (direction="debit") et le destinataire (direction="credit").
+ if (context?.direction === "credit") {
+ // Message pour le bénéficiaire qui reçoit un transfert
+ return `Vous avez reçu un transfert de ${context.destinataire} de ${context.montant} ${context.currency ?? "FCFA"}. Nouveau solde: ${context.balance ?? context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
+ }
+ // Par défaut (et pour direction="debit"), message pour l'expéditeur
+ return `Votre transfert de ${context.montant} ${context.currency ?? "FCFA"} vers ${context.destinataire} a été confirmé. Nouveau solde: ${context.balance ?? context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
+ case Notification_1.TypeNotification.CONFIRMATION_RETRAIT:
+ return `Votre demande de retrait de ${context.montant} ${context.currency ?? "FCFA"} est en cours de traitement. Référence: ${context.transactionId}.`;
+ case Notification_1.TypeNotification.RETRAIT_REUSSI:
+ return `Votre retrait de ${context.montant} ${context.currency ?? "FCFA"} a été effectué avec succès. Nouveau solde: ${context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
+ case Notification_1.TypeNotification.DEPOT_REUSSI:
+ return `Vous avez reçu un dépôt de ${context.montant} ${context.currency ?? "FCFA"} sur votre compte. Nouveau solde: ${context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
+ case Notification_1.TypeNotification.ALERT_SECURITE:
+ return `Alerte sécurité : connexion suspecte depuis un nouvel appareil.`;
+ case Notification_1.TypeNotification.VERIFICATION_KYC:
+ return `Votre vérification d’identité (KYC) est ${context.status === "valide" ? "validée " : "en attente "}.`;
+ case Notification_1.TypeNotification.VERIFICATION_EMAIL:
+ return `Votre code de vérification email est : ${context.code}. Ce code est valable 5 minutes. Ne le partagez jamais avec un tiers.`;
+ case Notification_1.TypeNotification.VERIFICATION_TELEPHONE:
+ return `Votre code OTP de vérification téléphone est : ${context.code}. Ce code est valable 5 minutes. Ne le partagez jamais avec un tiers.`;
+ default:
+ return `Notification générique`;
+ }
+};
+exports.generateMessage = generateMessage;
diff --git a/package-lock.json b/package-lock.json
index 682e5142..7c9af6f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,14 +10,15 @@
"license": "ISC",
"dependencies": {
"amqplib": "^0.10.9",
- "body-parser": "^2.2.0",
+ "body-parser": "^2.2.1",
"dotenv": "^17.2.3",
"express": "^5.1.0",
- "nodemailer": "^7.0.10",
+ "nodemailer": "^6.10.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"twilio": "^5.10.4",
- "typeorm": "^0.3.27"
+ "typeorm": "^0.3.27",
+ "zod": "^3.23.8"
},
"devDependencies": {
"@types/amqplib": "^0.10.8",
@@ -1571,7 +1572,6 @@
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1847,23 +1847,27 @@
}
},
"node_modules/body-parser": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
- "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
- "debug": "^4.4.0",
+ "debug": "^4.4.3",
"http-errors": "^2.0.0",
- "iconv-lite": "^0.6.3",
+ "iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
- "qs": "^6.14.0",
- "raw-body": "^3.0.0",
- "type-is": "^2.0.0"
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/bowser": {
@@ -2871,15 +2875,19 @@
}
},
"node_modules/iconv-lite": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
@@ -3277,9 +3285,9 @@
}
},
"node_modules/nodemailer": {
- "version": "7.0.10",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
- "integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+ "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -3429,7 +3437,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -3602,9 +3609,9 @@
"license": "MIT"
},
"node_modules/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -3679,8 +3686,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
- "license": "Apache-2.0",
- "peer": true
+ "license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
@@ -4284,7 +4290,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -4543,7 +4548,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4851,6 +4855,15 @@
"engines": {
"node": ">=6"
}
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index f507983f..dc5701b6 100644
--- a/package.json
+++ b/package.json
@@ -14,12 +14,13 @@
"type": "commonjs",
"dependencies": {
"amqplib": "^0.10.9",
- "body-parser": "^2.2.0",
+ "body-parser": "^2.2.1",
"dotenv": "^17.2.3",
"express": "^5.1.0",
- "nodemailer": "^7.0.10",
+ "nodemailer": "^6.10.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
+ "zod": "^3.23.8",
"twilio": "^5.10.4",
"typeorm": "^0.3.27"
},
diff --git a/src/config/rabbitmq.ts b/src/config/rabbitmq.ts
index d152ea78..6dbd0ae2 100644
--- a/src/config/rabbitmq.ts
+++ b/src/config/rabbitmq.ts
@@ -1,22 +1,21 @@
+import type { Channel } from "amqplib";
import * as amqp from "amqplib";
-import type { Connection, Channel } from "amqplib";
-
//let connection: amqp.Connection | null = null;
-let channel: Channel | null= null;
+let channel: Channel | null = null;
/** Variables standardisées */
export const EXCHANGE = process.env.RABBITMQ_EXCHANGE!;
export const QUEUE = process.env.RABBITMQ_QUEUE!;
/** Routing keys internes */
-export const RK_MAIN = "notification.process";
+export const RK_MAIN = "notification.process";
export const RK_RETRY = "notification.retry";
-export const RK_DLQ = "notification.dlq";
+export const RK_DLQ = "notification.dlq";
/** Queues dérivées (privées au service) */
export const QUEUE_RETRY = `${QUEUE}.retry`;
-export const QUEUE_DLQ = `${QUEUE}.dlq`;
+export const QUEUE_DLQ = `${QUEUE}.dlq`;
export async function ensureChannel(): Promise {
if (channel) return channel;
@@ -30,7 +29,7 @@ export async function ensureChannel(): Promise {
conn.on("close", () => {
console.error("RabbitMQ fermé – reconnexion...");
// channel = null;
- //connection = null;
+ //connection = null;
setTimeout(ensureChannel, 3000);
});
@@ -41,11 +40,20 @@ export async function ensureChannel(): Promise {
channel = await conn.createChannel();
const ch = channel!;
- // Exchange partagé
+ // Exchange partagé (doit être le même que celui utilisé par wallet-service, ex: "ricash.events")
await ch.assertExchange(EXCHANGE, "topic", { durable: true });
// Queue principale
await ch.assertQueue(QUEUE, { durable: true });
+
+ // événements venant du wallet-service
+ await ch.bindQueue(QUEUE, EXCHANGE, "wallet.*");
+ await ch.bindQueue(QUEUE, EXCHANGE, "wallet.transfer.*");
+
+ // événements OTP (ex: "otp.verification")
+ await ch.bindQueue(QUEUE, EXCHANGE, "otp.*");
+
+ // routing key interne historique du service de notifications
await ch.bindQueue(QUEUE, EXCHANGE, RK_MAIN);
// Queue retry
diff --git a/src/controllers/notificationController.ts b/src/controllers/notificationController.ts
index 2d4d7e32..7154609b 100644
--- a/src/controllers/notificationController.ts
+++ b/src/controllers/notificationController.ts
@@ -1,12 +1,52 @@
import { Request, Response } from "express";
-import { NotificationService } from "../services/notificationService";
+import { z } from "zod";
import { publishNotification } from "../messaging/publisher";
+import { NotificationService } from "../services/notificationService";
const service = new NotificationService();
+const ContactSchema = z.object({
+ email: z.string().email(),
+ phone: z.string().min(8),
+});
+
+const TransferNotificationSchema = z.object({
+ type: z.literal("transfer"),
+ sender: ContactSchema,
+ receiver: ContactSchema,
+ amount: z.number().positive(),
+ content: z.string().min(1),
+});
+
+const SimpleNotificationSchema = z.object({
+ type: z
+ .string()
+ .min(1)
+ .refine((value) => value !== "transfer", {
+ message: 'Utiliser le schéma "transfer" lorsque type = "transfer".',
+ }),
+ user: ContactSchema,
+ content: z.string().min(1),
+});
+
+const NotificationBodySchema = z.union([
+ TransferNotificationSchema,
+ SimpleNotificationSchema,
+]);
+
export const envoyerNotification = async (req: Request, res: Response) => {
try {
- const notif = await service.envoyerNotification(req.body);
+ const parsed = NotificationBodySchema.safeParse(req.body);
+
+ if (!parsed.success) {
+ return res.status(400).json({
+ success: false,
+ message: "Corps de requête invalide",
+ errors: parsed.error.flatten(),
+ });
+ }
+
+ const notif = await service.envoyerNotificationFromHttp(parsed.data);
res.status(201).json({ success: true, data: notif });
} catch (error: any) {
res.status(500).json({ success: false, message: error.message });
@@ -18,10 +58,13 @@ export const getNotifications = async (req: Request, res: Response) => {
res.json(list);
};
-
export async function testRabbitMQ(req: Request, res: Response) {
- const { queueName, message } = req.body;
- await publishNotification(queueName);
+ const { routingKey, message } = req.body;
+
+ await publishNotification(
+ routingKey || "notification.process",
+ message ?? { test: true },
+ );
+
res.json({ success: true });
}
-
diff --git a/src/controllers/optController.ts b/src/controllers/optController.ts
index 942ced6b..b7dbf017 100644
--- a/src/controllers/optController.ts
+++ b/src/controllers/optController.ts
@@ -1,14 +1,44 @@
import { Request, Response } from "express";
+import { z } from "zod";
+import { CanalNotification } from "../entities/Notification";
import { OtpService } from "../services/otpService";
const otpService = new OtpService();
+const GenerateOtpSchema = z.object({
+ utilisateurId: z.string().min(1),
+ canalNotification: z.enum(["SMS", "EMAIL"]),
+ email: z.string().email(),
+ phone: z.string().min(8),
+});
+
export const generateOtp = async (req: Request, res: Response) => {
try {
- const { utilisateurId, canalNotification } = req.body;
- const result = await otpService.createOtp(utilisateurId, canalNotification);
+ const parsed = GenerateOtpSchema.safeParse(req.body);
+
+ if (!parsed.success) {
+ return res.status(400).json({
+ success: false,
+ message: "Corps de requête invalide",
+ errors: parsed.error.flatten(),
+ });
+ }
+
+ const { utilisateurId, canalNotification, email, phone } = parsed.data;
+
+ const canalEnum =
+ canalNotification === "SMS"
+ ? CanalNotification.SMS
+ : CanalNotification.EMAIL;
+
+ const result = await otpService.createOtp(
+ utilisateurId,
+ canalEnum,
+ email,
+ phone,
+ );
res.json(result);
- } catch (error : any) {
+ } catch (error: any) {
res.status(500).json({ success: false, message: error.message });
}
};
@@ -18,7 +48,7 @@ export const verifyOtp = async (req: Request, res: Response) => {
const { utilisateurId, code } = req.body;
const result = await otpService.verifyOtp(utilisateurId, code);
res.json(result);
- } catch (error : any) {
+ } catch (error: any) {
res.status(500).json({ success: false, message: error.message });
}
};
diff --git a/src/entities/Notification.ts b/src/entities/Notification.ts
index c4d94157..12c14fc4 100644
--- a/src/entities/Notification.ts
+++ b/src/entities/Notification.ts
@@ -1,4 +1,9 @@
-import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ PrimaryGeneratedColumn,
+} from "typeorm";
export enum TypeNotification {
CONFIRMATION_TRANSFERT = "CONFIRMATION_TRANSFERT",
@@ -34,6 +39,12 @@ export class Notification {
@Column()
utilisateurId!: string;
+ @Column({ nullable: true })
+ destinationEmail?: string;
+
+ @Column({ nullable: true })
+ destinationPhone?: string;
+
@Column({ type: "enum", enum: TypeNotification })
typeNotification!: TypeNotification;
diff --git a/src/entities/Otp.ts b/src/entities/Otp.ts
index 3ac47b22..cf4945aa 100644
--- a/src/entities/Otp.ts
+++ b/src/entities/Otp.ts
@@ -1,5 +1,10 @@
-import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";
-import { CanalNotification, TypeNotification } from "./Notification";
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ PrimaryGeneratedColumn,
+} from "typeorm";
+import { CanalNotification } from "./Notification";
@Entity()
export class Otp {
@@ -10,11 +15,17 @@ export class Otp {
utilisateurId!: string; // destinaire
// email ou numéro de téléphone
+ @Column({ nullable: true })
+ destinationEmail?: string;
+
+ @Column({ nullable: true })
+ destinationPhone?: string;
+
@Column()
code!: string;
-// @Column()
-// type: TypeNotification;
+ // @Column()
+ // type: TypeNotification;
@Column()
canal!: CanalNotification; // EMAIL ou TELEPHONE
diff --git a/src/messaging/contracts/interServices.ts b/src/messaging/contracts/interServices.ts
index c603516c..dc6ee1f7 100644
--- a/src/messaging/contracts/interServices.ts
+++ b/src/messaging/contracts/interServices.ts
@@ -1,4 +1,3 @@
-
export interface InterServices {
utilisateurId: string;
typeNotification:
@@ -12,6 +11,13 @@ export interface InterServices {
| "VERIFICATION_KYC";
canal: "SMS" | "EMAIL" | "PUSH";
+ /**
+ * Coordonnées facultatives transmises par le producteur de l'événement.
+ * L'un des deux doit être renseigné (ou récupéré côté service de notif),
+ * mais ils ne doivent pas être tous les deux absents au final.
+ */
+ email?: string | null;
+ phone?: string | null;
context?: any;
diff --git a/src/messaging/externalConsumer.ts b/src/messaging/externalConsumer.ts
index a455fac6..243d4034 100644
--- a/src/messaging/externalConsumer.ts
+++ b/src/messaging/externalConsumer.ts
@@ -14,11 +14,22 @@ export async function startExternalNotificationConsumer() {
const payload: InterServices = JSON.parse(msg.content.toString());
try {
+ console.log(
+ "[ExternalConsumer] Message reçu sur",
+ QUEUE,
+ "payload:",
+ payload,
+ );
const service = new NotificationService();
const notification = mapInterServiceToNotification(payload);
await service.envoyerNotification(notification);
+ console.log(
+ "[ExternalConsumer] Notification traitée pour utilisateurId=",
+ notification.utilisateurId,
+ );
+
channel.ack(msg);
} catch (error) {
console.error("Erreur consumer externe", error);
diff --git a/src/messaging/mappers/notification.mapper.ts b/src/messaging/mappers/notification.mapper.ts
index 453e26de..45d56039 100644
--- a/src/messaging/mappers/notification.mapper.ts
+++ b/src/messaging/mappers/notification.mapper.ts
@@ -1,14 +1,29 @@
+import {
+ CanalNotification,
+ TypeNotification,
+} from "../../entities/Notification";
import { InterServices } from "../contracts/interServices";
-import { CanalNotification,TypeNotification } from "../../entities/Notification";
+export function mapInterServiceToNotification(payload: InterServices) {
+ // On choisit la "vraie" cible en fonction du canal :
+ // - EMAIL -> on privilégie payload.email si présent
+ // - SMS -> on privilégie payload.phone si présent
+ // - autres -> on retombe sur payload.utilisateurId
-export function mapInterServiceToNotification(
- payload: InterServices
-) {
+ let utilisateurId = payload.utilisateurId;
+
+ if (payload.canal === "EMAIL" && payload.email) {
+ utilisateurId = payload.email;
+ } else if (payload.canal === "SMS" && payload.phone) {
+ utilisateurId = payload.phone;
+ }
return {
- utilisateurId: payload.utilisateurId,
+ utilisateurId,
typeNotification: payload.typeNotification as TypeNotification,
canal: payload.canal as CanalNotification,
context: payload.context,
+ // coordonnées éventuellement poussées par le producteur
+ email: payload.email ?? undefined,
+ phone: payload.phone ?? undefined,
};
}
diff --git a/src/messaging/publisher.ts b/src/messaging/publisher.ts
index 78c03266..747afea9 100644
--- a/src/messaging/publisher.ts
+++ b/src/messaging/publisher.ts
@@ -1,17 +1,11 @@
-import { ensureChannel, EXCHANGE, RK_MAIN } from "../config/rabbitmq";
+import { ensureChannel, EXCHANGE } from "../config/rabbitmq";
-export async function publishNotification(message: any) {
+export async function publishNotification(routingKey: string, message: any) {
const channel = await ensureChannel();
- channel.publish(
- EXCHANGE,
- RK_MAIN,
- Buffer.from(JSON.stringify(message)),
- { persistent: true }
- );
+ channel.publish(EXCHANGE, routingKey, Buffer.from(JSON.stringify(message)), {
+ persistent: true,
+ });
- console.log("Notification publiée via exchange");
+ console.log(`Notification publiée sur ${EXCHANGE} avec RK="${routingKey}"`);
}
-
-
-
diff --git a/src/routes/health.ts b/src/routes/health.ts
index 91f8f141..6372f61a 100644
--- a/src/routes/health.ts
+++ b/src/routes/health.ts
@@ -1,13 +1,12 @@
-const express = require("express");
-import type { Request, Response } from "express";
+import express, { type Request, type Response } from "express";
const router = express.Router();
interface HealthResponse {
- status: string;
+ status: string;
}
router.get("/health", (req: Request, res: Response) => {
- res.status(200).json({ status: "OK" });
+ res.status(200).json({ status: "OK" });
});
-module.exports = router;
+export default router;
diff --git a/src/server.ts b/src/server.ts
index 1e250304..c3e34b2d 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,33 +1,60 @@
+import dotenv from "dotenv";
+import express from "express";
import "reflect-metadata";
-import { AppDataSource } from "./data-source";
import app from "./app";
-import dotenv from "dotenv";
-import { startConsumer } from "./messaging/consumer";
import { ensureChannel } from "./config/rabbitmq";
+import { AppDataSource } from "./data-source";
import { startExternalNotificationConsumer } from "./messaging/externalConsumer";
-
-const express = require("express");
-const healthRoute = require("../routes/health");
-
+import healthRoute from "./routes/health";
dotenv.config();
const PORT = process.env.SERVICE_PORT ? Number(process.env.SERVICE_PORT) : 8000;
+async function initRabbitWithRetry(delayMs = 3000): Promise {
+ let attempt = 1;
+
+ // Boucle de retry infinie mais espacée : on réessaie tant que RabbitMQ n'est pas prêt.
+ // Cela évite d'abandonner définitivement si le broker démarre après le service.
+ // Dès que la connexion réussit, on démarre les consumers une seule fois.
+ // En cas d'erreur de config (mauvaise URL), les logs permettront de diagnostiquer.
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ try {
+ console.log(`Initialisation RabbitMQ (tentative ${attempt})...`);
+
+ await ensureChannel();
+ await startExternalNotificationConsumer();
+ console.log("RabbitMQ initialisé, consumers démarrés");
+ return;
+ } catch (err) {
+ console.error(
+ `Échec de l'initialisation RabbitMQ (tentative ${attempt}) :`,
+ err,
+ );
+
+ attempt += 1;
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ }
+}
+
+// Middleware JSON + route de santé configurés immédiatement
+app.use(express.json());
+app.use("/", healthRoute);
AppDataSource.initialize()
.then(async () => {
- console.log(" Connexion à la base PostgreSQL réussie");
- await ensureChannel();
- await startConsumer();
- app.listen(PORT, () => console.log(` Serveur démarré sur le port ${PORT}`));
- //await startExternalNotificationConsumer();
- await startExternalNotificationConsumer();
+ console.log("Connexion à la base PostgreSQL réussie");
+ app.listen(PORT, () => {
+ console.log(`Serveur démarré sur le port ${PORT}`);
+ });
+
+ // Initialisation RabbitMQ en arrière-plan avec retry infini
+ void initRabbitWithRetry();
})
.catch((err) => console.error("Erreur de connexion :", err));
- app.use(express.json());
-app.use("/", healthRoute);
/*
async function startServer() {
console.log("⏳ Initialisation du service de notifications...");
@@ -47,4 +74,3 @@ async function startServer() {
}
startServer();*/
-
diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts
index e3694e4c..3fb11b04 100644
--- a/src/services/notificationService.ts
+++ b/src/services/notificationService.ts
@@ -1,13 +1,45 @@
-import { AppDataSource } from "../data-source";
-import { CanalNotification, Notification, StatutNotification, TypeNotification } from "../entities/Notification";
-import twilio from "twilio";
import dotenv from "dotenv";
-import { generateMessage } from "../utils/messageTemplates";
+import twilio from "twilio";
+import { AppDataSource } from "../data-source";
+import {
+ CanalNotification,
+ Notification,
+ StatutNotification,
+ TypeNotification,
+} from "../entities/Notification";
import { sendEmail } from "../utils/mailService";
+import { generateMessage } from "../utils/messageTemplates";
+import { userContactService } from "./userContactService";
dotenv.config();
-const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
+const client = twilio(
+ process.env.TWILIO_ACCOUNT_SID,
+ process.env.TWILIO_AUTH_TOKEN,
+);
+
+export interface ContactInfoDTO {
+ email: string;
+ phone: string;
+}
+
+export interface TransferNotificationDTO {
+ type: "transfer";
+ sender: ContactInfoDTO;
+ receiver: ContactInfoDTO;
+ amount: number;
+ content: string;
+}
+
+export interface SimpleNotificationDTO {
+ type: string;
+ user: ContactInfoDTO;
+ content: string;
+}
+
+export type HttpNotificationDTO =
+ | TransferNotificationDTO
+ | SimpleNotificationDTO;
export class NotificationService {
private notifRepo = AppDataSource.getRepository(Notification);
@@ -36,36 +68,217 @@ export class NotificationService {
// }
// }
+ private mapStringToTypeNotification(type: string): TypeNotification {
+ switch (type) {
+ case "transfer":
+ return TypeNotification.CONFIRMATION_TRANSFERT;
+ case "retrait_reussi":
+ case "RETRAIT_REUSSI":
+ return TypeNotification.RETRAIT_REUSSI;
+ case "depot_reussi":
+ case "DEPOT_REUSSI":
+ return TypeNotification.DEPOT_REUSSI;
+ case "alert_securite":
+ case "ALERT_SECURITE":
+ return TypeNotification.ALERT_SECURITE;
+ case "verification_email":
+ case "VERIFICATION_EMAIL":
+ return TypeNotification.VERIFICATION_EMAIL;
+ case "verification_telephone":
+ case "VERIFICATION_TELEPHONE":
+ return TypeNotification.VERIFICATION_TELEPHONE;
+ case "verification_kyc":
+ case "VERIFICATION_KYC":
+ return TypeNotification.VERIFICATION_KYC;
+ default:
+ return TypeNotification.ALERT_SECURITE;
+ }
+ }
+
+ private async sendMultiChannelToContact(
+ contact: ContactInfoDTO,
+ content: string,
+ type: TypeNotification,
+ role: string,
+ extraContext?: Record,
+ ) {
+ const context = { ...(extraContext || {}), role };
+
+ // SMS
+ const notifSms = this.notifRepo.create({
+ utilisateurId: contact.phone,
+ typeNotification: type,
+ canal: CanalNotification.SMS,
+ context,
+ message: content,
+ destinationPhone: contact.phone,
+ statut: StatutNotification.EN_COURS,
+ });
+
+ await this.notifRepo.save(notifSms);
+
+ try {
+ await client.messages.create({
+ body: content,
+ from: process.env.TWILIO_PHONE_NUMBER,
+ to: contact.phone,
+ });
+ notifSms.statut = StatutNotification.ENVOYEE;
+ } catch (error) {
+ notifSms.statut = StatutNotification.ECHEC;
+ console.error("Erreur d'envoi SMS :", error);
+ }
+
+ await this.notifRepo.save(notifSms);
+
+ // EMAIL
+ const notifEmail = this.notifRepo.create({
+ utilisateurId: contact.email,
+ typeNotification: type,
+ canal: CanalNotification.EMAIL,
+ context,
+ message: content,
+ destinationEmail: contact.email,
+ statut: StatutNotification.EN_COURS,
+ });
+
+ await this.notifRepo.save(notifEmail);
+
+ try {
+ await sendEmail(contact.email, "Notification", content);
+ notifEmail.statut = StatutNotification.ENVOYEE;
+ } catch (error) {
+ notifEmail.statut = StatutNotification.ECHEC;
+ console.error("Erreur d'envoi email :", error);
+ }
+
+ await this.notifRepo.save(notifEmail);
+
+ return {
+ sms: notifSms,
+ email: notifEmail,
+ };
+ }
+
+ /**
+ * Endpoint HTTP (Postman) :
+ * - dépend UNIQUEMENT des coordonnées fournies dans le JSON
+ * - envoie systématiquement sur email ET SMS quand fournis
+ * - gère le cas spécifique type = "transfer" (sender / receiver)
+ */
+ async envoyerNotificationFromHttp(payload: HttpNotificationDTO) {
+ if (payload.type === "transfer") {
+ const transferPayload = payload as TransferNotificationDTO;
+ const type = this.mapStringToTypeNotification(payload.type);
+
+ const senderResult = await this.sendMultiChannelToContact(
+ transferPayload.sender,
+ transferPayload.content,
+ type,
+ "SENDER",
+ { montant: transferPayload.amount },
+ );
+ const receiverResult = await this.sendMultiChannelToContact(
+ transferPayload.receiver,
+ transferPayload.content,
+ type,
+ "RECEIVER",
+ { montant: transferPayload.amount },
+ );
+
+ return {
+ sender: senderResult,
+ receiver: receiverResult,
+ };
+ }
+
+ const simplePayload = payload as SimpleNotificationDTO;
+ const type = this.mapStringToTypeNotification(simplePayload.type);
+
+ const userResult = await this.sendMultiChannelToContact(
+ simplePayload.user,
+ simplePayload.content,
+ type,
+ "USER",
+ );
- async envoyerNotification(data: {
- utilisateurId: string;
+ return {
+ user: userResult,
+ };
+ }
+
+ async envoyerNotification(data: {
+ utilisateurId: string; // identifiant métier (ex: user-123)
typeNotification: TypeNotification;
canal: CanalNotification;
context?: any;
+ /** Coordonnées facultatives fournies directement par l'appelant */
+ email?: string | null;
+ phone?: string | null;
}) {
- // ✅ Génération automatique du message personnalisé
+ // Génération automatique du message personnalisé
const message = generateMessage(data.typeNotification, data.context || {});
+ // 1. On part des coordonnées explicitement fournies dans la requête / l'événement
+ let destinationEmail: string | undefined = data.email ?? undefined;
+ let destinationPhone: string | undefined = data.phone ?? undefined;
+
+ // 2. Si au moins une coordonnée manque, on essaie de la compléter via le service de contact
+ if (!destinationEmail || !destinationPhone) {
+ const contact = await userContactService.getContact(data.utilisateurId);
+
+ if (!destinationEmail && contact.email) {
+ destinationEmail = contact.email;
+ }
+ if (!destinationPhone && contact.phone) {
+ destinationPhone = contact.phone;
+ }
+ }
+
+ // 3. Validation générale : au moins un des deux doit être présent
+ if (!destinationEmail && !destinationPhone) {
+ throw new Error(
+ `Aucun contact (email ou téléphone) disponible pour l'utilisateur ${data.utilisateurId}`,
+ );
+ }
+
+ // 4. Validation spécifique au canal demandé
+ if (data.canal === CanalNotification.EMAIL && !destinationEmail) {
+ throw new Error(
+ `Canal EMAIL demandé mais aucune adresse email valide pour l'utilisateur ${data.utilisateurId}`,
+ );
+ }
+
+ if (data.canal === CanalNotification.SMS && !destinationPhone) {
+ throw new Error(
+ `Canal SMS demandé mais aucun numéro de téléphone valide pour l'utilisateur ${data.utilisateurId}`,
+ );
+ }
+
const notif = this.notifRepo.create({
- ...data,
+ utilisateurId: data.utilisateurId,
+ typeNotification: data.typeNotification,
+ canal: data.canal,
+ context: data.context,
message,
+ destinationEmail,
+ destinationPhone,
statut: StatutNotification.EN_COURS,
});
await this.notifRepo.save(notif);
try {
- if (notif.canal === CanalNotification.SMS) {
+ if (notif.canal === CanalNotification.SMS && destinationPhone) {
await client.messages.create({
body: message,
from: process.env.TWILIO_PHONE_NUMBER,
- to: data.utilisateurId, // ici utilisateurId = numéro pour simplifier
+ to: destinationPhone,
});
}
- //Envoi d'email si canal = EMAIL
- if (notif.canal === CanalNotification.EMAIL) {
- await sendEmail(data.utilisateurId," HELLO ", message);
+ if (notif.canal === CanalNotification.EMAIL && destinationEmail) {
+ await sendEmail(destinationEmail, "RICASH NOTIFICATION", message);
}
notif.statut = StatutNotification.ENVOYEE;
diff --git a/src/services/otpService.ts b/src/services/otpService.ts
index 09ff564b..c1fb0f3b 100644
--- a/src/services/otpService.ts
+++ b/src/services/otpService.ts
@@ -1,12 +1,11 @@
import { AppDataSource } from "../data-source";
-import { Otp } from "../entities/Otp";
-import { NotificationService } from "./notificationService";
import { CanalNotification, TypeNotification } from "../entities/Notification";
+import { Otp } from "../entities/Otp";
+import { InterServices } from "../messaging/contracts/interServices";
import { publishNotification } from "../messaging/publisher";
export class OtpService {
private otpRepo = AppDataSource.getRepository(Otp);
- private notificationService = new NotificationService();
private generateCode(): string {
return Math.floor(1000 + Math.random() * 9000).toString(); // 4chiffres
@@ -14,16 +13,25 @@ export class OtpService {
private expirationDelay = 5 * 60 * 1000; // 5 minutes
- async createOtp(utilisateurId: string, canalNotification: CanalNotification.EMAIL | CanalNotification.SMS ) {
+ async createOtp(
+ utilisateurId: string,
+ canalNotification: CanalNotification.EMAIL | CanalNotification.SMS,
+ email: string,
+ phone: string,
+ ) {
const code = this.generateCode();
const expiration = new Date(Date.now() + this.expirationDelay);
+ const destinationEmail: string = email;
+ const destinationPhone: string = phone;
const otp = this.otpRepo.create({
- utilisateurId,
- canal: canalNotification,
- code,
- expiration
- });
+ utilisateurId, // identifiant métier
+ canal: canalNotification,
+ code,
+ expiration,
+ destinationEmail,
+ destinationPhone,
+ });
await this.otpRepo.save(otp);
// Détermination automatique du type de notification
@@ -32,33 +40,27 @@ export class OtpService {
? TypeNotification.VERIFICATION_EMAIL
: TypeNotification.VERIFICATION_TELEPHONE;
- // message standard convenu entre services
- const message = {
- traceId: `otp-${otp.id}`, // utile pour idempotence / debug
- source: "otp-service",
+ // message standard inter-services (aligné sur InterServices / NotificationEvent)
+ const message: InterServices = {
+ utilisateurId,
typeNotification: notifType,
canal: canalNotification,
- utilisateurId,
context: { code },
- meta: { otpId: otp.id, expiresAt: expiration.toISOString() },
+ email: destinationEmail,
+ phone: destinationPhone,
+ metadata: {
+ service: "notification-service:otp",
+ correlationId: `otp-${otp.id}`,
+ },
};
-
- // NotificationService s’occupe de générer le message
- //await this.notificationService.envoyerNotification({
- await publishNotification("notifications.main"
- //{
- // utilisateurId,
- // typeNotification: notifType,
- // canal: canalNotification === "EMAIL" ? CanalNotification.EMAIL : CanalNotification.SMS,
- // context: { code },
- // }
- );
+ // Publication d'un événement OTP sur l'exchange partagé (ex: ricash.events)
+ // Routing key dédiée : otp.verification (captée via le binding "otp.*")
+ await publishNotification("otp.verification", message);
return { success: true, message: "OTP envoyé", expiration };
}
-
async verifyOtp(utilisateurId: string, code: string) {
const otp = await this.otpRepo.findOne({
where: { utilisateurId, code },
@@ -84,6 +86,9 @@ export class OtpService {
async cleanExpiredOtps() {
const now = new Date();
- await this.otpRepo.createQueryBuilder().delete().where("expiration < :now",{ now }).execute;
+ await this.otpRepo
+ .createQueryBuilder()
+ .delete()
+ .where("expiration < :now", { now }).execute;
}
}
diff --git a/src/services/userContactService.ts b/src/services/userContactService.ts
new file mode 100644
index 00000000..824f93df
--- /dev/null
+++ b/src/services/userContactService.ts
@@ -0,0 +1,33 @@
+export interface UserContact {
+ email?: string;
+ phone?: string;
+}
+
+/**
+ * Service responsable de récupérer les coordonnées de contact
+ * (email / téléphone) à partir d'un identifiant métier utilisateur.
+ *
+ * Implémentation actuelle : simple map en mémoire pour les tests.
+ * Elle pourra être remplacée plus tard par :
+ * - un appel HTTP vers un service utilisateur,
+ * - une lecture dans une table `Utilisateur`, etc.
+ */
+export class UserContactService {
+ private contacts = new Map([
+ // Exemple de données de test ; à adapter ou supprimer en prod
+ ["user-test-email", { email: "managerdayif@gmail.com" }],
+ ["user-test-sms", { phone: "+22379994640" }],
+ [
+ "user-test-both",
+ { email: "managerdayif@gmail.com", phone: "+22379994640" },
+ ],
+ ]);
+
+ async getContact(utilisateurId: string): Promise {
+ const contact = this.contacts.get(utilisateurId);
+ return contact ?? {};
+ }
+}
+
+// Instance par défaut réutilisable dans tout le service
+export const userContactService = new UserContactService();
diff --git a/src/utils/messageTemplates.ts b/src/utils/messageTemplates.ts
index 70146051..e125f7ba 100644
--- a/src/utils/messageTemplates.ts
+++ b/src/utils/messageTemplates.ts
@@ -3,16 +3,23 @@ import { TypeNotification } from "../entities/Notification";
export const generateMessage = (type: TypeNotification, context: any) => {
switch (type) {
case TypeNotification.CONFIRMATION_TRANSFERT:
- return `Votre transfert de ${context.montant} FCFA vers ${context.destinataire} a été confirmé.`;
+ // Pour les transferts, on distingue l'expéditeur (direction="debit") et le destinataire (direction="credit").
+ if (context?.direction === "credit") {
+ // Message pour le bénéficiaire qui reçoit un transfert
+ return `Vous avez reçu un transfert de ${context.destinataire} de ${context.montant} ${context.currency ?? "FCFA"}. Nouveau solde: ${context.balance ?? context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
+ }
+
+ // Par défaut (et pour direction="debit"), message pour l'expéditeur
+ return `Votre transfert de ${context.montant} ${context.currency ?? "FCFA"} vers ${context.destinataire} a été confirmé. Nouveau solde: ${context.balance ?? context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
case TypeNotification.CONFIRMATION_RETRAIT:
- return `Votre demande de retrait de ${context.montant} FCFA est en cours de traitement.`;
+ return `Votre demande de retrait de ${context.montant} ${context.currency ?? "FCFA"} est en cours de traitement. Référence: ${context.transactionId}.`;
case TypeNotification.RETRAIT_REUSSI:
- return `Votre retrait de ${context.montant} FCFA a été effectué avec succès.`;
+ return `Votre retrait de ${context.montant} ${context.currency ?? "FCFA"} a été effectué avec succès. Nouveau solde: ${context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
case TypeNotification.DEPOT_REUSSI:
- return `Vous avez reçu un dépôt de ${context.montant} FCFA sur votre compte.`;
+ return `Vous avez reçu un dépôt de ${context.montant} ${context.currency ?? "FCFA"} sur votre compte. Nouveau solde: ${context.solde} ${context.currency ?? "FCFA"}. Référence: ${context.transactionId}.`;
case TypeNotification.ALERT_SECURITE:
return `Alerte sécurité : connexion suspecte depuis un nouvel appareil.`;
@@ -21,10 +28,10 @@ export const generateMessage = (type: TypeNotification, context: any) => {
return `Votre vérification d’identité (KYC) est ${context.status === "valide" ? "validée " : "en attente "}.`;
case TypeNotification.VERIFICATION_EMAIL:
- return `Votre code de vérification email est : ${context.code}`;
+ return `Votre code de vérification email est : ${context.code}. Ce code est valable 5 minutes. Ne le partagez jamais avec un tiers.`;
case TypeNotification.VERIFICATION_TELEPHONE:
- return `Votre code OTP de vérification téléphone est : ${context.code}`;
+ return `Votre code OTP de vérification téléphone est : ${context.code}. Ce code est valable 5 minutes. Ne le partagez jamais avec un tiers.`;
default:
return `Notification générique`;