diff --git a/README.md b/README.md index 250f883..711a702 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 diff --git a/dist/routes/health.js b/dist/routes/health.js index 45cbd02..e251fa3 100644 --- a/dist/routes/health.js +++ b/dist/routes/health.js @@ -1,7 +1,9 @@ "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")); @@ -10,87 +12,100 @@ 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"); - } - // 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 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 code = result.status === "OK" ? 200 : 503; - readinessCache = { ts: now, result, code }; - res.status(code).json(result); + // 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); }); exports.default = router; diff --git a/src/routes/health.ts b/src/routes/health.ts index da6d1ab..62f5167 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,4 +1,5 @@ import express, { type Request, type Response } from "express"; + import fs from "fs"; import path from "path"; import { getRabbitChannel } from "../config/rabbitmq";