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`;