diff --git a/.env.development b/.env.development index c606e46..77ee45a 100644 --- a/.env.development +++ b/.env.development @@ -3,7 +3,53 @@ PORT=3000 NODE_ENV=development LOG_LEVEL=debug -POSTGRES_USER= -POSTGRES_PASSWORD= -POSTGRES_DB= -DATABASE_URL= \ No newline at end of file +# Database Configuration +POSTGRES_USER=chainremit_dev +POSTGRES_PASSWORD=dev_password_123 +POSTGRES_DB=chainremit_dev +DATABASE_URL=postgresql://chainremit_dev:dev_password_123@localhost:5432/chainremit_dev + +# JWT Configuration +JWT_SECRET=dev-jwt-secret-key-not-for-production-use +JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=dev-refresh-jwt-secret-key-not-for-production-use +JWT_REFRESH_EXPIRES_IN=30d + +# Redis Configuration (for notifications queue) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=1 + +# Email Configuration (SendGrid) - Test mode +SENDGRID_API_KEY=SG.test_key_for_development_only +SENDGRID_FROM_EMAIL=dev@chainremit.local +SENDGRID_FROM_NAME=ChainRemit Dev + +# SMS Configuration (Twilio) - Test mode +TWILIO_ACCOUNT_SID=ACtest1234567890123456789012345678 +TWILIO_AUTH_TOKEN=test_auth_token_for_development +TWILIO_PHONE_NUMBER=+15551234567 + +# Push Notification Configuration (Firebase) - Test mode +FIREBASE_PROJECT_ID=chainremit-dev +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\ntest_private_key_for_development\n-----END PRIVATE KEY-----\n" +FIREBASE_CLIENT_EMAIL=firebase-adminsdk-test@chainremit-dev.iam.gserviceaccount.com +FIREBASE_DATABASE_URL=https://chainremit-dev-default-rtdb.firebaseio.com + +# Rate Limiting - More lenient for development +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=1000 + +# CORS Configuration - Allow all for development +CORS_ORIGIN=* + +# Admin Configuration +ADMIN_EMAIL_DOMAINS=chainremit.local,dev.chainremit.com +SUPER_ADMIN_USER_IDS=dev-admin-1,dev-admin-2 + +# Notification System Configuration - Development settings +NOTIFICATION_RETRY_ATTEMPTS=2 +NOTIFICATION_RETRY_DELAY=3000 +NOTIFICATION_BATCH_SIZE=10 +NOTIFICATION_RATE_LIMIT=50 \ No newline at end of file diff --git a/.env.example b/.env.example index 7932fb5..50d39a9 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,54 @@ PORT=3000 NODE_ENV=development LOG_LEVEL=info + +# Database Configuration POSTGRES_USER=your_username POSTGRES_PASSWORD=your_password POSTGRES_DB=your_database -DATABASE_URL=postgresql://your_username:your_password@localhost:5432/your_database \ No newline at end of file +DATABASE_URL=postgresql://your_username:your_password@localhost:5432/your_database + +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key-here-change-in-production +JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=your-super-secret-refresh-jwt-key-here-change-in-production +JWT_REFRESH_EXPIRES_IN=30d + +# Redis Configuration (for notifications queue) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Email Configuration (SendGrid) +SENDGRID_API_KEY=SG.your_sendgrid_api_key_here +SENDGRID_FROM_EMAIL=noreply@chainremit.com +SENDGRID_FROM_NAME=ChainRemit + +# SMS Configuration (Twilio) +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_twilio_auth_token_here +TWILIO_PHONE_NUMBER=+1234567890 + +# Push Notification Configuration (Firebase) +FIREBASE_PROJECT_ID=your-firebase-project-id +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nyour_firebase_private_key_here\n-----END PRIVATE KEY-----\n" +FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@your-project.iam.gserviceaccount.com +FIREBASE_DATABASE_URL=https://your-project-default-rtdb.firebaseio.com + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 + +# CORS Configuration +CORS_ORIGIN=http://localhost:3000,http://localhost:3001 + +# Admin Configuration +ADMIN_EMAIL_DOMAINS=chainremit.com,admin.chainremit.com +SUPER_ADMIN_USER_IDS=admin-user-1,admin-user-2 + +# Notification System Configuration +NOTIFICATION_RETRY_ATTEMPTS=3 +NOTIFICATION_RETRY_DELAY=5000 +NOTIFICATION_BATCH_SIZE=50 +NOTIFICATION_RATE_LIMIT=100 \ No newline at end of file diff --git a/.env.production b/.env.production index 96464e3..21fea4d 100644 --- a/.env.production +++ b/.env.production @@ -1,7 +1,55 @@ # Production Environment Variables PORT=80 NODE_ENV=production -LOG_LEVEL=info -POSTGRES_USER=prod_username -POSTGRES_PASSWORD=prod_password -POSTGRES_DB=prod_database \ No newline at end of file +LOG_LEVEL=warn + +# Database Configuration +POSTGRES_USER=chainremit_prod +POSTGRES_PASSWORD=CHANGE_THIS_SECURE_PASSWORD_IN_PRODUCTION +POSTGRES_DB=chainremit_production +DATABASE_URL=postgresql://chainremit_prod:CHANGE_THIS_SECURE_PASSWORD_IN_PRODUCTION@prod-db-host:5432/chainremit_production + +# JWT Configuration +JWT_SECRET=CHANGE_THIS_SUPER_SECURE_JWT_SECRET_IN_PRODUCTION +JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=CHANGE_THIS_SUPER_SECURE_REFRESH_JWT_SECRET_IN_PRODUCTION +JWT_REFRESH_EXPIRES_IN=30d + +# Redis Configuration (for notifications queue) +REDIS_HOST=prod-redis-host +REDIS_PORT=6379 +REDIS_PASSWORD=CHANGE_THIS_REDIS_PASSWORD_IN_PRODUCTION +REDIS_DB=0 + +# Email Configuration (SendGrid) - Production +SENDGRID_API_KEY=SG.CHANGE_THIS_TO_YOUR_PRODUCTION_SENDGRID_KEY +SENDGRID_FROM_EMAIL=noreply@chainremit.com +SENDGRID_FROM_NAME=ChainRemit + +# SMS Configuration (Twilio) - Production +TWILIO_ACCOUNT_SID=CHANGE_THIS_TO_YOUR_PRODUCTION_TWILIO_SID +TWILIO_AUTH_TOKEN=CHANGE_THIS_TO_YOUR_PRODUCTION_TWILIO_TOKEN +TWILIO_PHONE_NUMBER=CHANGE_THIS_TO_YOUR_PRODUCTION_PHONE_NUMBER + +# Push Notification Configuration (Firebase) - Production +FIREBASE_PROJECT_ID=chainremit-production +FIREBASE_PRIVATE_KEY="CHANGE_THIS_TO_YOUR_PRODUCTION_FIREBASE_PRIVATE_KEY" +FIREBASE_CLIENT_EMAIL=CHANGE_THIS_TO_YOUR_PRODUCTION_FIREBASE_CLIENT_EMAIL +FIREBASE_DATABASE_URL=https://chainremit-production-default-rtdb.firebaseio.com + +# Rate Limiting - Production settings +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 + +# CORS Configuration - Specific origins for production +CORS_ORIGIN=https://chainremit.com,https://app.chainremit.com + +# Admin Configuration +ADMIN_EMAIL_DOMAINS=chainremit.com +SUPER_ADMIN_USER_IDS=CHANGE_THIS_TO_PRODUCTION_ADMIN_USER_IDS + +# Notification System Configuration - Production settings +NOTIFICATION_RETRY_ATTEMPTS=3 +NOTIFICATION_RETRY_DELAY=5000 +NOTIFICATION_BATCH_SIZE=50 +NOTIFICATION_RATE_LIMIT=100 \ No newline at end of file diff --git a/coverage/lcov-report/config/config.ts.html b/coverage/lcov-report/config/config.ts.html new file mode 100644 index 0000000..e464d71 --- /dev/null +++ b/coverage/lcov-report/config/config.ts.html @@ -0,0 +1,238 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 | 1x +1x + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import dotenv from 'dotenv';
+dotenv.config();
+
+export const config = {
+ jwt: {
+ accessSecret: process.env.JWT_ACCESS_SECRET || 'default-access-secret',
+ refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
+ accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
+ refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
+ },
+ email: {
+ sendgridApiKey: process.env.SENDGRID_API_KEY,
+ fromEmail: process.env.FROM_EMAIL || 'noreply@chainremit.com',
+ fromName: process.env.FROM_NAME || 'ChainRemit',
+ },
+ sms: {
+ twilioAccountSid: process.env.TWILIO_ACCOUNT_SID,
+ twilioAuthToken: process.env.TWILIO_AUTH_TOKEN,
+ twilioPhoneNumber: process.env.TWILIO_PHONE_NUMBER,
+ },
+ push: {
+ firebaseServerKey: process.env.FIREBASE_SERVER_KEY,
+ firebaseDatabaseUrl: process.env.FIREBASE_DATABASE_URL,
+ firebaseProjectId: process.env.FIREBASE_PROJECT_ID,
+ },
+ redis: {
+ host: process.env.REDIS_HOST || 'localhost',
+ port: parseInt(process.env.REDIS_PORT || '6379'),
+ password: process.env.REDIS_PASSWORD,
+ },
+ oauth: {
+ google: {
+ clientId: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ },
+ apple: {
+ clientId: process.env.APPLE_CLIENT_ID,
+ teamId: process.env.APPLE_TEAM_ID,
+ keyId: process.env.APPLE_KEY_ID,
+ privateKey: process.env.APPLE_PRIVATE_KEY,
+ },
+ },
+ notification: {
+ maxRetries: parseInt(process.env.NOTIFICATION_MAX_RETRIES || '3'),
+ retryDelay: parseInt(process.env.NOTIFICATION_RETRY_DELAY || '5000'),
+ batchSize: parseInt(process.env.NOTIFICATION_BATCH_SIZE || '100'),
+ },
+ app: {
+ baseUrl: process.env.BASE_URL || 'http://localhost:3000',
+ },
+};
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| config.ts | +
+
+ |
+ 100% | +3/3 | +100% | +24/24 | +100% | +0/0 | +100% | +3/3 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| notification.controller.ts | +
+
+ |
+ 8.58% | +20/233 | +0% | +0/145 | +0% | +0/22 | +8.77% | +20/228 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 | +1x +1x + +1x +1x +1x +1x + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { Request, Response, NextFunction } from 'express';
+import { NotificationService } from '../services/notification.service';
+import { QueueService } from '../services/queue.service';
+import { AuthRequest } from '../middleware/auth.middleware';
+import { ErrorResponse } from '../utils/errorResponse';
+import { asyncHandler } from '../middleware/async.middleware';
+import logger from '../utils/logger';
+import {
+ NotificationType,
+ NotificationChannel,
+ NotificationPriority,
+ SendNotificationRequest,
+ NotificationPreferencesRequest,
+} from '../types/notification.types';
+
+/**
+ * @description Send notification to user
+ * @route POST /api/notifications/send
+ * @access Private
+ */
+export const sendNotification = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const { userId, type, channels, data, priority, scheduledAt } = req.body;
+
+ // Validate required fields
+ Iif (!userId || !type || !data) {
+ return next(new ErrorResponse('userId, type, and data are required', 400));
+ }
+
+ // Validate notification type
+ Iif (!Object.values(NotificationType).includes(type)) {
+ return next(new ErrorResponse('Invalid notification type', 400));
+ }
+
+ // Validate channels if provided
+ Iif (channels && !Array.isArray(channels)) {
+ return next(new ErrorResponse('Channels must be an array', 400));
+ }
+
+ Iif (
+ channels &&
+ !channels.every((channel: string) =>
+ Object.values(NotificationChannel).includes(channel as NotificationChannel),
+ )
+ ) {
+ return next(new ErrorResponse('Invalid notification channel', 400));
+ }
+
+ // Validate priority if provided
+ Iif (priority && !Object.values(NotificationPriority).includes(priority)) {
+ return next(new ErrorResponse('Invalid notification priority', 400));
+ }
+
+ // Validate scheduledAt if provided
+ let scheduledDate: Date | undefined;
+ Iif (scheduledAt) {
+ scheduledDate = new Date(scheduledAt);
+ Iif (isNaN(scheduledDate.getTime())) {
+ return next(new ErrorResponse('Invalid scheduledAt date format', 400));
+ }
+ Iif (scheduledDate <= new Date()) {
+ return next(new ErrorResponse('scheduledAt must be in the future', 400));
+ }
+ }
+
+ try {
+ const request: SendNotificationRequest = {
+ userId,
+ type,
+ channels: channels || undefined,
+ data,
+ priority: priority || NotificationPriority.NORMAL,
+ scheduledAt: scheduledDate,
+ };
+
+ const result = await NotificationService.sendNotification(request);
+
+ logger.info('Notification send request processed', {
+ userId,
+ type,
+ jobIds: result.jobIds,
+ success: result.success,
+ });
+
+ res.status(200).json({
+ success: true,
+ data: result,
+ });
+ } catch (error) {
+ logger.error('Failed to send notification', {
+ userId,
+ type,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to send notification', 500));
+ }
+ },
+);
+
+/**
+ * @description Send bulk notifications
+ * @route POST /api/notifications/send-bulk
+ * @access Private (Admin only)
+ */
+export const sendBulkNotifications = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const { notifications } = req.body;
+
+ Iif (!Array.isArray(notifications) || notifications.length === 0) {
+ return next(
+ new ErrorResponse('notifications array is required and must not be empty', 400),
+ );
+ }
+
+ Iif (notifications.length > 1000) {
+ return next(new ErrorResponse('Maximum 1000 notifications per bulk request', 400));
+ }
+
+ // Validate each notification request
+ for (const [index, notification] of notifications.entries()) {
+ Iif (!notification.userId || !notification.type || !notification.data) {
+ return next(
+ new ErrorResponse(
+ `Invalid notification at index ${index}: userId, type, and data are required`,
+ 400,
+ ),
+ );
+ }
+
+ Iif (!Object.values(NotificationType).includes(notification.type)) {
+ return next(new ErrorResponse(`Invalid notification type at index ${index}`, 400));
+ }
+ }
+
+ try {
+ const results = await NotificationService.sendBulkNotifications(notifications);
+
+ const successCount = results.filter((r) => r.success).length;
+ const failureCount = results.length - successCount;
+
+ logger.info('Bulk notifications processed', {
+ total: notifications.length,
+ success: successCount,
+ failed: failureCount,
+ });
+
+ res.status(200).json({
+ success: true,
+ data: {
+ results,
+ summary: {
+ total: notifications.length,
+ success: successCount,
+ failed: failureCount,
+ },
+ },
+ });
+ } catch (error) {
+ logger.error('Failed to send bulk notifications', {
+ count: notifications.length,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to send bulk notifications', 500));
+ }
+ },
+);
+
+/**
+ * @description Get user notification preferences
+ * @route GET /api/notifications/preferences
+ * @access Private
+ */
+export const getNotificationPreferences = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const userId = req.userId!;
+
+ try {
+ let preferences = await NotificationService.getUserPreferences(userId);
+
+ // Create default preferences if none exist
+ Iif (!preferences) {
+ const { notificationDb } = await import('../model/notification.model');
+ preferences = await notificationDb.createDefaultPreferences(userId);
+ }
+
+ res.status(200).json({
+ success: true,
+ data: preferences,
+ });
+ } catch (error) {
+ logger.error('Failed to get notification preferences', {
+ userId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to get notification preferences', 500));
+ }
+ },
+);
+
+/**
+ * @description Update user notification preferences
+ * @route PUT /api/notifications/preferences
+ * @access Private
+ */
+export const updateNotificationPreferences = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const userId = req.userId!;
+ const updates: NotificationPreferencesRequest = req.body;
+
+ // Validate the update structure
+ Iif (typeof updates !== 'object' || updates === null) {
+ return next(new ErrorResponse('Invalid preferences format', 400));
+ }
+
+ // Validate email preferences if provided
+ Iif (updates.email) {
+ const validEmailKeys = [
+ 'enabled',
+ 'transactionUpdates',
+ 'securityAlerts',
+ 'marketingEmails',
+ 'systemNotifications',
+ ];
+ for (const key of Object.keys(updates.email)) {
+ Iif (!validEmailKeys.includes(key)) {
+ return next(new ErrorResponse(`Invalid email preference key: ${key}`, 400));
+ }
+ Iif (typeof (updates.email as any)[key] !== 'boolean') {
+ return next(new ErrorResponse(`Email preference ${key} must be boolean`, 400));
+ }
+ }
+ }
+
+ // Validate SMS preferences if provided
+ Iif (updates.sms) {
+ const validSmsKeys = [
+ 'enabled',
+ 'transactionUpdates',
+ 'securityAlerts',
+ 'criticalAlerts',
+ ];
+ for (const key of Object.keys(updates.sms)) {
+ Iif (!validSmsKeys.includes(key)) {
+ return next(new ErrorResponse(`Invalid SMS preference key: ${key}`, 400));
+ }
+ Iif (typeof (updates.sms as any)[key] !== 'boolean') {
+ return next(new ErrorResponse(`SMS preference ${key} must be boolean`, 400));
+ }
+ }
+ }
+
+ // Validate push preferences if provided
+ Iif (updates.push) {
+ const validPushKeys = [
+ 'enabled',
+ 'transactionUpdates',
+ 'securityAlerts',
+ 'marketingUpdates',
+ 'systemNotifications',
+ ];
+ for (const key of Object.keys(updates.push)) {
+ Iif (!validPushKeys.includes(key)) {
+ return next(new ErrorResponse(`Invalid push preference key: ${key}`, 400));
+ }
+ Iif (typeof (updates.push as any)[key] !== 'boolean') {
+ return next(new ErrorResponse(`Push preference ${key} must be boolean`, 400));
+ }
+ }
+ }
+
+ try {
+ const updatedPreferences = await NotificationService.updateUserPreferences(
+ userId,
+ updates as any,
+ );
+
+ Iif (!updatedPreferences) {
+ return next(new ErrorResponse('Failed to update preferences', 500));
+ }
+
+ logger.info('Notification preferences updated', {
+ userId,
+ updates: Object.keys(updates),
+ });
+
+ res.status(200).json({
+ success: true,
+ data: updatedPreferences,
+ });
+ } catch (error) {
+ logger.error('Failed to update notification preferences', {
+ userId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to update notification preferences', 500));
+ }
+ },
+);
+
+/**
+ * @description Get user notification history
+ * @route GET /api/notifications/history
+ * @access Private
+ */
+export const getNotificationHistory = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const userId = req.userId!;
+ const { limit = '50', offset = '0', type, channel, status } = req.query;
+
+ // Validate query parameters
+ const limitNum = parseInt(limit as string, 10);
+ const offsetNum = parseInt(offset as string, 10);
+
+ Iif (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
+ return next(new ErrorResponse('Limit must be between 1 and 100', 400));
+ }
+
+ Iif (isNaN(offsetNum) || offsetNum < 0) {
+ return next(new ErrorResponse('Offset must be a non-negative number', 400));
+ }
+
+ // Validate type filter if provided
+ Iif (type && !Object.values(NotificationType).includes(type as NotificationType)) {
+ return next(new ErrorResponse('Invalid notification type filter', 400));
+ }
+
+ // Validate channel filter if provided
+ Iif (
+ channel &&
+ !Object.values(NotificationChannel).includes(channel as NotificationChannel)
+ ) {
+ return next(new ErrorResponse('Invalid notification channel filter', 400));
+ }
+
+ try {
+ let history = await NotificationService.getUserNotificationHistory(
+ userId,
+ limitNum,
+ offsetNum,
+ );
+
+ // Apply filters
+ Iif (type) {
+ history = history.filter((h) => h.type === type);
+ }
+
+ Iif (channel) {
+ history = history.filter((h) => h.channel === channel);
+ }
+
+ Iif (status) {
+ history = history.filter((h) => h.status === status);
+ }
+
+ res.status(200).json({
+ success: true,
+ data: {
+ history,
+ pagination: {
+ limit: limitNum,
+ offset: offsetNum,
+ total: history.length,
+ },
+ },
+ });
+ } catch (error) {
+ logger.error('Failed to get notification history', {
+ userId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to get notification history', 500));
+ }
+ },
+);
+
+/**
+ * @description Get notification analytics
+ * @route GET /api/notifications/analytics
+ * @access Private (Admin only)
+ */
+export const getNotificationAnalytics = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const { startDate, endDate, userId } = req.query;
+
+ let start: Date | undefined;
+ let end: Date | undefined;
+
+ // Validate date parameters
+ Iif (startDate) {
+ start = new Date(startDate as string);
+ Iif (isNaN(start.getTime())) {
+ return next(new ErrorResponse('Invalid startDate format', 400));
+ }
+ }
+
+ Iif (endDate) {
+ end = new Date(endDate as string);
+ Iif (isNaN(end.getTime())) {
+ return next(new ErrorResponse('Invalid endDate format', 400));
+ }
+ }
+
+ Iif (start && end && start >= end) {
+ return next(new ErrorResponse('startDate must be before endDate', 400));
+ }
+
+ try {
+ const analytics = await NotificationService.getAnalytics(start, end, userId as string);
+
+ res.status(200).json({
+ success: true,
+ data: analytics,
+ });
+ } catch (error) {
+ logger.error('Failed to get notification analytics', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to get notification analytics', 500));
+ }
+ },
+);
+
+/**
+ * @description Get notification templates
+ * @route GET /api/notifications/templates
+ * @access Private (Admin only)
+ */
+export const getNotificationTemplates = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ try {
+ const templates = await NotificationService.getTemplates();
+
+ res.status(200).json({
+ success: true,
+ data: templates,
+ });
+ } catch (error) {
+ logger.error('Failed to get notification templates', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to get notification templates', 500));
+ }
+ },
+);
+
+/**
+ * @description Create notification template
+ * @route POST /api/notifications/templates
+ * @access Private (Admin only)
+ */
+export const createNotificationTemplate = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const { name, type, channels, subject, content, variables, isActive } = req.body;
+
+ // Validate required fields
+ Iif (!name || !type || !channels || !subject || !content) {
+ return next(
+ new ErrorResponse('name, type, channels, subject, and content are required', 400),
+ );
+ }
+
+ // Validate type
+ Iif (!Object.values(NotificationType).includes(type)) {
+ return next(new ErrorResponse('Invalid notification type', 400));
+ }
+
+ // Validate channels
+ Iif (!Array.isArray(channels) || channels.length === 0) {
+ return next(new ErrorResponse('channels must be a non-empty array', 400));
+ }
+
+ Iif (!channels.every((channel) => Object.values(NotificationChannel).includes(channel))) {
+ return next(new ErrorResponse('Invalid notification channel', 400));
+ }
+
+ // Validate variables
+ Iif (variables && !Array.isArray(variables)) {
+ return next(new ErrorResponse('variables must be an array', 400));
+ }
+
+ try {
+ const template = await NotificationService.createTemplate({
+ name,
+ type,
+ channels,
+ subject,
+ content,
+ variables: variables || [],
+ isActive: isActive !== undefined ? isActive : true,
+ });
+
+ logger.info('Notification template created', {
+ templateId: template.id,
+ name,
+ type,
+ channels,
+ });
+
+ res.status(201).json({
+ success: true,
+ data: template,
+ });
+ } catch (error) {
+ logger.error('Failed to create notification template', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to create notification template', 500));
+ }
+ },
+);
+
+/**
+ * @description Update notification template
+ * @route PUT /api/notifications/templates/:id
+ * @access Private (Admin only)
+ */
+export const updateNotificationTemplate = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const { id } = req.params;
+ const updates = req.body;
+
+ Iif (!id) {
+ return next(new ErrorResponse('Template ID is required', 400));
+ }
+
+ // Validate updates if type is being changed
+ Iif (updates.type && !Object.values(NotificationType).includes(updates.type)) {
+ return next(new ErrorResponse('Invalid notification type', 400));
+ }
+
+ // Validate channels if being changed
+ Iif (updates.channels) {
+ Iif (!Array.isArray(updates.channels) || updates.channels.length === 0) {
+ return next(new ErrorResponse('channels must be a non-empty array', 400));
+ }
+
+ Iif (
+ !updates.channels.every((channel: string) =>
+ Object.values(NotificationChannel).includes(channel as NotificationChannel),
+ )
+ ) {
+ return next(new ErrorResponse('Invalid notification channel', 400));
+ }
+ }
+
+ // Validate variables if being changed
+ Iif (updates.variables && !Array.isArray(updates.variables)) {
+ return next(new ErrorResponse('variables must be an array', 400));
+ }
+
+ try {
+ const template = await NotificationService.updateTemplate(id, updates);
+
+ Iif (!template) {
+ return next(new ErrorResponse('Template not found', 404));
+ }
+
+ logger.info('Notification template updated', {
+ templateId: id,
+ updates: Object.keys(updates),
+ });
+
+ res.status(200).json({
+ success: true,
+ data: template,
+ });
+ } catch (error) {
+ logger.error('Failed to update notification template', {
+ templateId: id,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to update notification template', 500));
+ }
+ },
+);
+
+/**
+ * @description Get queue statistics
+ * @route GET /api/notifications/queue/stats
+ * @access Private (Admin only)
+ */
+export const getQueueStats = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ try {
+ const stats = await QueueService.getQueueStats();
+ const health = await QueueService.healthCheck();
+
+ res.status(200).json({
+ success: true,
+ data: {
+ ...stats,
+ healthy: health.healthy,
+ error: health.error,
+ },
+ });
+ } catch (error) {
+ logger.error('Failed to get queue stats', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to get queue stats', 500));
+ }
+ },
+);
+
+/**
+ * @description Retry failed notification jobs
+ * @route POST /api/notifications/queue/retry
+ * @access Private (Admin only)
+ */
+export const retryFailedJobs = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const { limit = 10 } = req.body;
+
+ const limitNum = parseInt(limit, 10);
+ Iif (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
+ return next(new ErrorResponse('Limit must be between 1 and 100', 400));
+ }
+
+ try {
+ const retriedCount = await QueueService.retryFailedJobs(limitNum);
+
+ logger.info('Retried failed notification jobs', { retriedCount });
+
+ res.status(200).json({
+ success: true,
+ data: {
+ retriedCount,
+ },
+ });
+ } catch (error) {
+ logger.error('Failed to retry failed jobs', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to retry failed jobs', 500));
+ }
+ },
+);
+
+/**
+ * @description Clean old queue jobs
+ * @route POST /api/notifications/queue/clean
+ * @access Private (Admin only)
+ */
+export const cleanOldJobs = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ try {
+ await QueueService.cleanOldJobs();
+
+ logger.info('Cleaned old queue jobs');
+
+ res.status(200).json({
+ success: true,
+ message: 'Old jobs cleaned successfully',
+ });
+ } catch (error) {
+ logger.error('Failed to clean old jobs', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to clean old jobs', 500));
+ }
+ },
+);
+
+// Quick notification helpers
+
+/**
+ * @description Send transaction confirmation notification
+ * @route POST /api/notifications/transaction-confirmation
+ * @access Private
+ */
+export const sendTransactionConfirmation = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const userId = req.userId!;
+ const { amount, currency, transactionId, recipientName, date } = req.body;
+
+ Iif (!amount || !currency || !transactionId || !recipientName || !date) {
+ return next(
+ new ErrorResponse(
+ 'amount, currency, transactionId, recipientName, and date are required',
+ 400,
+ ),
+ );
+ }
+
+ try {
+ const result = await NotificationService.sendTransactionConfirmation(userId, {
+ amount,
+ currency,
+ transactionId,
+ recipientName,
+ date,
+ });
+
+ res.status(200).json({
+ success: true,
+ data: result,
+ });
+ } catch (error) {
+ logger.error('Failed to send transaction confirmation', {
+ userId,
+ transactionId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to send transaction confirmation', 500));
+ }
+ },
+);
+
+/**
+ * @description Send security alert notification
+ * @route POST /api/notifications/security-alert
+ * @access Private
+ */
+export const sendSecurityAlert = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ const userId = req.userId!;
+ const { alertType, description, timestamp, ipAddress } = req.body;
+
+ Iif (!alertType || !description || !timestamp || !ipAddress) {
+ return next(
+ new ErrorResponse(
+ 'alertType, description, timestamp, and ipAddress are required',
+ 400,
+ ),
+ );
+ }
+
+ try {
+ const result = await NotificationService.sendSecurityAlert(userId, {
+ alertType,
+ description,
+ timestamp,
+ ipAddress,
+ });
+
+ res.status(200).json({
+ success: true,
+ data: result,
+ });
+ } catch (error) {
+ logger.error('Failed to send security alert', {
+ userId,
+ alertType,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return next(new ErrorResponse('Failed to send security alert', 500));
+ }
+ },
+);
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| protect.guard.ts | +
+
+ |
+ 23.8% | +5/21 | +0% | +0/12 | +0% | +0/1 | +23.8% | +5/21 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 | +1x +1x +1x +1x + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { Request, Response, NextFunction } from 'express';
+import jwt from 'jsonwebtoken';
+import { asyncHandler } from '../middleware/async.middleware';
+import { ErrorResponse } from '../utils/errorResponse';
+import { db } from '../model/user.model';
+import { User } from '../types/user.types';
+
+// Extend Request interface to include user property
+export interface AuthRequest extends Request {
+ user?: User;
+}
+
+export const protect = asyncHandler(
+ async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
+ let token: string | undefined;
+
+ if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
+ // Set token from Bearer token in header
+ token = req.headers.authorization.split(' ')[1];
+ } else Iif (req.cookies?.token) {
+ // Set token from cookie
+ token = req.cookies.token;
+ }
+
+ // Make sure token exists
+ Iif (!token) {
+ return next(new ErrorResponse('Not authorized to access this route', 401));
+ }
+
+ try {
+ // Verify token
+ const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as {
+ userId: string;
+ };
+
+ // Check if token is valid and fetch user
+ if (decoded && decoded.userId) {
+ const user = await db.findUserById(decoded.userId);
+ if (user) {
+ req.user = user;
+ next();
+ } else {
+ return next(new ErrorResponse('Not authorized to access this route', 400));
+ }
+ } else {
+ return next(
+ new ErrorResponse(
+ "Not authorized to access this route, Can't Resolve Request 'HINT: Login Again'",
+ 400,
+ ),
+ );
+ }
+ } catch (err) {
+ return next(new ErrorResponse('Not authorized to access this route', 401));
+ }
+ },
+);
+ |
| - + | |||||||||
|---|---|---|---|---|---|---|---|---|---|
| config | +
+
+ |
+ 100% | +3/3 | +100% | +24/24 | +100% | +0/0 | +100% | +3/3 | +
| controller | +
+
+ |
+ 8.58% | +20/233 | +0% | +0/145 | +0% | +0/22 | +8.77% | +20/228 | +
| guard | +
+
+ |
+ 23.8% | +5/21 | +0% | +0/12 | +0% | +0/1 | +23.8% | +5/21 | +
| middleware | +
+
+ |
+ 17.14% | +12/70 | +0% | +0/22 | +14.28% | +1/7 | +12.3% | +8/65 | +
| model | +
+
+ |
+ 12.24% | +24/196 | +0% | +0/82 | +6.74% | +6/89 | +14.63% | +24/164 | +
| router | +
+
+ |
+ 100% | +20/20 | +100% | +0/0 | +100% | +0/0 | +100% | +20/20 | +
| services | +
+
+ |
+ 10.46% | +67/640 | +1.9% | +5/262 | +2.91% | +3/103 | +10.5% | +66/628 | +
| types | +
+
+ |
+ 100% | +32/32 | +100% | +8/8 | +100% | +4/4 | +100% | +32/32 | +
| utils | +
+
+ |
+ 57.14% | +4/7 | +100% | +2/2 | +0% | +0/1 | +57.14% | +4/7 | +