From 894b79101087a5d39530ed7bab4e9d3e1547e774 Mon Sep 17 00:00:00 2001 From: ricashtrans Date: Wed, 31 Dec 2025 13:00:21 +0000 Subject: [PATCH 1/7] Update docker-build.yml From 73e37cbcade7baed502eb73f9d29b48c2952f457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9kou=20Dayifourou=20KEITA?= <123929396+Dayifour@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:10:13 +0000 Subject: [PATCH 2/7] Merge the branch main on the branch develop (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update docker-build.yml (#10) * Update docker-build.yml * README.md explicitement bien détaillé --------- Co-authored-by: Manager Dayif * Notification-Service Fonctionnel ✅ (#13) * Refactor OTP service to publish notifications with updated message structure and routing key * Refactor RabbitMQ configuration and health route for improved readability and consistency * Refactor notification controller and server initialization for improved structure and readability * Add user contact service and update notification handling for email and phone * Refactor code structure for improved readability in Otp, NotificationService, and UserContactService * Update Docker Compose configuration, refactor Notification entity imports, enhance OTP service with user contact retrieval * Update Docker Compose to use external network and enhance external notification consumer logging * Update dependencies: body-parser to 2.2.1 and nodemailer to 6.10.1 * Enhance OTP generation to validate email and phone inputs, and retrieve user contacts if not provided * Refactor InterServices and NotificationService to include optional email and phone fields, enhancing contact retrieval logic for notifications * Add Zod validation library and enhance notification service with multi-channel support for email and SMS * Enhance notification and OTP generation with Zod validation for request bodies * feat: Implement notification and OTP services with RabbitMQ integration - Added RabbitMQ configuration and channel management in `rabbitmq.js`. - Created notification controller to handle sending notifications and retrieving them. - Developed OTP controller for generating and verifying OTPs. - Introduced data source configuration for PostgreSQL using TypeORM. - Defined Notification and Otp entities with appropriate fields and relationships. - Implemented notification service for sending notifications via SMS and email. - Created OTP service for generating and verifying OTPs, including publishing events to RabbitMQ. - Added user contact service for retrieving user contact information. - Implemented mail service for sending emails using Nodemailer. - Developed message templates for generating notification messages based on type. - Created health check route for service status. - Set up consumers for processing notifications from RabbitMQ. - Added external consumer for handling inter-service notifications. * feat: notifications inter-service et robustesse consumer Co-authored-by: Aissata Traore * fix: messages de transfert sender/receiver Co-authored-by: Aissata Traore --------- Co-authored-by: Aissata Traore --------- Co-authored-by: ricashtrans Co-authored-by: Aissata Traore --- .github/workflows/docker-build.yml | 42 ++- Docker-compose.yml | 15 +- README.md | 265 +++++++++++------- dist/app.js | 11 + dist/config/rabbitmq.js | 108 +++++++ dist/controllers/notificationController.js | 61 ++++ dist/controllers/optController.js | 46 +++ dist/data-source.js | 23 ++ dist/entities/Notification.js | 89 ++++++ dist/entities/Otp.js | 56 ++++ dist/index.js | 23 ++ dist/messaging/consumer.js | 37 +++ dist/messaging/contracts/interServices.js | 2 + dist/messaging/externalConsumer.js | 27 ++ dist/messaging/mappers/notification.mapper.js | 25 ++ dist/messaging/publisher.js | 11 + dist/routes/health.js | 11 + dist/routes/notificationRoutes.js | 13 + dist/server.js | 69 +++++ dist/services/notificationService.js | 205 ++++++++++++++ dist/services/otpService.js | 77 +++++ dist/services/userContactService.js | 32 +++ dist/utils/mailService.js | 32 +++ dist/utils/messageTemplates.js | 33 +++ package-lock.json | 65 +++-- package.json | 5 +- src/config/rabbitmq.ts | 24 +- src/controllers/notificationController.ts | 55 +++- src/controllers/optController.ts | 38 ++- src/entities/Notification.ts | 13 +- src/entities/Otp.ts | 19 +- src/messaging/contracts/interServices.ts | 8 +- src/messaging/externalConsumer.ts | 11 + src/messaging/mappers/notification.mapper.ts | 25 +- src/messaging/publisher.ts | 18 +- src/routes/health.ts | 9 +- src/server.ts | 58 ++-- src/services/notificationService.ts | 241 +++++++++++++++- src/services/otpService.ts | 61 ++-- src/services/userContactService.ts | 33 +++ src/utils/messageTemplates.ts | 19 +- 41 files changed, 1756 insertions(+), 259 deletions(-) create mode 100644 dist/app.js create mode 100644 dist/config/rabbitmq.js create mode 100644 dist/controllers/notificationController.js create mode 100644 dist/controllers/optController.js create mode 100644 dist/data-source.js create mode 100644 dist/entities/Notification.js create mode 100644 dist/entities/Otp.js create mode 100644 dist/index.js create mode 100644 dist/messaging/consumer.js create mode 100644 dist/messaging/contracts/interServices.js create mode 100644 dist/messaging/externalConsumer.js create mode 100644 dist/messaging/mappers/notification.mapper.js create mode 100644 dist/messaging/publisher.js create mode 100644 dist/routes/health.js create mode 100644 dist/routes/notificationRoutes.js create mode 100644 dist/server.js create mode 100644 dist/services/notificationService.js create mode 100644 dist/services/otpService.js create mode 100644 dist/services/userContactService.js create mode 100644 dist/utils/mailService.js create mode 100644 dist/utils/messageTemplates.js create mode 100644 src/services/userContactService.ts 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`; From 28b598b47d70a58561907a8b3a49cbc8a0167e9b Mon Sep 17 00:00:00 2001 From: Manager Dayif Date: Wed, 25 Feb 2026 16:17:36 +0000 Subject: [PATCH 3/7] add: Great Readme.md --- README.md | 358 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 232 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 250f883a..61d5b80f 100644 --- a/README.md +++ b/README.md @@ -63,130 +63,236 @@ Depuis la refonte, le service est **strictement dépendant des coordonnées four - 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) + # Notification-Service + + Service de notifications (e-mail & SMS & OTP) développé en Node.js, Express et TypeScript. + + Ce README décrit l'installation, la configuration, les endpoints, les variables d'environnement et les bonnes pratiques pour déployer et tester le service. + + Table des matières + - Présentation + - Prérequis + - Installation + - Variables d'environnement + - Commandes utiles + - Endpoints et exemples + - Health checks + - Docker / Compose + - Débogage et logs + - Notes de sécurité + + --- + + Présentation + ------------ + Ce service reçoit des requêtes HTTP pour envoyer des notifications et générer/vérifier des OTP. Il s'intègre avec : + - PostgreSQL (TypeORM) + - RabbitMQ (échange partagé, queue privée) + - Twilio (SMS) + - Nodemailer (e-mail) + + Le code organise les responsabilités en contrôleurs, services, entités, utilitaires et messaging (publisher/consumer). + + Prérequis + --------- + - Node.js >= 18 + - npm + - PostgreSQL accessible (ou instance locale) + - RabbitMQ accessible (ou instance locale) + - Compte Twilio (si SMS en production) ou configuration de mock + - Compte e-mail (Gmail ou SMTP compatible) pour envoi d'e-mails + + Installation + ------------ + 1. Cloner le dépôt et positionnez-vous dans le dossier du service : + + ```bash + cd notification_service + ``` + + 2. Installer les dépendances : + + ```bash + npm install + ``` + + 3. Compiler TypeScript : + + ```bash + npm run build + ``` + + 4. Lancer en développement (reload automatique) : + + ```bash + npm run dev + ``` + + Variables d'environnement + ------------------------ + Les variables attendues par le service (fichier `.env` recommandé) : + + - SERVICE_PORT: port d'écoute HTTP (ex: 8000) + - SERVICE_VERSION: version déployée (optionnel) + - COMMIT_SHA: sha du commit déployé (optionnel) + + - PostgreSQL: + - DB_HOST + - DB_PORT (par défaut 5432) + - DB_USER + - DB_PASSWORD + - DB_NAME + + - RabbitMQ: + - RABBITMQ_URL (ex: amqp://user:pass@host:5672) + - RABBITMQ_EXCHANGE (nom de l'exchange partagé) + - RABBITMQ_QUEUE (nom de la queue principale pour ce service) + + - Twilio (si SMS) : + - TWILIO_ACCOUNT_SID + - TWILIO_AUTH_TOKEN + - TWILIO_PHONE_NUMBER + + - E-mail (Nodemailer) : + - MAIL_USER + - MAIL_PASS + + - Health / diagnostics (optionnel) : + - HEALTH_CHECK_TIMEOUT_MS (ms, défaut 1000) + - HEALTH_CACHE_TTL_MS (ms, défaut 5000) + - HEALTH_EXPOSE_ERRORS (true|false, défaut false) + + Commandes utiles + ---------------- + - `npm run dev` — démarre avec `ts-node-dev` (dev hot-reload) + - `npm run build` — compile TypeScript vers `dist/` + - `npm start` — exécute `node src/server.ts` (production si compilé) + + Endpoints et exemples + --------------------- + Base URL: `http://{host}:{SERVICE_PORT}` + + Health + - `GET /health` — liveness minimal (retourne OK + uptime) + - `GET /health/ready` — readiness : vérifie PostgreSQL et RabbitMQ, retourne 200 ou 503. Réponse contient `components.db` et `components.rabbitmq`. + + Notifications + - `POST /api/notifications/envoyer` — envoie une notification. + - Corps possible (exemples) : + + Transfer (expéditeur + destinataire envoyés sur SMS + email si fournis) : + + ```json + { + "type": "transfer", + "sender": { "email": "a@ex.com", "phone": "+223xxxxxxxx" }, + "receiver": { "email": "b@ex.com", "phone": "+223yyyyyyyy" }, + "amount": 10000, + "content": "Votre transfert de 10000 F CFA a été effectué" + } + ``` + + Simple notification : + + ```json + { + "type": "alert_securite", + "user": { "email": "u@ex.com", "phone": "+223zzzzzzzz" }, + "content": "Un événement important a eu lieu" + } + ``` + + - Réponse : `201` + objet décrivant les enregistrements créés (sms / email) + + - `POST /api/notifications/rabbitmq` — endpoint de test qui publie un message sur RabbitMQ (routingKey/message dans body) + + OTP + - `POST /api/notifications/otp/generate` — génère un OTP + - Body example: + ```json + { "utilisateurId": "user-123", "canalNotification": "SMS", "phone": "+223..." } + ``` + + - `POST /api/notifications/otp/verify` — vérifie un OTP + - Body example: + ```json + { "utilisateurId": "user-123", "code": "1234" } + ``` + + Health checks (détails) + ----------------------- + - `/health` est une probe de liveness simple, utile pour Kubernetes readiness/liveness probes basiques. + - `/health/ready` exécute des vérifications actives : + - exécute `SELECT 1` sur PostgreSQL (avec timeout configurable) + - vérifie que le channel RabbitMQ est initialisé + - met en cache le résultat pendant `HEALTH_CACHE_TTL_MS` pour limiter la charge + - renvoie `version` et `commit` si disponibles + + Docker / Compose + ----------------- + Le repo contient un `Dockerfile` et un `docker-compose.yml` : + + Construction : + + ```bash + docker build -t ricash/notification-service:latest . + ``` + + Compose (exemple très simple) : + + ```yaml + version: '3.8' + services: + notification-service: + image: ricash/notification-service:latest + env_file: .env + ports: + - "8000:8000" + depends_on: + - db + - rabbitmq + + db: + image: postgres:15 + environment: + POSTGRES_USER: example + POSTGRES_PASSWORD: example + POSTGRES_DB: ricash + + rabbitmq: + image: rabbitmq:3-management + ports: + - "5672:5672" + - "15672:15672" + ``` + + Débogage et logs + --------------- + - Les logs sont écrits sur stdout. + - Vérifier les erreurs de connexion à RabbitMQ et PostgreSQL au démarrage. + - En cas d'erreurs d'envoi SMS/Email, les exceptions sont loggées et le statut de la notification est mis à `ECHEC`. + + Sécurité et bonnes pratiques + --------------------------- + - Ne pas exposer `HEALTH_EXPOSE_ERRORS=true` en production si les messages d'erreur contiennent des données sensibles. + - Utiliser des secrets manager pour les identifiants (DB, Twilio, MAIL_PASS). + - Désactiver `synchronize: true` (TypeORM) en production et utiliser des migrations contrôlées. + + Contribution + ------------ + Pour proposer des améliorations : + 1. Créer une branche feature + 2. Ajouter tests / valider localement + 3. Ouvrir une Pull Request vers `develop` + + Support + ------- + Si tu veux, je peux : + - ajouter des exemples Postman + - créer un `docker-compose.dev.yml` complet pour démarrer la stack locale + - ajouter des tests unitaires pour `NotificationService` / `OtpService` + + --- + + Fait avec ❤️ — Notification-Service -```json -{ - "type": "ALERT_SECURITE", - "user": { - "email": "client@mail.com", - "phone": "+22322222222" - }, - "content": "Connexion suspecte détectée." -} -``` - -- `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**. - -En cas de JSON invalide (champ manquant / mauvais type), le contrôleur renvoie : - -```json -{ - "success": false, - "message": "Corps de requête invalide", - "errors": { ...détail Zod... } -} -``` - -### 2. Génération d’OTP - -`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 From e9c16db550ac9c49e8f3a467d261a4a8bc60db01 Mon Sep 17 00:00:00 2001 From: Manager Dayif Date: Wed, 25 Feb 2026 16:17:36 +0000 Subject: [PATCH 4/7] add: Great Readme.md --- README.md | 329 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 216 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 250f883a..711a7027 100644 --- a/README.md +++ b/README.md @@ -63,130 +63,233 @@ Depuis la refonte, le service est **strictement dépendant des coordonnées four - 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." -} -``` - -- `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**. - -En cas de JSON invalide (champ manquant / mauvais type), le contrôleur renvoie : - -```json -{ - "success": false, - "message": "Corps de requête invalide", - "errors": { ...détail Zod... } -} -``` - -### 2. Génération d’OTP - -`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" -} -``` + # Notification-Service -- `utilisateurId`: identifiant métier (user id). -- `canalNotification`: `"SMS"` ou `"EMAIL"`. -- `email`: email du destinataire (obligatoire). -- `phone`: numéro du destinataire (obligatoire). + Service de notifications (e-mail & SMS & OTP) développé en Node.js, Express et TypeScript. -│ │ ├── Notification.ts # Modèle de données pour les notifications + Ce README décrit l'installation, la configuration, les endpoints, les variables d'environnement et les bonnes pratiques pour déployer et tester le service. -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-" - } -} -``` + Table des matières + - Présentation + - Prérequis + - Installation + - Variables d'environnement + - Commandes utiles + - Endpoints et exemples + - Health checks + - Docker / Compose + - Débogage et logs + - Notes de sécurité -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. » + ## Présentation -### 3. Vérification d’un OTP + Ce service reçoit des requêtes HTTP pour envoyer des notifications et générer/vérifier des OTP. Il s'intègre avec : + - PostgreSQL (TypeORM) + - RabbitMQ (échange partagé, queue privée) + - Twilio (SMS) + - Nodemailer (e-mail) -`POST /api/notifications/otp/verify` + Le code organise les responsabilités en contrôleurs, services, entités, utilitaires et messaging (publisher/consumer). -Body JSON : + ## Prérequis + - Node.js >= 18 + - npm + - PostgreSQL accessible (ou instance locale) + - RabbitMQ accessible (ou instance locale) + - Compte Twilio (si SMS en production) ou configuration de mock + - Compte e-mail (Gmail ou SMTP compatible) pour envoi d'e-mails -```json -{ - "utilisateurId": "user-otp-1", - "code": "1234" -} -``` + ## Installation + 1. Cloner le dépôt et positionnez-vous dans le dossier du service : -Réponses possibles : + ```bash + cd notification_service + ``` -```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é" } -``` + 2. Installer les dépendances : + + ```bash + npm install + ``` + + 3. Compiler TypeScript : + + ```bash + npm run build + ``` + + 4. Lancer en développement (reload automatique) : + + ```bash + npm run dev + ``` + + ## Variables d'environnement + + Les variables attendues par le service (fichier `.env` recommandé) : + - SERVICE_PORT: port d'écoute HTTP (ex: 8000) + - SERVICE_VERSION: version déployée (optionnel) + - COMMIT_SHA: sha du commit déployé (optionnel) ---- + - PostgreSQL: + - DB_HOST + - DB_PORT (par défaut 5432) + - DB_USER + - DB_PASSWORD + - DB_NAME -│ │ ├── 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 + - RabbitMQ: + - RABBITMQ_URL (ex: amqp://user:pass@host:5672) + - RABBITMQ_EXCHANGE (nom de l'exchange partagé) + - RABBITMQ_QUEUE (nom de la queue principale pour ce service) + + - Twilio (si SMS) : + - TWILIO_ACCOUNT_SID + - TWILIO_AUTH_TOKEN + - TWILIO_PHONE_NUMBER + + - E-mail (Nodemailer) : + - MAIL_USER + - MAIL_PASS + + - Health / diagnostics (optionnel) : + - HEALTH_CHECK_TIMEOUT_MS (ms, défaut 1000) + - HEALTH_CACHE_TTL_MS (ms, défaut 5000) + - HEALTH_EXPOSE_ERRORS (true|false, défaut false) + + ## Commandes utiles + - `npm run dev` — démarre avec `ts-node-dev` (dev hot-reload) + - `npm run build` — compile TypeScript vers `dist/` + - `npm start` — exécute `node src/server.ts` (production si compilé) + + ## Endpoints et exemples + + Base URL: `http://{host}:{SERVICE_PORT}` + + Health + - `GET /health` — liveness minimal (retourne OK + uptime) + - `GET /health/ready` — readiness : vérifie PostgreSQL et RabbitMQ, retourne 200 ou 503. Réponse contient `components.db` et `components.rabbitmq`. + + Notifications + - `POST /api/notifications/envoyer` — envoie une notification. + - Corps possible (exemples) : + + Transfer (expéditeur + destinataire envoyés sur SMS + email si fournis) : + + ```json + { + "type": "transfer", + "sender": { "email": "a@ex.com", "phone": "+223xxxxxxxx" }, + "receiver": { "email": "b@ex.com", "phone": "+223yyyyyyyy" }, + "amount": 10000, + "content": "Votre transfert de 10000 F CFA a été effectué" + } + ``` + + Simple notification : + + ```json + { + "type": "alert_securite", + "user": { "email": "u@ex.com", "phone": "+223zzzzzzzz" }, + "content": "Un événement important a eu lieu" + } + ``` + + - Réponse : `201` + objet décrivant les enregistrements créés (sms / email) + + - `POST /api/notifications/rabbitmq` — endpoint de test qui publie un message sur RabbitMQ (routingKey/message dans body) + + OTP + - `POST /api/notifications/otp/generate` — génère un OTP + - Body example: + ```json + { + "utilisateurId": "user-123", + "canalNotification": "SMS", + "phone": "+223..." + } + ``` + + - `POST /api/notifications/otp/verify` — vérifie un OTP + - Body example: + ```json + { "utilisateurId": "user-123", "code": "1234" } + ``` + + ## Health checks (détails) + - `/health` est une probe de liveness simple, utile pour Kubernetes readiness/liveness probes basiques. + - `/health/ready` exécute des vérifications actives : + - exécute `SELECT 1` sur PostgreSQL (avec timeout configurable) + - vérifie que le channel RabbitMQ est initialisé + - met en cache le résultat pendant `HEALTH_CACHE_TTL_MS` pour limiter la charge + - renvoie `version` et `commit` si disponibles + + ## Docker / Compose + + Le repo contient un `Dockerfile` et un `docker-compose.yml` : + + Construction : + + ```bash + docker build -t ricash/notification-service:latest . + ``` + + Compose (exemple très simple) : + + ```yaml + version: "3.8" + services: + notification-service: + image: ricash/notification-service:latest + env_file: .env + ports: + - "8000:8000" + depends_on: + - db + - rabbitmq + + db: + image: postgres:15 + environment: + POSTGRES_USER: example + POSTGRES_PASSWORD: example + POSTGRES_DB: ricash + + rabbitmq: + image: rabbitmq:3-management + ports: + - "5672:5672" + - "15672:15672" + ``` + + ## Débogage et logs + - Les logs sont écrits sur stdout. + - Vérifier les erreurs de connexion à RabbitMQ et PostgreSQL au démarrage. + - En cas d'erreurs d'envoi SMS/Email, les exceptions sont loggées et le statut de la notification est mis à `ECHEC`. + + ## Sécurité et bonnes pratiques + - Ne pas exposer `HEALTH_EXPOSE_ERRORS=true` en production si les messages d'erreur contiennent des données sensibles. + - Utiliser des secrets manager pour les identifiants (DB, Twilio, MAIL_PASS). + - Désactiver `synchronize: true` (TypeORM) en production et utiliser des migrations contrôlées. + + ## Contribution + + Pour proposer des améliorations : + 1. Créer une branche feature + 2. Ajouter tests / valider localement + 3. Ouvrir une Pull Request vers `develop` + + ## Support + + Si tu veux, je peux : + - ajouter des exemples Postman + - créer un `docker-compose.dev.yml` complet pour démarrer la stack locale + - ajouter des tests unitaires pour `NotificationService` / `OtpService` + + *** + + Fait avec ❤️ — Notification-Service From dfb19c65b7ff3f3ef7ef417fa9a31177562d0ad0 Mon Sep 17 00:00:00 2001 From: Manager Dayif Date: Tue, 24 Mar 2026 16:35:42 +0000 Subject: [PATCH 5/7] feat: add alert notification schema and handling in notification service refactor: simplify health check timeout logic refactor: update OTP service to remove email parameter --- dist/routes/health.js | 153 ++++++++++----------- src/controllers/notificationController.ts | 16 ++- src/controllers/optController.ts | 15 +-- src/services/notificationService.ts | 157 ++++++++++++++++++++++ src/services/otpService.ts | 14 +- 5 files changed, 247 insertions(+), 108 deletions(-) diff --git a/dist/routes/health.js b/dist/routes/health.js index e251fa3b..45cbd028 100644 --- a/dist/routes/health.js +++ b/dist/routes/health.js @@ -1,9 +1,7 @@ "use strict"; -var __importDefault = - (this && this.__importDefault) || - function (mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; +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 fs_1 = __importDefault(require("fs")); @@ -12,100 +10,87 @@ const rabbitmq_1 = require("../config/rabbitmq"); const data_source_1 = require("../data-source"); const router = express_1.default.Router(); // Configurable via env -const HEALTH_TIMEOUT_MS = parseInt( - process.env.HEALTH_CHECK_TIMEOUT_MS || "1000", - 10, -); -const HEALTH_CACHE_TTL_MS = parseInt( - process.env.HEALTH_CACHE_TTL_MS || "5000", - 10, -); +const HEALTH_TIMEOUT_MS = parseInt(process.env.HEALTH_CHECK_TIMEOUT_MS || "1000", 10); +const HEALTH_CACHE_TTL_MS = parseInt(process.env.HEALTH_CACHE_TTL_MS || "5000", 10); const EXPOSE_ERRORS = process.env.HEALTH_EXPOSE_ERRORS === "true"; // Read package.json for version fallback let pkgVersion; let pkgName; try { - const pkgPath = path_1.default.join(__dirname, "..", "..", "package.json"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, "utf8")); - pkgVersion = pkg.version; - pkgName = pkg.name; -} catch { - // ignore + const pkgPath = path_1.default.join(__dirname, "..", "..", "package.json"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, "utf8")); + pkgVersion = pkg.version; + pkgName = pkg.name; +} +catch { + // ignore } // Simple cache to avoid expensive repeated checks let readinessCache = null; function withTimeout(p, ms, name) { - return Promise.race([ - p, - new Promise((_, rej) => - setTimeout(() => rej(new Error(`timeout:${name || "op"}`)), ms), - ), - ]); + return Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout:${name || "op"}`)), ms)), + ]); } // Simple liveness probe router.get("/health", (req, res) => { - res.status(200).json({ status: "OK", uptime: process.uptime() }); + res.status(200).json({ status: "OK", uptime: process.uptime() }); }); // Readiness probe: checks PostgreSQL connection and RabbitMQ channel router.get("/health/ready", async (req, res) => { - const now = Date.now(); - if (readinessCache && now - readinessCache.ts < HEALTH_CACHE_TTL_MS) { - return res.status(readinessCache.code).json(readinessCache.result); - } - const result = { - status: "OK", - uptime: process.uptime(), - timestamp: new Date().toISOString(), - version: process.env.SERVICE_VERSION || pkgVersion, - commit: process.env.COMMIT_SHA, - components: { - db: { status: "UNKNOWN" }, - rabbitmq: { status: "UNKNOWN" }, - }, - }; - // Check DB with timeout - try { - if (!data_source_1.AppDataSource.isInitialized) { - throw new Error("DataSource not initialized"); + const now = Date.now(); + if (readinessCache && now - readinessCache.ts < HEALTH_CACHE_TTL_MS) { + return res.status(readinessCache.code).json(readinessCache.result); + } + const result = { + status: "OK", + uptime: process.uptime(), + timestamp: new Date().toISOString(), + version: process.env.SERVICE_VERSION || pkgVersion, + commit: process.env.COMMIT_SHA, + components: { + db: { status: "UNKNOWN" }, + rabbitmq: { status: "UNKNOWN" }, + }, + }; + // Check DB with timeout + try { + if (!data_source_1.AppDataSource.isInitialized) { + throw new Error("DataSource not initialized"); + } + // lightweight query with timeout + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + await withTimeout(data_source_1.AppDataSource.query("SELECT 1"), HEALTH_TIMEOUT_MS, "db"); + result.components.db.status = "OK"; + } + catch (err) { + result.status = "NOT_OK"; + result.components.db.status = "DOWN"; + result.components.db.error = EXPOSE_ERRORS + ? err instanceof Error + ? err.message + : String(err) + : "unavailable"; + } + // Check RabbitMQ with timeout + try { + await withTimeout(Promise.resolve((0, rabbitmq_1.getRabbitChannel)()), HEALTH_TIMEOUT_MS, "rabbitmq"); + result.components.rabbitmq.status = "OK"; + } + catch (err) { + result.status = "NOT_OK"; + result.components.rabbitmq.status = "DOWN"; + result.components.rabbitmq.error = EXPOSE_ERRORS + ? err instanceof Error + ? err.message + : String(err) + : "unavailable"; } - // lightweight query with timeout - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - await withTimeout( - data_source_1.AppDataSource.query("SELECT 1"), - HEALTH_TIMEOUT_MS, - "db", - ); - result.components.db.status = "OK"; - } catch (err) { - result.status = "NOT_OK"; - result.components.db.status = "DOWN"; - result.components.db.error = EXPOSE_ERRORS - ? err instanceof Error - ? err.message - : String(err) - : "unavailable"; - } - // Check RabbitMQ with timeout - try { - await withTimeout( - Promise.resolve((0, rabbitmq_1.getRabbitChannel)()), - HEALTH_TIMEOUT_MS, - "rabbitmq", - ); - result.components.rabbitmq.status = "OK"; - } catch (err) { - result.status = "NOT_OK"; - result.components.rabbitmq.status = "DOWN"; - result.components.rabbitmq.error = EXPOSE_ERRORS - ? err instanceof Error - ? err.message - : String(err) - : "unavailable"; - } - const code = result.status === "OK" ? 200 : 503; - readinessCache = { ts: now, result, code }; - res.status(code).json(result); + const code = result.status === "OK" ? 200 : 503; + readinessCache = { ts: now, result, code }; + res.status(code).json(result); }); exports.default = router; diff --git a/src/controllers/notificationController.ts b/src/controllers/notificationController.ts index 7154609b..e1ca8da6 100644 --- a/src/controllers/notificationController.ts +++ b/src/controllers/notificationController.ts @@ -10,6 +10,11 @@ const ContactSchema = z.object({ phone: z.string().min(8), }); +const AlertContactSchema = z.object({ + phone: z.string().min(8), + email: z.string().email().optional(), +}); + const TransferNotificationSchema = z.object({ type: z.literal("transfer"), sender: ContactSchema, @@ -18,12 +23,18 @@ const TransferNotificationSchema = z.object({ content: z.string().min(1), }); +const AlertNotificationSchema = z.object({ + type: z.enum(["alert_securite", "ALERT_SECURITE"]), + user: AlertContactSchema, + 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".', + .refine((value) => value !== "transfer" && value !== "alert_securite" && value !== "ALERT_SECURITE", { + message: 'Utiliser le schéma "transfer" ou "alert_securite" selon le type.', }), user: ContactSchema, content: z.string().min(1), @@ -31,6 +42,7 @@ const SimpleNotificationSchema = z.object({ const NotificationBodySchema = z.union([ TransferNotificationSchema, + AlertNotificationSchema, SimpleNotificationSchema, ]); diff --git a/src/controllers/optController.ts b/src/controllers/optController.ts index b7dbf017..49ba3a6f 100644 --- a/src/controllers/optController.ts +++ b/src/controllers/optController.ts @@ -6,9 +6,7 @@ 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(), + utilisateurId: z.string().min(1).optional(), phone: z.string().min(8), }); @@ -24,17 +22,12 @@ export const generateOtp = async (req: Request, res: Response) => { }); } - const { utilisateurId, canalNotification, email, phone } = parsed.data; - - const canalEnum = - canalNotification === "SMS" - ? CanalNotification.SMS - : CanalNotification.EMAIL; + const { phone } = parsed.data; + const utilisateurId = parsed.data.utilisateurId ?? phone; const result = await otpService.createOtp( utilisateurId, - canalEnum, - email, + CanalNotification.SMS, phone, ); res.json(result); diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 3fb11b04..5c7f762b 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -23,6 +23,11 @@ export interface ContactInfoDTO { phone: string; } +export interface AlertContactInfoDTO { + phone: string; + email?: string; +} + export interface TransferNotificationDTO { type: "transfer"; sender: ContactInfoDTO; @@ -37,8 +42,15 @@ export interface SimpleNotificationDTO { content: string; } +export interface AlertNotificationDTO { + type: "alert_securite" | "ALERT_SECURITE"; + user: AlertContactInfoDTO; + content: string; +} + export type HttpNotificationDTO = | TransferNotificationDTO + | AlertNotificationDTO | SimpleNotificationDTO; export class NotificationService { @@ -160,6 +172,72 @@ export class NotificationService { }; } + private async sendSmsPriorityToContact( + contact: AlertContactInfoDTO, + content: string, + type: TypeNotification, + role: string, + extraContext?: Record, + ) { + const context = { ...(extraContext || {}), role }; + + 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); + + let notifEmail: Notification | undefined; + if (contact.email) { + 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, + ...(notifEmail ? { email: notifEmail } : {}), + }; + } + /** * Endpoint HTTP (Postman) : * - dépend UNIQUEMENT des coordonnées fournies dans le JSON @@ -192,6 +270,22 @@ export class NotificationService { }; } + if (payload.type === "alert_securite" || payload.type === "ALERT_SECURITE") { + const alertPayload = payload as AlertNotificationDTO; + const type = this.mapStringToTypeNotification(alertPayload.type); + + const userResult = await this.sendSmsPriorityToContact( + alertPayload.user, + alertPayload.content, + type, + "USER", + ); + + return { + user: userResult, + }; + } + const simplePayload = payload as SimpleNotificationDTO; const type = this.mapStringToTypeNotification(simplePayload.type); @@ -242,6 +336,69 @@ export class NotificationService { ); } + // Priorité SMS pour les alertes sécurité: SMS d'abord si numéro disponible, + // puis email en second canal si disponible. + if (data.typeNotification === TypeNotification.ALERT_SECURITE) { + const context = data.context; + let smsNotif: Notification | undefined; + let emailNotif: Notification | undefined; + + if (destinationPhone) { + smsNotif = this.notifRepo.create({ + utilisateurId: data.utilisateurId, + typeNotification: data.typeNotification, + canal: CanalNotification.SMS, + context, + message, + destinationPhone, + statut: StatutNotification.EN_COURS, + }); + await this.notifRepo.save(smsNotif); + + try { + await client.messages.create({ + body: message, + from: process.env.TWILIO_PHONE_NUMBER, + to: destinationPhone, + }); + smsNotif.statut = StatutNotification.ENVOYEE; + } catch (error) { + smsNotif.statut = StatutNotification.ECHEC; + console.error("Erreur d'envoi SMS :", error); + } + + await this.notifRepo.save(smsNotif); + } + + if (destinationEmail) { + emailNotif = this.notifRepo.create({ + utilisateurId: data.utilisateurId, + typeNotification: data.typeNotification, + canal: CanalNotification.EMAIL, + context, + message, + destinationEmail, + statut: StatutNotification.EN_COURS, + }); + await this.notifRepo.save(emailNotif); + + try { + await sendEmail(destinationEmail, "RICASH NOTIFICATION", message); + emailNotif.statut = StatutNotification.ENVOYEE; + } catch (error) { + emailNotif.statut = StatutNotification.ECHEC; + console.error("Erreur d'envoi email :", error); + } + + await this.notifRepo.save(emailNotif); + } + + return { + ...(smsNotif ? { sms: smsNotif } : {}), + ...(emailNotif ? { email: emailNotif } : {}), + }; + } + // 4. Validation spécifique au canal demandé if (data.canal === CanalNotification.EMAIL && !destinationEmail) { throw new Error( diff --git a/src/services/otpService.ts b/src/services/otpService.ts index c1fb0f3b..eb177230 100644 --- a/src/services/otpService.ts +++ b/src/services/otpService.ts @@ -15,30 +15,23 @@ export class OtpService { async createOtp( utilisateurId: string, - canalNotification: CanalNotification.EMAIL | CanalNotification.SMS, - email: string, + canalNotification: CanalNotification.SMS, phone: string, ) { const code = this.generateCode(); const expiration = new Date(Date.now() + this.expirationDelay); - const destinationEmail: string = email; - const destinationPhone: string = phone; + 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" - ? TypeNotification.VERIFICATION_EMAIL - : TypeNotification.VERIFICATION_TELEPHONE; + const notifType = TypeNotification.VERIFICATION_TELEPHONE; // message standard inter-services (aligné sur InterServices / NotificationEvent) const message: InterServices = { @@ -46,7 +39,6 @@ export class OtpService { typeNotification: notifType, canal: canalNotification, context: { code }, - email: destinationEmail, phone: destinationPhone, metadata: { service: "notification-service:otp", From 91d91684ce07af4e596ff1afd19f3ec2585a360f Mon Sep 17 00:00:00 2001 From: Manager Dayif Date: Tue, 24 Mar 2026 16:35:51 +0000 Subject: [PATCH 6/7] feat: enhance notification schemas and add alert handling in notification service --- dist/controllers/notificationController.js | 16 ++- dist/controllers/optController.js | 12 +- dist/entities/Notification.js | 54 +++++++++ dist/services/notificationService.js | 122 +++++++++++++++++++++ dist/services/otpService.js | 10 +- src/controllers/notificationController.ts | 13 ++- src/entities/Notification.ts | 64 +++++++++++ src/messaging/contracts/interServices.ts | 12 +- src/services/notificationService.ts | 11 +- 9 files changed, 283 insertions(+), 31 deletions(-) diff --git a/dist/controllers/notificationController.js b/dist/controllers/notificationController.js index 275428a1..a75ad9b2 100644 --- a/dist/controllers/notificationController.js +++ b/dist/controllers/notificationController.js @@ -10,6 +10,10 @@ const ContactSchema = zod_1.z.object({ email: zod_1.z.string().email(), phone: zod_1.z.string().min(8), }); +const AlertContactSchema = zod_1.z.object({ + phone: zod_1.z.string().min(8), + email: zod_1.z.string().email().optional(), +}); const TransferNotificationSchema = zod_1.z.object({ type: zod_1.z.literal("transfer"), sender: ContactSchema, @@ -17,18 +21,26 @@ const TransferNotificationSchema = zod_1.z.object({ amount: zod_1.z.number().positive(), content: zod_1.z.string().min(1), }); +const AlertNotificationSchema = zod_1.z.object({ + type: zod_1.z.enum(["alert_securite", "ALERT_SECURITE"]), + user: AlertContactSchema, + 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".', + .refine((value) => value !== "transfer" && + value !== "alert_securite" && + value !== "ALERT_SECURITE", { + message: 'Utiliser le schéma "transfer" ou "alert_securite" selon le type.', }), user: ContactSchema, content: zod_1.z.string().min(1), }); const NotificationBodySchema = zod_1.z.union([ TransferNotificationSchema, + AlertNotificationSchema, SimpleNotificationSchema, ]); const envoyerNotification = async (req, res) => { diff --git a/dist/controllers/optController.js b/dist/controllers/optController.js index ada808bc..4c6f4ae7 100644 --- a/dist/controllers/optController.js +++ b/dist/controllers/optController.js @@ -6,9 +6,7 @@ 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(), + utilisateurId: zod_1.z.string().min(1).optional(), phone: zod_1.z.string().min(8), }); const generateOtp = async (req, res) => { @@ -21,11 +19,9 @@ const generateOtp = async (req, res) => { 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); + const { phone } = parsed.data; + const utilisateurId = parsed.data.utilisateurId ?? phone; + const result = await otpService.createOtp(utilisateurId, Notification_1.CanalNotification.SMS, phone); res.json(result); } catch (error) { diff --git a/dist/entities/Notification.js b/dist/entities/Notification.js index ce62a83d..7bf88d27 100644 --- a/dist/entities/Notification.js +++ b/dist/entities/Notification.js @@ -13,7 +13,9 @@ exports.Notification = exports.StatutNotification = exports.CanalNotification = const typeorm_1 = require("typeorm"); var TypeNotification; (function (TypeNotification) { + // Existing types TypeNotification["CONFIRMATION_TRANSFERT"] = "CONFIRMATION_TRANSFERT"; + TypeNotification["CONFIRMATION_DEPOT"] = "CONFIRMATION_DEPOT"; TypeNotification["CONFIRMATION_RETRAIT"] = "CONFIRMATION_RETRAIT"; TypeNotification["RETRAIT_REUSSI"] = "RETRAIT_REUSSI"; TypeNotification["DEPOT_REUSSI"] = "DEPOT_REUSSI"; @@ -21,6 +23,58 @@ var TypeNotification; TypeNotification["VERIFICATION_KYC"] = "VERIFICATION_KYC"; TypeNotification["VERIFICATION_EMAIL"] = "VERIFICATION_EMAIL"; TypeNotification["VERIFICATION_TELEPHONE"] = "VERIFICATION_TELEPHONE"; + // 1. ADMIN MANAGEMENT + TypeNotification["ADMIN_CREE"] = "ADMIN_CREE"; + TypeNotification["ADMIN_MIS_A_JOUR"] = "ADMIN_MIS_A_JOUR"; + TypeNotification["ADMIN_SUPPRIME"] = "ADMIN_SUPPRIME"; + // 2. AGENT WORKFLOW + TypeNotification["AGENT_INSCRIPTION"] = "AGENT_INSCRIPTION"; + TypeNotification["AGENT_EN_ATTENTE_VALIDATION"] = "AGENT_EN_ATTENTE_VALIDATION"; + TypeNotification["AGENT_VALIDE"] = "AGENT_VALIDE"; + TypeNotification["AGENT_REJETE"] = "AGENT_REJETE"; + // 3. CLIENT + TypeNotification["CLIENT_INSCRIPTION"] = "CLIENT_INSCRIPTION"; + TypeNotification["CLIENT_COMPTE_ACTIF"] = "CLIENT_COMPTE_ACTIF"; + // 4. AUTHENTICATION AND SECURITY + TypeNotification["CONNEXION_REUSSIE"] = "CONNEXION_REUSSIE"; + TypeNotification["ECHEC_CONNEXION"] = "ECHEC_CONNEXION"; + TypeNotification["DECONNEXION"] = "DECONNEXION"; + TypeNotification["NOUVEL_APPAREIL"] = "NOUVEL_APPAREIL"; + TypeNotification["CHANGEMENT_MOT_DE_PASSE"] = "CHANGEMENT_MOT_DE_PASSE"; + TypeNotification["CHANGEMENT_EMAIL"] = "CHANGEMENT_EMAIL"; + TypeNotification["CHANGEMENT_TELEPHONE"] = "CHANGEMENT_TELEPHONE"; + TypeNotification["COMPTE_BLOQUE"] = "COMPTE_BLOQUE"; + TypeNotification["COMPTE_DEBLOQUE"] = "COMPTE_DEBLOQUE"; + // 5. TRANSACTIONS + TypeNotification["TRANSFERT_ENVOYE"] = "TRANSFERT_ENVOYE"; + TypeNotification["TRANSFERT_RECU"] = "TRANSFERT_RECU"; + TypeNotification["ECHEC_TRANSFERT"] = "ECHEC_TRANSFERT"; + TypeNotification["DEPOT_EN_COURS"] = "DEPOT_EN_COURS"; + TypeNotification["ECHEC_DEPOT"] = "ECHEC_DEPOT"; + TypeNotification["RETRAIT_EN_COURS"] = "RETRAIT_EN_COURS"; + TypeNotification["ECHEC_RETRAIT"] = "ECHEC_RETRAIT"; + // 6. OTP AND VERIFICATION + TypeNotification["OTP_ENVOYE"] = "OTP_ENVOYE"; + TypeNotification["OTP_VALIDE"] = "OTP_VALIDE"; + TypeNotification["OTP_EXPIRE"] = "OTP_EXPIRE"; + TypeNotification["OTP_INVALIDE"] = "OTP_INVALIDE"; + // 7. KYC + TypeNotification["KYC_EN_COURS"] = "KYC_EN_COURS"; + TypeNotification["KYC_VALIDE"] = "KYC_VALIDE"; + TypeNotification["KYC_REJETE"] = "KYC_REJETE"; + // 8. PAYMENT + TypeNotification["PAIEMENT_REUSSI"] = "PAIEMENT_REUSSI"; + TypeNotification["PAIEMENT_ECHOUE"] = "PAIEMENT_ECHOUE"; + TypeNotification["FACTURE_GENEREE"] = "FACTURE_GENEREE"; + TypeNotification["FACTURE_PAYEE"] = "FACTURE_PAYEE"; + // 9. FRAUD AND ALERTS + TypeNotification["TENTATIVE_FRAUDE"] = "TENTATIVE_FRAUDE"; + TypeNotification["TRANSACTION_SUSPECTE"] = "TRANSACTION_SUSPECTE"; + TypeNotification["ACTIVITE_INHABITUELLE"] = "ACTIVITE_INHABITUELLE"; + // 10. SYSTEM + TypeNotification["MAINTENANCE"] = "MAINTENANCE"; + TypeNotification["MISE_A_JOUR_SYSTEME"] = "MISE_A_JOUR_SYSTEME"; + TypeNotification["ANNONCE"] = "ANNONCE"; })(TypeNotification || (exports.TypeNotification = TypeNotification = {})); var CanalNotification; (function (CanalNotification) { diff --git a/dist/services/notificationService.js b/dist/services/notificationService.js index 8b3e658b..ed1d78bb 100644 --- a/dist/services/notificationService.js +++ b/dist/services/notificationService.js @@ -38,6 +38,10 @@ class NotificationService { // } // } mapStringToTypeNotification(type) { + const normalized = type.trim().toUpperCase(); + if (Object.values(Notification_1.TypeNotification).includes(normalized)) { + return normalized; + } switch (type) { case "transfer": return Notification_1.TypeNotification.CONFIRMATION_TRANSFERT; @@ -114,6 +118,58 @@ class NotificationService { email: notifEmail, }; } + async sendSmsPriorityToContact(contact, content, type, role, extraContext) { + const context = { ...(extraContext || {}), role }; + 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); + let notifEmail; + if (contact.email) { + 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, + ...(notifEmail ? { email: notifEmail } : {}), + }; + } /** * Endpoint HTTP (Postman) : * - dépend UNIQUEMENT des coordonnées fournies dans le JSON @@ -131,6 +187,15 @@ class NotificationService { receiver: receiverResult, }; } + if (payload.type === "alert_securite" || + payload.type === "ALERT_SECURITE") { + const alertPayload = payload; + const type = this.mapStringToTypeNotification(alertPayload.type); + const userResult = await this.sendSmsPriorityToContact(alertPayload.user, alertPayload.content, type, "USER"); + return { + user: userResult, + }; + } const simplePayload = payload; const type = this.mapStringToTypeNotification(simplePayload.type); const userResult = await this.sendMultiChannelToContact(simplePayload.user, simplePayload.content, type, "USER"); @@ -158,6 +223,63 @@ class NotificationService { if (!destinationEmail && !destinationPhone) { throw new Error(`Aucun contact (email ou téléphone) disponible pour l'utilisateur ${data.utilisateurId}`); } + // Priorité SMS pour les alertes sécurité: SMS d'abord si numéro disponible, + // puis email en second canal si disponible. + if (data.typeNotification === Notification_1.TypeNotification.ALERT_SECURITE) { + const context = data.context; + let smsNotif; + let emailNotif; + if (destinationPhone) { + smsNotif = this.notifRepo.create({ + utilisateurId: data.utilisateurId, + typeNotification: data.typeNotification, + canal: Notification_1.CanalNotification.SMS, + context, + message, + destinationPhone, + statut: Notification_1.StatutNotification.EN_COURS, + }); + await this.notifRepo.save(smsNotif); + try { + await client.messages.create({ + body: message, + from: process.env.TWILIO_PHONE_NUMBER, + to: destinationPhone, + }); + smsNotif.statut = Notification_1.StatutNotification.ENVOYEE; + } + catch (error) { + smsNotif.statut = Notification_1.StatutNotification.ECHEC; + console.error("Erreur d'envoi SMS :", error); + } + await this.notifRepo.save(smsNotif); + } + if (destinationEmail) { + emailNotif = this.notifRepo.create({ + utilisateurId: data.utilisateurId, + typeNotification: data.typeNotification, + canal: Notification_1.CanalNotification.EMAIL, + context, + message, + destinationEmail, + statut: Notification_1.StatutNotification.EN_COURS, + }); + await this.notifRepo.save(emailNotif); + try { + await (0, mailService_1.sendEmail)(destinationEmail, "RICASH NOTIFICATION", message); + emailNotif.statut = Notification_1.StatutNotification.ENVOYEE; + } + catch (error) { + emailNotif.statut = Notification_1.StatutNotification.ECHEC; + console.error("Erreur d'envoi email :", error); + } + await this.notifRepo.save(emailNotif); + } + return { + ...(smsNotif ? { sms: smsNotif } : {}), + ...(emailNotif ? { email: emailNotif } : {}), + }; + } // 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}`); diff --git a/dist/services/otpService.js b/dist/services/otpService.js index bcaa5645..8c31d1c0 100644 --- a/dist/services/otpService.js +++ b/dist/services/otpService.js @@ -13,31 +13,25 @@ class OtpService { generateCode() { return Math.floor(1000 + Math.random() * 9000).toString(); // 4chiffres } - async createOtp(utilisateurId, canalNotification, email, phone) { + async createOtp(utilisateurId, canalNotification, 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; + const notifType = 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", diff --git a/src/controllers/notificationController.ts b/src/controllers/notificationController.ts index e1ca8da6..3b1c7956 100644 --- a/src/controllers/notificationController.ts +++ b/src/controllers/notificationController.ts @@ -33,9 +33,16 @@ const SimpleNotificationSchema = z.object({ type: z .string() .min(1) - .refine((value) => value !== "transfer" && value !== "alert_securite" && value !== "ALERT_SECURITE", { - message: 'Utiliser le schéma "transfer" ou "alert_securite" selon le type.', - }), + .refine( + (value) => + value !== "transfer" && + value !== "alert_securite" && + value !== "ALERT_SECURITE", + { + message: + 'Utiliser le schéma "transfer" ou "alert_securite" selon le type.', + }, + ), user: ContactSchema, content: z.string().min(1), }); diff --git a/src/entities/Notification.ts b/src/entities/Notification.ts index 12c14fc4..ce463f55 100644 --- a/src/entities/Notification.ts +++ b/src/entities/Notification.ts @@ -6,7 +6,9 @@ import { } from "typeorm"; export enum TypeNotification { + // Existing types CONFIRMATION_TRANSFERT = "CONFIRMATION_TRANSFERT", + CONFIRMATION_DEPOT = "CONFIRMATION_DEPOT", CONFIRMATION_RETRAIT = "CONFIRMATION_RETRAIT", RETRAIT_REUSSI = "RETRAIT_REUSSI", DEPOT_REUSSI = "DEPOT_REUSSI", @@ -14,6 +16,68 @@ export enum TypeNotification { VERIFICATION_KYC = "VERIFICATION_KYC", VERIFICATION_EMAIL = "VERIFICATION_EMAIL", VERIFICATION_TELEPHONE = "VERIFICATION_TELEPHONE", + + // 1. ADMIN MANAGEMENT + ADMIN_CREE = "ADMIN_CREE", + ADMIN_MIS_A_JOUR = "ADMIN_MIS_A_JOUR", + ADMIN_SUPPRIME = "ADMIN_SUPPRIME", + + // 2. AGENT WORKFLOW + AGENT_INSCRIPTION = "AGENT_INSCRIPTION", + AGENT_EN_ATTENTE_VALIDATION = "AGENT_EN_ATTENTE_VALIDATION", + AGENT_VALIDE = "AGENT_VALIDE", + AGENT_REJETE = "AGENT_REJETE", + + // 3. CLIENT + CLIENT_INSCRIPTION = "CLIENT_INSCRIPTION", + CLIENT_COMPTE_ACTIF = "CLIENT_COMPTE_ACTIF", + + // 4. AUTHENTICATION AND SECURITY + CONNEXION_REUSSIE = "CONNEXION_REUSSIE", + ECHEC_CONNEXION = "ECHEC_CONNEXION", + DECONNEXION = "DECONNEXION", + NOUVEL_APPAREIL = "NOUVEL_APPAREIL", + CHANGEMENT_MOT_DE_PASSE = "CHANGEMENT_MOT_DE_PASSE", + CHANGEMENT_EMAIL = "CHANGEMENT_EMAIL", + CHANGEMENT_TELEPHONE = "CHANGEMENT_TELEPHONE", + COMPTE_BLOQUE = "COMPTE_BLOQUE", + COMPTE_DEBLOQUE = "COMPTE_DEBLOQUE", + + // 5. TRANSACTIONS + TRANSFERT_ENVOYE = "TRANSFERT_ENVOYE", + TRANSFERT_RECU = "TRANSFERT_RECU", + ECHEC_TRANSFERT = "ECHEC_TRANSFERT", + DEPOT_EN_COURS = "DEPOT_EN_COURS", + ECHEC_DEPOT = "ECHEC_DEPOT", + RETRAIT_EN_COURS = "RETRAIT_EN_COURS", + ECHEC_RETRAIT = "ECHEC_RETRAIT", + + // 6. OTP AND VERIFICATION + OTP_ENVOYE = "OTP_ENVOYE", + OTP_VALIDE = "OTP_VALIDE", + OTP_EXPIRE = "OTP_EXPIRE", + OTP_INVALIDE = "OTP_INVALIDE", + + // 7. KYC + KYC_EN_COURS = "KYC_EN_COURS", + KYC_VALIDE = "KYC_VALIDE", + KYC_REJETE = "KYC_REJETE", + + // 8. PAYMENT + PAIEMENT_REUSSI = "PAIEMENT_REUSSI", + PAIEMENT_ECHOUE = "PAIEMENT_ECHOUE", + FACTURE_GENEREE = "FACTURE_GENEREE", + FACTURE_PAYEE = "FACTURE_PAYEE", + + // 9. FRAUD AND ALERTS + TENTATIVE_FRAUDE = "TENTATIVE_FRAUDE", + TRANSACTION_SUSPECTE = "TRANSACTION_SUSPECTE", + ACTIVITE_INHABITUELLE = "ACTIVITE_INHABITUELLE", + + // 10. SYSTEM + MAINTENANCE = "MAINTENANCE", + MISE_A_JOUR_SYSTEME = "MISE_A_JOUR_SYSTEME", + ANNONCE = "ANNONCE", } export enum CanalNotification { diff --git a/src/messaging/contracts/interServices.ts b/src/messaging/contracts/interServices.ts index dc6ee1f7..b5521519 100644 --- a/src/messaging/contracts/interServices.ts +++ b/src/messaging/contracts/interServices.ts @@ -1,14 +1,8 @@ +import { TypeNotification } from "../../entities/Notification"; + export interface InterServices { utilisateurId: string; - typeNotification: - | "CONFIRMATION_TRANSFERT" - | "RETRAIT_REUSSI" - | "DEPOT_REUSSI" - | "ALERT_SECURITE" - | "CONFIRMATION_DEPOT" - | "VERIFICATION_EMAIL" - | "VERIFICATION_TELEPHONE" - | "VERIFICATION_KYC"; + typeNotification: TypeNotification; canal: "SMS" | "EMAIL" | "PUSH"; /** diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 5c7f762b..c290ed04 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -81,6 +81,12 @@ export class NotificationService { // } private mapStringToTypeNotification(type: string): TypeNotification { + const normalized = type.trim().toUpperCase(); + + if ((Object.values(TypeNotification) as string[]).includes(normalized)) { + return normalized as TypeNotification; + } + switch (type) { case "transfer": return TypeNotification.CONFIRMATION_TRANSFERT; @@ -270,7 +276,10 @@ export class NotificationService { }; } - if (payload.type === "alert_securite" || payload.type === "ALERT_SECURITE") { + if ( + payload.type === "alert_securite" || + payload.type === "ALERT_SECURITE" + ) { const alertPayload = payload as AlertNotificationDTO; const type = this.mapStringToTypeNotification(alertPayload.type); From cc2f948932f2938147836013b175cc46922b090d Mon Sep 17 00:00:00 2001 From: Manager Dayif Date: Tue, 24 Mar 2026 16:37:39 +0000 Subject: [PATCH 7/7] docs: update README.md with enhanced service overview, installation instructions, and notification types --- README.md | 595 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 345 insertions(+), 250 deletions(-) diff --git a/README.md b/README.md index 711a7027..a99f15a7 100644 --- a/README.md +++ b/README.md @@ -1,295 +1,390 @@ # Notification-Service -Ce projet implémente un **service de notifications** en **Node.js**, **Express** et **TypeScript**. -Il gère deux fonctionnalités principales : +Service de notifications (SMS, email, OTP) base sur Node.js, Express, TypeScript, PostgreSQL, RabbitMQ. -- La génération et la vérification d’OTP (codes à usage unique). -- L’envoi de notifications (par e-mail,SMS ou autres canaux). +## Vue d'ensemble ---- +Ce service expose des endpoints HTTP et consomme des evenements RabbitMQ pour envoyer des notifications. -## Fonctionnalités principales +Comportement cle actuel: -- 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. +- OTP generation: telephone only, canal SMS. +- Alertes securite: SMS prioritaire, email secondaire si disponible. +- Les types de notification sont centralises dans `TypeNotification`. ---- +## Prerequis -# Endpoints +- Node.js >= 18 +- npm +- PostgreSQL +- RabbitMQ +- Compte Twilio (SMS) +- Compte SMTP/Gmail (email) -Tous les endpoints sont accessibles sous :
-/api/notifications +## Installation -## Fonctionnalités principales +```bash +cd notification_service +npm install +npm run build +npm run dev +``` + +## Variables d'environnement + +### API + +- SERVICE_PORT (ex: 8000) +- SERVICE_VERSION (optionnel) +- COMMIT_SHA (optionnel) + +### PostgreSQL + +- DB_HOST +- DB_PORT (defaut 5432) +- DB_USER +- DB_PASSWORD +- DB_NAME + +### RabbitMQ + +- RABBITMQ_URL +- RABBITMQ_EXCHANGE +- RABBITMQ_QUEUE + +### SMS (Twilio) + +- TWILIO_ACCOUNT_SID +- TWILIO_AUTH_TOKEN +- TWILIO_PHONE_NUMBER + +### Email + +- MAIL_USER +- MAIL_PASS + +### Health + +- HEALTH_CHECK_TIMEOUT_MS (defaut 1000) +- HEALTH_CACHE_TTL_MS (defaut 5000) +- HEALTH_EXPOSE_ERRORS (defaut false) + +## Commandes -- 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.). +```bash +npm run dev +npm run build +npm start +``` + +## Base URL + +```text +http://{host}:{SERVICE_PORT} +``` ---- +## Endpoints -## Endpoints HTTP +### 1) Health liveness -Tous les endpoints HTTP exposés par ce service sont préfixés par : +- Methode: GET +- Route: /health -- `/api/notifications` +Reponse exemple: -### 1. Envoi d’une notification (HTTP direct) +```json +{ + "status": "OK", + "uptime": 123.45 +} +``` -`POST /api/notifications/envoyer` +### 2) Health readiness -Depuis la refonte, le service est **strictement dépendant des coordonnées fournies dans le JSON**. Deux formes sont possibles : +- Methode: GET +- Route: /health/ready -#### a) Notification de transfert +Reponse exemple: + +```json +{ + "status": "OK", + "uptime": 123.45, + "timestamp": "2026-03-24T10:00:00.000Z", + "version": "1.0.0", + "commit": "abc123", + "components": { + "db": { "status": "OK" }, + "rabbitmq": { "status": "OK" } + } +} +``` + +### 3) Envoi notification HTTP directe + +- Methode: POST +- Route: /api/notifications/envoyer + +#### Cas A: transfer (sender/receiver avec email + phone obligatoires) ```json { "type": "transfer", "sender": { - "email": "expediteur@mail.com", - "phone": "+22300000000" + "email": "sender@example.com", + "phone": "+22370000001" }, "receiver": { - "email": "destinataire@mail.com", - "phone": "+22311111111" + "email": "receiver@example.com", + "phone": "+22370000002" }, "amount": 5000, - "content": "Transfert de 5000 FCFA réussi." + "content": "Transfert de 5000 FCFA effectue" +} +``` + +#### Cas B: alert_securite (SMS prioritaire, email optionnel) + +```json +{ + "type": "alert_securite", + "user": { + "phone": "+22370000003", + "email": "client@example.com" + }, + "content": "Tentative de connexion suspecte detectee" +} +``` + +#### Cas C: autre type simple (schema standard) + +Note: pour les types simples hors transfer et alert_securite, `user.email` et `user.phone` sont attendus. + +```json +{ + "type": "CLIENT_COMPTE_ACTIF", + "user": { + "email": "client@example.com", + "phone": "+22370000004" + }, + "content": "Votre compte est desormais actif" +} +``` + +### 4) Liste des notifications + +- Methode: GET +- Route: /api/notifications + +Reponse exemple: + +```json +[ + { + "id": "0f4c...", + "utilisateurId": "user-123", + "typeNotification": "ALERT_SECURITE", + "canal": "SMS", + "message": "Alerte securite...", + "statut": "ENVOYEE", + "dateEnvoi": "2026-03-24T10:00:00.000Z" + } +] +``` + +### 5) Publication test RabbitMQ + +- Methode: POST +- Route: /api/notifications/rabbitmq + +Request exemple: + +```json +{ + "routingKey": "notification.process", + "message": { + "utilisateurId": "user-123", + "typeNotification": "ALERT_SECURITE", + "canal": "SMS", + "phone": "+22370000005" + } +} +``` + +Reponse exemple: + +```json +{ + "success": true +} +``` + +### 6) OTP generate + +- Methode: POST +- Route: /api/notifications/otp/generate +- Regle actuelle: telephone only, SMS only. + +Request exemple minimal: + +```json +{ + "phone": "+22370000006" } ``` -- Le schéma Zod impose : - - `type` = `"transfer"`. - - `sender.email` / `sender.phone` obligatoires. +Request exemple avec utilisateurId: + +```json +{ + "utilisateurId": "pre-user-001", + "phone": "+22370000006" +} +``` + +Reponse exemple: + +```json +{ + "success": true, + "message": "OTP envoye", + "expiration": "2026-03-24T10:05:00.000Z" +} +``` + +### 7) OTP verify + +- Methode: POST +- Route: /api/notifications/otp/verify + +Request exemple: + +```json +{ + "utilisateurId": "pre-user-001", + "code": "1234" +} +``` + +Reponse exemple: + +```json +{ + "success": true, + "message": "OTP valide" +} +``` + +## RabbitMQ inter-services + +Message type attendu pour notification inter-service: + +```json +{ + "utilisateurId": "user-123", + "typeNotification": "ALERT_SECURITE", + "canal": "SMS", + "email": "client@example.com", + "phone": "+22370000007", + "context": { + "reason": "multiple_failed_pin_attempts" + }, + "metadata": { + "service": "wallet-service", + "correlationId": "evt-123" + } +} +``` + +Regle importante: + +- Pour `ALERT_SECURITE`, le service applique une priorite SMS quand un numero est present, puis envoi email si adresse disponible. + +## Types de notification disponibles + +### 1. Gestion admin + +- ADMIN_CREE +- ADMIN_MIS_A_JOUR +- ADMIN_SUPPRIME + +### 2. Agent + +- AGENT_INSCRIPTION +- AGENT_EN_ATTENTE_VALIDATION +- AGENT_VALIDE +- AGENT_REJETE + +### 3. Client + +- CLIENT_INSCRIPTION +- CLIENT_COMPTE_ACTIF + +### 4. Authentification et securite + +- CONNEXION_REUSSIE +- ECHEC_CONNEXION +- DECONNEXION +- NOUVEL_APPAREIL +- CHANGEMENT_MOT_DE_PASSE +- CHANGEMENT_EMAIL +- CHANGEMENT_TELEPHONE +- COMPTE_BLOQUE +- COMPTE_DEBLOQUE +- ALERT_SECURITE - # Notification-Service +### 5. Transactions - Service de notifications (e-mail & SMS & OTP) développé en Node.js, Express et TypeScript. +- CONFIRMATION_TRANSFERT +- CONFIRMATION_DEPOT +- CONFIRMATION_RETRAIT +- TRANSFERT_ENVOYE +- TRANSFERT_RECU +- ECHEC_TRANSFERT +- DEPOT_EN_COURS +- DEPOT_REUSSI +- ECHEC_DEPOT +- RETRAIT_EN_COURS +- RETRAIT_REUSSI +- ECHEC_RETRAIT - Ce README décrit l'installation, la configuration, les endpoints, les variables d'environnement et les bonnes pratiques pour déployer et tester le service. +### 6. OTP et verification - Table des matières - - Présentation - - Prérequis - - Installation - - Variables d'environnement - - Commandes utiles - - Endpoints et exemples - - Health checks - - Docker / Compose - - Débogage et logs - - Notes de sécurité +- OTP_ENVOYE +- OTP_VALIDE +- OTP_EXPIRE +- OTP_INVALIDE +- VERIFICATION_EMAIL +- VERIFICATION_TELEPHONE - *** +### 7. KYC - ## Présentation +- KYC_EN_COURS +- KYC_VALIDE +- KYC_REJETE +- VERIFICATION_KYC - Ce service reçoit des requêtes HTTP pour envoyer des notifications et générer/vérifier des OTP. Il s'intègre avec : - - PostgreSQL (TypeORM) - - RabbitMQ (échange partagé, queue privée) - - Twilio (SMS) - - Nodemailer (e-mail) +### 8. Paiement - Le code organise les responsabilités en contrôleurs, services, entités, utilitaires et messaging (publisher/consumer). +- PAIEMENT_REUSSI +- PAIEMENT_ECHOUE +- FACTURE_GENEREE +- FACTURE_PAYEE - ## Prérequis - - Node.js >= 18 - - npm - - PostgreSQL accessible (ou instance locale) - - RabbitMQ accessible (ou instance locale) - - Compte Twilio (si SMS en production) ou configuration de mock - - Compte e-mail (Gmail ou SMTP compatible) pour envoi d'e-mails +### 9. Fraude et alertes - ## Installation - 1. Cloner le dépôt et positionnez-vous dans le dossier du service : +- TENTATIVE_FRAUDE +- TRANSACTION_SUSPECTE +- ACTIVITE_INHABITUELLE - ```bash - cd notification_service - ``` +### 10. Systeme - 2. Installer les dépendances : - - ```bash - npm install - ``` - - 3. Compiler TypeScript : - - ```bash - npm run build - ``` - - 4. Lancer en développement (reload automatique) : - - ```bash - npm run dev - ``` - - ## Variables d'environnement - - Les variables attendues par le service (fichier `.env` recommandé) : - - SERVICE_PORT: port d'écoute HTTP (ex: 8000) - - SERVICE_VERSION: version déployée (optionnel) - - COMMIT_SHA: sha du commit déployé (optionnel) +- MAINTENANCE +- MISE_A_JOUR_SYSTEME +- ANNONCE - - PostgreSQL: - - DB_HOST - - DB_PORT (par défaut 5432) - - DB_USER - - DB_PASSWORD - - DB_NAME +## Notes d'exploitation - - RabbitMQ: - - RABBITMQ_URL (ex: amqp://user:pass@host:5672) - - RABBITMQ_EXCHANGE (nom de l'exchange partagé) - - RABBITMQ_QUEUE (nom de la queue principale pour ce service) - - - Twilio (si SMS) : - - TWILIO_ACCOUNT_SID - - TWILIO_AUTH_TOKEN - - TWILIO_PHONE_NUMBER - - - E-mail (Nodemailer) : - - MAIL_USER - - MAIL_PASS - - - Health / diagnostics (optionnel) : - - HEALTH_CHECK_TIMEOUT_MS (ms, défaut 1000) - - HEALTH_CACHE_TTL_MS (ms, défaut 5000) - - HEALTH_EXPOSE_ERRORS (true|false, défaut false) - - ## Commandes utiles - - `npm run dev` — démarre avec `ts-node-dev` (dev hot-reload) - - `npm run build` — compile TypeScript vers `dist/` - - `npm start` — exécute `node src/server.ts` (production si compilé) - - ## Endpoints et exemples - - Base URL: `http://{host}:{SERVICE_PORT}` - - Health - - `GET /health` — liveness minimal (retourne OK + uptime) - - `GET /health/ready` — readiness : vérifie PostgreSQL et RabbitMQ, retourne 200 ou 503. Réponse contient `components.db` et `components.rabbitmq`. - - Notifications - - `POST /api/notifications/envoyer` — envoie une notification. - - Corps possible (exemples) : - - Transfer (expéditeur + destinataire envoyés sur SMS + email si fournis) : - - ```json - { - "type": "transfer", - "sender": { "email": "a@ex.com", "phone": "+223xxxxxxxx" }, - "receiver": { "email": "b@ex.com", "phone": "+223yyyyyyyy" }, - "amount": 10000, - "content": "Votre transfert de 10000 F CFA a été effectué" - } - ``` - - Simple notification : - - ```json - { - "type": "alert_securite", - "user": { "email": "u@ex.com", "phone": "+223zzzzzzzz" }, - "content": "Un événement important a eu lieu" - } - ``` - - - Réponse : `201` + objet décrivant les enregistrements créés (sms / email) - - - `POST /api/notifications/rabbitmq` — endpoint de test qui publie un message sur RabbitMQ (routingKey/message dans body) - - OTP - - `POST /api/notifications/otp/generate` — génère un OTP - - Body example: - ```json - { - "utilisateurId": "user-123", - "canalNotification": "SMS", - "phone": "+223..." - } - ``` - - - `POST /api/notifications/otp/verify` — vérifie un OTP - - Body example: - ```json - { "utilisateurId": "user-123", "code": "1234" } - ``` - - ## Health checks (détails) - - `/health` est une probe de liveness simple, utile pour Kubernetes readiness/liveness probes basiques. - - `/health/ready` exécute des vérifications actives : - - exécute `SELECT 1` sur PostgreSQL (avec timeout configurable) - - vérifie que le channel RabbitMQ est initialisé - - met en cache le résultat pendant `HEALTH_CACHE_TTL_MS` pour limiter la charge - - renvoie `version` et `commit` si disponibles - - ## Docker / Compose - - Le repo contient un `Dockerfile` et un `docker-compose.yml` : - - Construction : - - ```bash - docker build -t ricash/notification-service:latest . - ``` - - Compose (exemple très simple) : - - ```yaml - version: "3.8" - services: - notification-service: - image: ricash/notification-service:latest - env_file: .env - ports: - - "8000:8000" - depends_on: - - db - - rabbitmq - - db: - image: postgres:15 - environment: - POSTGRES_USER: example - POSTGRES_PASSWORD: example - POSTGRES_DB: ricash - - rabbitmq: - image: rabbitmq:3-management - ports: - - "5672:5672" - - "15672:15672" - ``` - - ## Débogage et logs - - Les logs sont écrits sur stdout. - - Vérifier les erreurs de connexion à RabbitMQ et PostgreSQL au démarrage. - - En cas d'erreurs d'envoi SMS/Email, les exceptions sont loggées et le statut de la notification est mis à `ECHEC`. - - ## Sécurité et bonnes pratiques - - Ne pas exposer `HEALTH_EXPOSE_ERRORS=true` en production si les messages d'erreur contiennent des données sensibles. - - Utiliser des secrets manager pour les identifiants (DB, Twilio, MAIL_PASS). - - Désactiver `synchronize: true` (TypeORM) en production et utiliser des migrations contrôlées. - - ## Contribution - - Pour proposer des améliorations : - 1. Créer une branche feature - 2. Ajouter tests / valider localement - 3. Ouvrir une Pull Request vers `develop` - - ## Support - - Si tu veux, je peux : - - ajouter des exemples Postman - - créer un `docker-compose.dev.yml` complet pour démarrer la stack locale - - ajouter des tests unitaires pour `NotificationService` / `OtpService` - - *** - - Fait avec ❤️ — Notification-Service +- En cas d'erreur SMS/email, le statut est marque ECHEC. +- Pour la production, preferer des migrations TypeORM controlees plutot que synchronize. +- Eviter d'exposer les erreurs internes du health endpoint en production.