feat: Complete notifications system with push support and tests

- Add Firebase client for FCM push notifications
- Update notification service with push token management
- Add push token registration/removal endpoints
- Update all queries to use auth schema
- Add comprehensive unit tests for notification.service
- Add unit tests for distribution.job

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 03:47:38 -06:00
parent e45591a0ef
commit 35a94f0529
11 changed files with 5180 additions and 28 deletions

2671
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,9 +25,11 @@
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.0.1",
"firebase-admin": "^13.6.0",
"google-auth-library": "^9.4.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
@ -40,6 +42,7 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
"pdfkit": "^0.15.0",
"pg": "^8.11.3",
"qrcode": "^1.5.3",
"speakeasy": "^2.0.0",
@ -48,6 +51,7 @@
"swagger-ui-express": "^5.0.1",
"twilio": "^4.19.3",
"uuid": "^9.0.1",
"web-push": "^3.6.7",
"winston": "^3.11.0",
"ws": "^8.18.0",
"zod": "^3.22.4"
@ -68,6 +72,7 @@
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.14",
"@types/passport-local": "^1.0.38",
"@types/pdfkit": "^0.13.4",
"@types/pg": "^8.10.9",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
@ -75,6 +80,7 @@
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^9.0.7",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"globals": "^15.14.0",

View File

@ -58,6 +58,17 @@ export const config = {
timeout: parseInt(process.env.ML_ENGINE_TIMEOUT || '5000', 10),
},
firebase: {
serviceAccountKey: process.env.FIREBASE_SERVICE_ACCOUNT_KEY || '',
projectId: process.env.FIREBASE_PROJECT_ID || '',
},
webPush: {
publicKey: process.env.VAPID_PUBLIC_KEY || '',
privateKey: process.env.VAPID_PRIVATE_KEY || '',
subject: process.env.VAPID_SUBJECT || 'mailto:admin@orbiquant.io',
},
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),

View File

@ -0,0 +1,379 @@
/**
* Distribution Job Unit Tests
*
* Tests for the daily distribution job including:
* - Processing active accounts
* - Calculating returns based on product
* - Applying performance fees
* - Skipping negative return days
* - Sending notifications
* - Logging run summaries
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks, createMockPoolClient } from '../../../../__tests__/mocks/database.mock';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Mock notification service
const mockSendDistributionNotification = jest.fn();
jest.mock('../../../notifications', () => ({
notificationService: {
sendDistributionNotification: mockSendDistributionNotification,
},
}));
// Import after mocks
import { distributionJob } from '../distribution.job';
describe('DistributionJob', () => {
const mockPoolClient = createMockPoolClient();
beforeEach(() => {
resetDatabaseMocks();
mockSendDistributionNotification.mockClear();
mockPoolClient.query.mockClear();
jest.clearAllMocks();
// Setup transaction mock
mockDb.transaction.mockImplementation(async (callback) => {
return callback(mockPoolClient);
});
});
describe('run', () => {
const mockAccounts = [
{
id: 'account-1',
user_id: 'user-1',
product_id: 'product-1',
product_code: 'atlas',
product_name: 'Atlas - El Guardián',
account_number: 'ATL-001',
current_balance: '10000.00',
status: 'active',
},
{
id: 'account-2',
user_id: 'user-2',
product_id: 'product-1',
product_name: 'Atlas - El Guardián',
product_code: 'atlas',
account_number: 'ATL-002',
current_balance: '25000.00',
status: 'active',
},
];
const mockProducts = [
{
id: 'product-1',
code: 'atlas',
name: 'Atlas - El Guardián',
target_return_min: '3.00',
target_return_max: '5.00',
performance_fee: '20.00',
},
];
it('should process all active accounts', async () => {
// Mock queries
mockDb.query
// getActiveAccounts
.mockResolvedValueOnce(createMockQueryResult(mockAccounts))
// getProducts
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
// logDistributionRun
.mockResolvedValueOnce({ rowCount: 1 });
// Mock transaction queries
mockPoolClient.query
// Lock account row
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
// Update account balance
.mockResolvedValueOnce({ rowCount: 1 })
// Insert transaction
.mockResolvedValueOnce({ rowCount: 1 })
// Insert distribution history
.mockResolvedValueOnce({ rowCount: 1 })
// Lock account row (second account)
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '25000.00' }]))
// Update account balance
.mockResolvedValueOnce({ rowCount: 1 })
// Insert transaction
.mockResolvedValueOnce({ rowCount: 1 })
// Insert distribution history
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
const summary = await distributionJob.run();
expect(summary.totalAccounts).toBe(2);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('FROM investment.accounts'),
expect.any(Array)
);
});
it('should calculate correct returns based on product', async () => {
// Use Math.random mock to control variance
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5); // Will give variance = 0.1
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
await distributionJob.run();
// Verify update was called with calculated amount
expect(mockPoolClient.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE investment.accounts'),
expect.any(Array)
);
Math.random = originalRandom;
});
it('should apply performance fees', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
const summary = await distributionJob.run();
// Check that fees were collected
expect(summary.totalFees).toBeGreaterThanOrEqual(0);
Math.random = originalRandom;
});
it('should skip negative return days', async () => {
const originalRandom = Math.random;
// Return value that causes negative returns (< 0.3)
Math.random = jest.fn().mockReturnValue(0.1);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
const summary = await distributionJob.run();
// No distributions should occur on negative days
expect(mockPoolClient.query).not.toHaveBeenCalled();
expect(summary.successfulDistributions).toBe(0);
Math.random = originalRandom;
});
it('should send notifications to users', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
await distributionJob.run();
expect(mockSendDistributionNotification).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({
productName: 'Atlas - El Guardián',
accountNumber: 'ATL-001',
})
);
Math.random = originalRandom;
});
it('should log run summary', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
await distributionJob.run();
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO investment.distribution_runs'),
expect.any(Array)
);
Math.random = originalRandom;
});
it('should handle account not found during distribution', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
// Lock returns empty (account was deleted)
.mockResolvedValueOnce(createMockQueryResult([]));
mockDb.transaction.mockImplementation(async (callback) => {
try {
return await callback(mockPoolClient);
} catch (error) {
throw error;
}
});
const summary = await distributionJob.run();
expect(summary.failedDistributions).toBe(1);
Math.random = originalRandom;
});
it('should handle product not found for account', async () => {
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([{
...mockAccounts[0],
product_id: 'non-existent-product',
}]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
const summary = await distributionJob.run();
expect(summary.failedDistributions).toBe(1);
});
it('should not process if no active accounts', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const summary = await distributionJob.run();
expect(summary.totalAccounts).toBe(0);
expect(summary.successfulDistributions).toBe(0);
});
it('should prevent concurrent runs', async () => {
// Start first run
mockDb.query
.mockResolvedValueOnce(createMockQueryResult(mockAccounts))
.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => {
resolve(createMockQueryResult(mockProducts));
}, 100)));
const firstRun = distributionJob.run();
// Attempt second run immediately
await expect(distributionJob.run()).rejects.toThrow('Distribution already in progress');
// Let first run complete
mockDb.query.mockResolvedValue({ rowCount: 1 });
mockPoolClient.query.mockResolvedValue(createMockQueryResult([{ current_balance: '10000.00' }]));
await firstRun;
});
});
describe('getStatus', () => {
it('should return running state', () => {
const status = distributionJob.getStatus();
expect(status).toHaveProperty('isRunning');
expect(status).toHaveProperty('lastRunAt');
expect(status).toHaveProperty('isScheduled');
});
it('should return last run time after successful run', async () => {
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([]))
.mockResolvedValueOnce({ rowCount: 1 });
await distributionJob.run();
const status = distributionJob.getStatus();
expect(status.lastRunAt).toBeDefined();
});
});
describe('start/stop', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
distributionJob.stop();
jest.useRealTimers();
});
it('should schedule job for midnight UTC', () => {
distributionJob.start();
const status = distributionJob.getStatus();
expect(status.isScheduled).toBe(true);
});
it('should stop scheduled job', () => {
distributionJob.start();
distributionJob.stop();
const status = distributionJob.getStatus();
expect(status.isScheduled).toBe(false);
});
});
});

View File

@ -0,0 +1,292 @@
/**
* Notification Controller
* Handles notification-related HTTP requests
*/
import { Response } from 'express';
import { AuthenticatedRequest } from '../../../core/guards/auth.guard';
import { notificationService } from '../services/notification.service';
import { logger } from '../../../shared/utils/logger';
/**
* GET /api/v1/notifications
* Get user's notifications
*/
export async function getNotifications(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const { limit, offset, unreadOnly } = req.query;
const notifications = await notificationService.getUserNotifications(userId, {
limit: limit ? parseInt(limit as string, 10) : 50,
offset: offset ? parseInt(offset as string, 10) : 0,
unreadOnly: unreadOnly === 'true',
});
res.json({
success: true,
data: notifications,
});
} catch (error) {
logger.error('[NotificationController] Failed to get notifications:', error);
res.status(500).json({
success: false,
error: 'Failed to get notifications',
});
}
}
/**
* GET /api/v1/notifications/unread-count
* Get unread notification count
*/
export async function getUnreadCount(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const count = await notificationService.getUnreadCount(userId);
res.json({
success: true,
data: { count },
});
} catch (error) {
logger.error('[NotificationController] Failed to get unread count:', error);
res.status(500).json({
success: false,
error: 'Failed to get unread count',
});
}
}
/**
* PATCH /api/v1/notifications/:notificationId/read
* Mark notification as read
*/
export async function markAsRead(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const { notificationId } = req.params;
const success = await notificationService.markAsRead(notificationId, userId);
if (!success) {
res.status(404).json({
success: false,
error: 'Notification not found',
});
return;
}
res.json({
success: true,
message: 'Notification marked as read',
});
} catch (error) {
logger.error('[NotificationController] Failed to mark as read:', error);
res.status(500).json({
success: false,
error: 'Failed to mark notification as read',
});
}
}
/**
* POST /api/v1/notifications/read-all
* Mark all notifications as read
*/
export async function markAllAsRead(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const count = await notificationService.markAllAsRead(userId);
res.json({
success: true,
data: { markedCount: count },
});
} catch (error) {
logger.error('[NotificationController] Failed to mark all as read:', error);
res.status(500).json({
success: false,
error: 'Failed to mark notifications as read',
});
}
}
/**
* DELETE /api/v1/notifications/:notificationId
* Delete a notification
*/
export async function deleteNotification(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const { notificationId } = req.params;
const success = await notificationService.deleteNotification(notificationId, userId);
if (!success) {
res.status(404).json({
success: false,
error: 'Notification not found',
});
return;
}
res.json({
success: true,
message: 'Notification deleted',
});
} catch (error) {
logger.error('[NotificationController] Failed to delete notification:', error);
res.status(500).json({
success: false,
error: 'Failed to delete notification',
});
}
}
/**
* GET /api/v1/notifications/preferences
* Get notification preferences
*/
export async function getPreferences(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const preferences = await notificationService.getUserPreferences(userId);
res.json({
success: true,
data: preferences,
});
} catch (error) {
logger.error('[NotificationController] Failed to get preferences:', error);
res.status(500).json({
success: false,
error: 'Failed to get notification preferences',
});
}
}
/**
* PATCH /api/v1/notifications/preferences
* Update notification preferences
*/
export async function updatePreferences(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const updates = req.body;
await notificationService.updateUserPreferences(userId, updates);
const preferences = await notificationService.getUserPreferences(userId);
res.json({
success: true,
data: preferences,
});
} catch (error) {
logger.error('[NotificationController] Failed to update preferences:', error);
res.status(500).json({
success: false,
error: 'Failed to update notification preferences',
});
}
}
/**
* POST /api/v1/notifications/push-token
* Register a push notification token
*/
export async function registerPushToken(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const { token, platform, deviceInfo } = req.body;
if (!token || !platform) {
res.status(400).json({
success: false,
error: 'Token and platform are required',
});
return;
}
if (!['web', 'ios', 'android'].includes(platform)) {
res.status(400).json({
success: false,
error: 'Invalid platform. Must be web, ios, or android',
});
return;
}
await notificationService.registerPushToken(userId, token, platform, deviceInfo);
res.json({
success: true,
message: 'Push token registered successfully',
});
} catch (error) {
logger.error('[NotificationController] Failed to register push token:', error);
res.status(500).json({
success: false,
error: 'Failed to register push token',
});
}
}
/**
* DELETE /api/v1/notifications/push-token
* Remove a push notification token
*/
export async function removePushToken(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user!.id;
const { token } = req.body;
if (!token) {
res.status(400).json({
success: false,
error: 'Token is required',
});
return;
}
await notificationService.removePushToken(userId, token);
res.json({
success: true,
message: 'Push token removed successfully',
});
} catch (error) {
logger.error('[NotificationController] Failed to remove push token:', error);
res.status(500).json({
success: false,
error: 'Failed to remove push token',
});
}
}

View File

@ -0,0 +1,7 @@
/**
* Notifications Module
* Exports notification service, routes, and types
*/
export * from './services/notification.service';
export * from './notification.routes';

View File

@ -0,0 +1,74 @@
/**
* Notification Routes
* API endpoints for managing notifications
*/
import { Router, RequestHandler } from 'express';
import * as notificationController from './controllers/notification.controller';
import { requireAuth } from '../../core/guards/auth.guard';
const router = Router();
// Type cast helper for authenticated routes
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
// All routes require authentication
router.use(requireAuth);
/**
* GET /api/v1/notifications
* Get user's notifications
* Query params: limit, offset, unreadOnly
*/
router.get('/', authHandler(notificationController.getNotifications));
/**
* GET /api/v1/notifications/unread-count
* Get unread notification count
*/
router.get('/unread-count', authHandler(notificationController.getUnreadCount));
/**
* GET /api/v1/notifications/preferences
* Get notification preferences
*/
router.get('/preferences', authHandler(notificationController.getPreferences));
/**
* PATCH /api/v1/notifications/preferences
* Update notification preferences
*/
router.patch('/preferences', authHandler(notificationController.updatePreferences));
/**
* POST /api/v1/notifications/read-all
* Mark all notifications as read
*/
router.post('/read-all', authHandler(notificationController.markAllAsRead));
/**
* PATCH /api/v1/notifications/:notificationId/read
* Mark notification as read
*/
router.patch('/:notificationId/read', authHandler(notificationController.markAsRead));
/**
* DELETE /api/v1/notifications/:notificationId
* Delete a notification
*/
router.delete('/:notificationId', authHandler(notificationController.deleteNotification));
/**
* POST /api/v1/notifications/push-token
* Register push notification token
*/
router.post('/push-token', authHandler(notificationController.registerPushToken));
/**
* DELETE /api/v1/notifications/push-token
* Remove push notification token
*/
router.delete('/push-token', authHandler(notificationController.removePushToken));
export { router as notificationRouter };

View File

@ -0,0 +1,454 @@
/**
* Notification Service Unit Tests
*
* Tests for notification service including:
* - Sending notifications through various channels
* - User notification preferences
* - Notification CRUD operations
* - Push token management
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
// Mock database
jest.mock('../../../../shared/database', () => ({
db: mockDb,
}));
// Mock logger
jest.mock('../../../../shared/utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Mock WebSocket manager
const mockWsManager = {
sendToUser: jest.fn(),
broadcastAll: jest.fn(),
};
jest.mock('../../../../core/websocket/websocket.server', () => ({
wsManager: mockWsManager,
}));
// Mock Firebase client
const mockFirebaseClient = {
sendToMultiple: jest.fn(),
deactivateInvalidTokens: jest.fn(),
};
jest.mock('../../../../shared/clients/firebase.client', () => ({
firebaseClient: mockFirebaseClient,
}));
// Mock nodemailer
const mockSendMail = jest.fn();
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => ({
sendMail: mockSendMail,
})),
}));
// Import service after mocks
import { notificationService, NotificationType, NotificationPriority } from '../notification.service';
describe('NotificationService', () => {
beforeEach(() => {
resetDatabaseMocks();
mockWsManager.sendToUser.mockClear();
mockWsManager.broadcastAll.mockClear();
mockFirebaseClient.sendToMultiple.mockClear();
mockFirebaseClient.deactivateInvalidTokens.mockClear();
mockSendMail.mockClear();
jest.clearAllMocks();
});
describe('sendNotification', () => {
const mockPayload = {
type: 'alert_triggered' as NotificationType,
title: 'Price Alert',
message: 'BTC reached $50,000',
priority: 'high' as NotificationPriority,
iconType: 'warning' as const,
data: { symbol: 'BTCUSDT', price: 50000 },
actionUrl: '/trading?symbol=BTCUSDT',
};
const mockNotificationRow = {
id: 'notif-123',
user_id: 'user-123',
type: 'alert_triggered',
title: 'Price Alert',
message: 'BTC reached $50,000',
priority: 'high',
data: JSON.stringify({ symbol: 'BTCUSDT', price: 50000 }),
action_url: '/trading?symbol=BTCUSDT',
icon_type: 'warning',
channels: ['in_app', 'email', 'push'],
is_read: false,
read_at: null,
created_at: new Date().toISOString(),
};
it('should store notification in database', async () => {
// Mock user preferences (all channels enabled)
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([{
email_enabled: true,
push_enabled: true,
in_app_enabled: true,
sms_enabled: false,
disabled_notification_types: [],
}]))
// Mock INSERT notification
.mockResolvedValueOnce(createMockQueryResult([mockNotificationRow]))
// Mock user email lookup
.mockResolvedValueOnce(createMockQueryResult([{ email: 'user@test.com' }]))
// Mock push tokens lookup
.mockResolvedValueOnce(createMockQueryResult([]));
mockSendMail.mockResolvedValue({ messageId: 'test-123' });
const result = await notificationService.sendNotification('user-123', mockPayload);
expect(result.id).toBe('notif-123');
expect(result.userId).toBe('user-123');
expect(result.type).toBe('alert_triggered');
expect(mockDb.query).toHaveBeenCalled();
});
it('should send via WebSocket for in_app channel', async () => {
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([{
in_app_enabled: true,
email_enabled: false,
push_enabled: false,
sms_enabled: false,
disabled_notification_types: [],
}]))
.mockResolvedValueOnce(createMockQueryResult([mockNotificationRow]));
await notificationService.sendNotification('user-123', mockPayload, {
channels: ['in_app'],
});
expect(mockWsManager.sendToUser).toHaveBeenCalledWith('user-123', expect.objectContaining({
type: 'notification',
data: expect.objectContaining({
title: 'Price Alert',
}),
}));
});
it('should send email for email channel', async () => {
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([{
email_enabled: true,
push_enabled: false,
in_app_enabled: false,
sms_enabled: false,
disabled_notification_types: [],
}]))
.mockResolvedValueOnce(createMockQueryResult([mockNotificationRow]))
.mockResolvedValueOnce(createMockQueryResult([{ email: 'user@test.com' }]));
mockSendMail.mockResolvedValue({ messageId: 'email-123' });
await notificationService.sendNotification('user-123', mockPayload, {
channels: ['email'],
});
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({
to: 'user@test.com',
subject: expect.stringContaining('Price Alert'),
}));
});
it('should respect user preferences', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{
email_enabled: false,
push_enabled: false,
in_app_enabled: true,
sms_enabled: false,
disabled_notification_types: [],
}]))
.mockResolvedValueOnce(createMockQueryResult([mockNotificationRow]));
await notificationService.sendNotification('user-123', mockPayload);
expect(mockSendMail).not.toHaveBeenCalled();
expect(mockFirebaseClient.sendToMultiple).not.toHaveBeenCalled();
});
it('should skip disabled notification types', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{
email_enabled: true,
push_enabled: true,
in_app_enabled: true,
sms_enabled: false,
disabled_notification_types: ['alert_triggered'],
}]));
// No INSERT should happen for disabled type
// Service returns empty channels, but still stores if skipPreferences is false
const result = await notificationService.sendNotification('user-123', mockPayload);
// Notification is not stored because no channels are enabled
expect(mockWsManager.sendToUser).not.toHaveBeenCalled();
});
it('should only use in_app during quiet hours', async () => {
const now = new Date();
const quietHoursStart = `${(now.getHours() - 1 + 24) % 24}:00`;
const quietHoursEnd = `${(now.getHours() + 1) % 24}:00`;
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([{
email_enabled: true,
push_enabled: true,
in_app_enabled: true,
sms_enabled: false,
quiet_hours_start: quietHoursStart,
quiet_hours_end: quietHoursEnd,
disabled_notification_types: [],
}]))
.mockResolvedValueOnce(createMockQueryResult([{
...mockNotificationRow,
channels: ['in_app'],
}]));
await notificationService.sendNotification('user-123', mockPayload);
// During quiet hours, only in_app is used
expect(mockSendMail).not.toHaveBeenCalled();
});
});
describe('getUserNotifications', () => {
it('should return paginated notifications', async () => {
const mockRows = [
{
id: 'notif-1',
user_id: 'user-123',
type: 'alert_triggered',
title: 'Alert 1',
message: 'Message 1',
priority: 'normal',
data: null,
action_url: null,
icon_type: 'info',
channels: ['in_app'],
is_read: false,
read_at: null,
created_at: new Date().toISOString(),
},
{
id: 'notif-2',
user_id: 'user-123',
type: 'trade_executed',
title: 'Alert 2',
message: 'Message 2',
priority: 'high',
data: null,
action_url: '/trading',
icon_type: 'success',
channels: ['in_app', 'email'],
is_read: true,
read_at: new Date().toISOString(),
created_at: new Date().toISOString(),
},
];
mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockRows));
const notifications = await notificationService.getUserNotifications('user-123', {
limit: 10,
offset: 0,
});
expect(notifications).toHaveLength(2);
expect(notifications[0].id).toBe('notif-1');
expect(notifications[1].id).toBe('notif-2');
});
it('should filter unread only when requested', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{
id: 'notif-1',
user_id: 'user-123',
type: 'alert_triggered',
title: 'Alert',
message: 'Message',
priority: 'normal',
data: null,
action_url: null,
icon_type: 'info',
channels: ['in_app'],
is_read: false,
read_at: null,
created_at: new Date().toISOString(),
}]));
await notificationService.getUserNotifications('user-123', { unreadOnly: true });
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('is_read = FALSE'),
expect.any(Array)
);
});
});
describe('markAsRead', () => {
it('should mark notification as read', async () => {
mockDb.query.mockResolvedValueOnce({ rowCount: 1 });
const result = await notificationService.markAsRead('notif-123', 'user-123');
expect(result).toBe(true);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE auth.notifications'),
['notif-123', 'user-123']
);
});
it('should return false for non-existent notification', async () => {
mockDb.query.mockResolvedValueOnce({ rowCount: 0 });
const result = await notificationService.markAsRead('non-existent', 'user-123');
expect(result).toBe(false);
});
});
describe('markAllAsRead', () => {
it('should mark all notifications as read', async () => {
mockDb.query.mockResolvedValueOnce({ rowCount: 5 });
const count = await notificationService.markAllAsRead('user-123');
expect(count).toBe(5);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE auth.notifications'),
['user-123']
);
});
});
describe('getUnreadCount', () => {
it('should return unread notification count', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ count: '10' }]));
const count = await notificationService.getUnreadCount('user-123');
expect(count).toBe(10);
});
});
describe('deleteNotification', () => {
it('should delete notification', async () => {
mockDb.query.mockResolvedValueOnce({ rowCount: 1 });
const result = await notificationService.deleteNotification('notif-123', 'user-123');
expect(result).toBe(true);
});
it('should return false for non-existent notification', async () => {
mockDb.query.mockResolvedValueOnce({ rowCount: 0 });
const result = await notificationService.deleteNotification('non-existent', 'user-123');
expect(result).toBe(false);
});
});
describe('registerPushToken', () => {
it('should register a new push token', async () => {
mockDb.query.mockResolvedValueOnce({ rowCount: 1 });
await notificationService.registerPushToken('user-123', 'token-abc', 'web', {
browser: 'Chrome',
});
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO auth.user_push_tokens'),
expect.arrayContaining(['user-123', 'token-abc', 'web'])
);
});
});
describe('removePushToken', () => {
it('should remove a push token', async () => {
mockDb.query.mockResolvedValueOnce({ rowCount: 1 });
await notificationService.removePushToken('user-123', 'token-abc');
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM auth.user_push_tokens'),
['user-123', 'token-abc']
);
});
});
describe('broadcastNotification', () => {
it('should broadcast to all connected users', async () => {
await notificationService.broadcastNotification({
type: 'system_announcement',
title: 'System Update',
message: 'Platform will be under maintenance',
});
expect(mockWsManager.broadcastAll).toHaveBeenCalledWith(expect.objectContaining({
type: 'notification',
data: expect.objectContaining({
title: 'System Update',
}),
}));
});
});
describe('sendPushNotification', () => {
it('should send via Firebase and deactivate failed tokens', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([
{ token: 'token-1', platform: 'web' },
{ token: 'token-2', platform: 'android' },
]));
mockFirebaseClient.sendToMultiple.mockResolvedValueOnce({
successCount: 1,
failureCount: 1,
failedTokens: ['token-2'],
});
await notificationService.sendPushNotification('user-123', {
type: 'trade_executed',
title: 'Trade Executed',
message: 'Your order was filled',
});
expect(mockFirebaseClient.sendToMultiple).toHaveBeenCalledWith(
['token-1', 'token-2'],
expect.objectContaining({
title: 'Trade Executed',
body: 'Your order was filled',
})
);
expect(mockFirebaseClient.deactivateInvalidTokens).toHaveBeenCalledWith(['token-2']);
});
it('should skip if no push tokens exist', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
await notificationService.sendPushNotification('user-123', {
type: 'trade_executed',
title: 'Trade',
message: 'Message',
});
expect(mockFirebaseClient.sendToMultiple).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,967 @@
/**
* Notification Service
* Unified service for push, email, in-app, and WebSocket notifications
*/
import nodemailer from 'nodemailer';
import { config } from '../../../config';
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import { wsManager } from '../../../core/websocket/websocket.server';
import { firebaseClient } from '../../../shared/clients/firebase.client';
// ============================================================================
// Types
// ============================================================================
export type NotificationType =
| 'alert_triggered'
| 'trade_executed'
| 'deposit_confirmed'
| 'withdrawal_completed'
| 'distribution_received'
| 'system_announcement'
| 'security_alert'
| 'account_update';
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
export type DeliveryChannel = 'push' | 'email' | 'in_app' | 'sms';
export interface NotificationPayload {
title: string;
message: string;
type: NotificationType;
priority?: NotificationPriority;
data?: Record<string, unknown>;
actionUrl?: string;
iconType?: 'success' | 'warning' | 'error' | 'info';
}
export interface Notification {
id: string;
userId: string;
type: NotificationType;
title: string;
message: string;
priority: NotificationPriority;
data?: Record<string, unknown>;
actionUrl?: string;
iconType: string;
channels: DeliveryChannel[];
isRead: boolean;
readAt?: Date;
createdAt: Date;
}
export interface UserNotificationPreferences {
userId: string;
emailEnabled: boolean;
pushEnabled: boolean;
inAppEnabled: boolean;
smsEnabled: boolean;
quietHoursStart?: string;
quietHoursEnd?: string;
disabledTypes: NotificationType[];
}
export interface SendNotificationOptions {
channels?: DeliveryChannel[];
priority?: NotificationPriority;
skipPreferences?: boolean;
}
interface NotificationRow {
id: string;
user_id: string;
type: string;
title: string;
message: string;
priority: string;
data: string | null;
action_url: string | null;
icon_type: string;
channels: string[];
is_read: boolean;
read_at: string | null;
created_at: string;
}
// ============================================================================
// Email Templates
// ============================================================================
const emailTemplates: Record<NotificationType, (payload: NotificationPayload) => { subject: string; html: string }> = {
alert_triggered: (p) => ({
subject: `Price Alert: ${p.title}`,
html: getAlertEmailTemplate(p),
}),
trade_executed: (p) => ({
subject: `Trade Executed: ${p.title}`,
html: getTradeEmailTemplate(p),
}),
deposit_confirmed: (p) => ({
subject: `Deposit Confirmed: ${p.title}`,
html: getTransactionEmailTemplate(p, 'deposit'),
}),
withdrawal_completed: (p) => ({
subject: `Withdrawal Completed: ${p.title}`,
html: getTransactionEmailTemplate(p, 'withdrawal'),
}),
distribution_received: (p) => ({
subject: `Investment Returns: ${p.title}`,
html: getDistributionEmailTemplate(p),
}),
system_announcement: (p) => ({
subject: `Trading Platform: ${p.title}`,
html: getSystemEmailTemplate(p),
}),
security_alert: (p) => ({
subject: `Security Alert: ${p.title}`,
html: getSecurityEmailTemplate(p),
}),
account_update: (p) => ({
subject: `Account Update: ${p.title}`,
html: getAccountEmailTemplate(p),
}),
};
// ============================================================================
// Notification Service Class
// ============================================================================
class NotificationService {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
secure: config.email.secure,
auth: {
user: config.email.user,
pass: config.email.password,
},
});
}
// ==========================================================================
// Main Send Methods
// ==========================================================================
/**
* Send notification to a user through all enabled channels
*/
async sendNotification(
userId: string,
payload: NotificationPayload,
options: SendNotificationOptions = {}
): Promise<Notification> {
const { channels, priority = 'normal', skipPreferences = false } = options;
// Get user preferences
const preferences = await this.getUserPreferences(userId);
const enabledChannels = channels || this.getEnabledChannels(preferences, payload.type, skipPreferences);
// Store notification in database
const notification = await this.storeNotification(userId, payload, enabledChannels, priority);
// Send through each channel
const deliveryPromises: Promise<void>[] = [];
if (enabledChannels.includes('in_app')) {
deliveryPromises.push(this.sendInAppNotification(userId, notification));
}
if (enabledChannels.includes('email')) {
deliveryPromises.push(this.sendEmailNotification(userId, payload));
}
if (enabledChannels.includes('push')) {
deliveryPromises.push(this.sendPushNotification(userId, payload));
}
// Execute all delivery methods in parallel
await Promise.allSettled(deliveryPromises);
logger.info('[NotificationService] Notification sent:', {
notificationId: notification.id,
userId,
type: payload.type,
channels: enabledChannels,
});
return notification;
}
/**
* Send notification to multiple users
*/
async sendBulkNotification(
userIds: string[],
payload: NotificationPayload,
options: SendNotificationOptions = {}
): Promise<void> {
const batchSize = 100;
for (let i = 0; i < userIds.length; i += batchSize) {
const batch = userIds.slice(i, i + batchSize);
await Promise.allSettled(
batch.map((userId) => this.sendNotification(userId, payload, options))
);
}
logger.info('[NotificationService] Bulk notification sent:', {
type: payload.type,
recipientCount: userIds.length,
});
}
/**
* Broadcast system-wide notification
*/
async broadcastNotification(payload: NotificationPayload): Promise<void> {
// Send via WebSocket to all connected users
wsManager.broadcastAll({
type: 'notification',
data: {
type: payload.type,
title: payload.title,
message: payload.message,
priority: payload.priority || 'normal',
data: payload.data,
iconType: payload.iconType || 'info',
timestamp: new Date().toISOString(),
},
});
logger.info('[NotificationService] Broadcast notification sent:', { type: payload.type });
}
// ==========================================================================
// Channel-Specific Send Methods
// ==========================================================================
/**
* Send in-app notification via WebSocket
*/
async sendInAppNotification(userId: string, notification: Notification): Promise<void> {
try {
wsManager.sendToUser(userId, {
type: 'notification',
data: {
id: notification.id,
type: notification.type,
title: notification.title,
message: notification.message,
priority: notification.priority,
data: notification.data,
actionUrl: notification.actionUrl,
iconType: notification.iconType,
createdAt: notification.createdAt.toISOString(),
},
});
} catch (error) {
logger.error('[NotificationService] In-app notification failed:', {
userId,
error: (error as Error).message,
});
}
}
/**
* Send email notification
*/
async sendEmailNotification(userId: string, payload: NotificationPayload): Promise<void> {
try {
// Get user email
const userResult = await db.query<{ email: string }>(
'SELECT email FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const { email } = userResult.rows[0];
const template = emailTemplates[payload.type](payload);
await this.transporter.sendMail({
from: `"Trading Platform" <${config.email.from}>`,
to: email,
subject: template.subject,
html: template.html,
});
logger.debug('[NotificationService] Email sent:', { userId, type: payload.type });
} catch (error) {
logger.error('[NotificationService] Email notification failed:', {
userId,
error: (error as Error).message,
});
}
}
/**
* Send push notification via Firebase Cloud Messaging
*/
async sendPushNotification(userId: string, payload: NotificationPayload): Promise<void> {
try {
// Get user push tokens
const tokensResult = await db.query<{ token: string; platform: string }>(
`SELECT token, platform FROM auth.user_push_tokens
WHERE user_id = $1 AND is_active = TRUE`,
[userId]
);
if (tokensResult.rows.length === 0) {
logger.debug('[NotificationService] No push tokens for user:', { userId });
return;
}
const tokens = tokensResult.rows.map(t => t.token);
// Send via Firebase
const result = await firebaseClient.sendToMultiple(tokens, {
title: payload.title,
body: payload.message,
data: {
type: payload.type,
actionUrl: payload.actionUrl || '',
...(payload.data ? Object.fromEntries(
Object.entries(payload.data).map(([k, v]) => [k, String(v)])
) : {}),
},
});
// Deactivate invalid tokens
if (result.failedTokens.length > 0) {
await firebaseClient.deactivateInvalidTokens(result.failedTokens);
}
logger.debug('[NotificationService] Push notification sent:', {
userId,
type: payload.type,
success: result.successCount,
failed: result.failureCount,
});
} catch (error) {
logger.error('[NotificationService] Push notification failed:', {
userId,
error: (error as Error).message,
});
}
}
/**
* Register a push token for a user
*/
async registerPushToken(
userId: string,
token: string,
platform: 'web' | 'ios' | 'android',
deviceInfo?: Record<string, unknown>
): Promise<void> {
await db.query(
`INSERT INTO auth.user_push_tokens (user_id, token, platform, device_info)
VALUES ($1, $2, $3, $4)
ON CONFLICT (token) DO UPDATE SET
user_id = EXCLUDED.user_id,
platform = EXCLUDED.platform,
device_info = EXCLUDED.device_info,
is_active = TRUE,
updated_at = NOW()`,
[userId, token, platform, deviceInfo ? JSON.stringify(deviceInfo) : null]
);
logger.info('[NotificationService] Push token registered:', {
userId,
platform,
});
}
/**
* Remove a push token
*/
async removePushToken(userId: string, token: string): Promise<void> {
await db.query(
`DELETE FROM auth.user_push_tokens WHERE user_id = $1 AND token = $2`,
[userId, token]
);
logger.info('[NotificationService] Push token removed:', { userId });
}
// ==========================================================================
// Database Operations
// ==========================================================================
/**
* Store notification in database
*/
private async storeNotification(
userId: string,
payload: NotificationPayload,
channels: DeliveryChannel[],
priority: NotificationPriority
): Promise<Notification> {
const result = await db.query<NotificationRow>(
`INSERT INTO auth.notifications (
user_id, type, title, message, priority, data, action_url, icon_type, channels
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
userId,
payload.type,
payload.title,
payload.message,
priority,
payload.data ? JSON.stringify(payload.data) : null,
payload.actionUrl || null,
payload.iconType || 'info',
channels,
]
);
return this.transformNotification(result.rows[0]);
}
/**
* Get user notifications
*/
async getUserNotifications(
userId: string,
options: { limit?: number; offset?: number; unreadOnly?: boolean } = {}
): Promise<Notification[]> {
const { limit = 50, offset = 0, unreadOnly = false } = options;
const conditions = ['user_id = $1'];
const params: (string | number | boolean)[] = [userId];
if (unreadOnly) {
conditions.push('is_read = FALSE');
}
const result = await db.query<NotificationRow>(
`SELECT * FROM auth.notifications
WHERE ${conditions.join(' AND ')}
ORDER BY created_at DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset]
);
return result.rows.map(this.transformNotification);
}
/**
* Mark notification as read
*/
async markAsRead(notificationId: string, userId: string): Promise<boolean> {
const result = await db.query(
`UPDATE auth.notifications
SET is_read = TRUE, read_at = NOW()
WHERE id = $1 AND user_id = $2`,
[notificationId, userId]
);
return (result.rowCount ?? 0) > 0;
}
/**
* Mark all notifications as read
*/
async markAllAsRead(userId: string): Promise<number> {
const result = await db.query(
`UPDATE auth.notifications
SET is_read = TRUE, read_at = NOW()
WHERE user_id = $1 AND is_read = FALSE`,
[userId]
);
return result.rowCount ?? 0;
}
/**
* Get unread count
*/
async getUnreadCount(userId: string): Promise<number> {
const result = await db.query<{ count: string }>(
'SELECT COUNT(*) as count FROM auth.notifications WHERE user_id = $1 AND is_read = FALSE',
[userId]
);
return parseInt(result.rows[0].count, 10);
}
/**
* Delete notification
*/
async deleteNotification(notificationId: string, userId: string): Promise<boolean> {
const result = await db.query(
'DELETE FROM auth.notifications WHERE id = $1 AND user_id = $2',
[notificationId, userId]
);
return (result.rowCount ?? 0) > 0;
}
// ==========================================================================
// User Preferences
// ==========================================================================
/**
* Get user notification preferences
*/
async getUserPreferences(userId: string): Promise<UserNotificationPreferences> {
const result = await db.query<Record<string, unknown>>(
`SELECT
notification_email as email_enabled,
notification_push as push_enabled,
notification_in_app as in_app_enabled,
notification_sms as sms_enabled,
quiet_hours_start,
quiet_hours_end,
disabled_notification_types
FROM user_settings WHERE user_id = $1`,
[userId]
);
if (result.rows.length === 0) {
// Return defaults
return {
userId,
emailEnabled: true,
pushEnabled: true,
inAppEnabled: true,
smsEnabled: false,
disabledTypes: [],
};
}
const row = result.rows[0];
return {
userId,
emailEnabled: row.email_enabled as boolean ?? true,
pushEnabled: row.push_enabled as boolean ?? true,
inAppEnabled: row.in_app_enabled as boolean ?? true,
smsEnabled: row.sms_enabled as boolean ?? false,
quietHoursStart: row.quiet_hours_start as string | undefined,
quietHoursEnd: row.quiet_hours_end as string | undefined,
disabledTypes: (row.disabled_notification_types as NotificationType[]) || [],
};
}
/**
* Update user notification preferences
*/
async updateUserPreferences(
userId: string,
updates: Partial<Omit<UserNotificationPreferences, 'userId'>>
): Promise<void> {
const fields: string[] = [];
const params: (string | boolean | string[] | null)[] = [];
let idx = 1;
if (updates.emailEnabled !== undefined) {
fields.push(`notification_email = $${idx++}`);
params.push(updates.emailEnabled);
}
if (updates.pushEnabled !== undefined) {
fields.push(`notification_push = $${idx++}`);
params.push(updates.pushEnabled);
}
if (updates.inAppEnabled !== undefined) {
fields.push(`notification_in_app = $${idx++}`);
params.push(updates.inAppEnabled);
}
if (updates.smsEnabled !== undefined) {
fields.push(`notification_sms = $${idx++}`);
params.push(updates.smsEnabled);
}
if (updates.quietHoursStart !== undefined) {
fields.push(`quiet_hours_start = $${idx++}`);
params.push(updates.quietHoursStart);
}
if (updates.quietHoursEnd !== undefined) {
fields.push(`quiet_hours_end = $${idx++}`);
params.push(updates.quietHoursEnd);
}
if (updates.disabledTypes !== undefined) {
fields.push(`disabled_notification_types = $${idx++}`);
params.push(updates.disabledTypes);
}
if (fields.length === 0) return;
params.push(userId);
await db.query(
`UPDATE user_settings SET ${fields.join(', ')} WHERE user_id = $${idx}`,
params
);
}
// ==========================================================================
// Alert Integration
// ==========================================================================
/**
* Send price alert triggered notification
*/
async sendAlertNotification(
userId: string,
alert: {
symbol: string;
condition: string;
targetPrice: number;
currentPrice: number;
note?: string;
}
): Promise<void> {
const conditionText = this.formatAlertCondition(alert.condition);
await this.sendNotification(userId, {
type: 'alert_triggered',
title: `${alert.symbol} ${conditionText}`,
message: `${alert.symbol} is now $${alert.currentPrice.toFixed(2)} (target: $${alert.targetPrice.toFixed(2)})`,
priority: 'high',
iconType: 'warning',
data: {
symbol: alert.symbol,
condition: alert.condition,
targetPrice: alert.targetPrice,
currentPrice: alert.currentPrice,
note: alert.note,
},
actionUrl: `/trading?symbol=${alert.symbol}`,
});
}
/**
* Send trade executed notification
*/
async sendTradeNotification(
userId: string,
trade: {
symbol: string;
side: 'buy' | 'sell';
quantity: number;
price: number;
total: number;
}
): Promise<void> {
const sideText = trade.side === 'buy' ? 'Bought' : 'Sold';
await this.sendNotification(userId, {
type: 'trade_executed',
title: `${sideText} ${trade.symbol}`,
message: `${sideText} ${trade.quantity} ${trade.symbol} at $${trade.price.toFixed(2)} (Total: $${trade.total.toFixed(2)})`,
priority: 'normal',
iconType: trade.side === 'buy' ? 'success' : 'info',
data: trade,
actionUrl: '/trading/history',
});
}
/**
* Send distribution notification
*/
async sendDistributionNotification(
userId: string,
distribution: {
productName: string;
amount: number;
accountNumber: string;
newBalance: number;
}
): Promise<void> {
await this.sendNotification(userId, {
type: 'distribution_received',
title: 'Investment Returns Received',
message: `You received $${distribution.amount.toFixed(2)} from ${distribution.productName}. New balance: $${distribution.newBalance.toFixed(2)}`,
priority: 'normal',
iconType: 'success',
data: distribution,
actionUrl: '/investment/accounts',
});
}
// ==========================================================================
// Helper Methods
// ==========================================================================
private getEnabledChannels(
preferences: UserNotificationPreferences,
type: NotificationType,
skipPreferences: boolean
): DeliveryChannel[] {
if (skipPreferences) {
return ['in_app', 'email', 'push'];
}
// Check if notification type is disabled
if (preferences.disabledTypes.includes(type)) {
return [];
}
// Check quiet hours
if (this.isQuietHours(preferences)) {
return ['in_app']; // Only in-app during quiet hours
}
const channels: DeliveryChannel[] = [];
if (preferences.inAppEnabled) channels.push('in_app');
if (preferences.emailEnabled) channels.push('email');
if (preferences.pushEnabled) channels.push('push');
if (preferences.smsEnabled) channels.push('sms');
return channels;
}
private isQuietHours(preferences: UserNotificationPreferences): boolean {
if (!preferences.quietHoursStart || !preferences.quietHoursEnd) {
return false;
}
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes();
const [startHour, startMin] = preferences.quietHoursStart.split(':').map(Number);
const [endHour, endMin] = preferences.quietHoursEnd.split(':').map(Number);
const startTime = startHour * 60 + startMin;
const endTime = endHour * 60 + endMin;
if (startTime <= endTime) {
return currentTime >= startTime && currentTime <= endTime;
} else {
// Quiet hours span midnight
return currentTime >= startTime || currentTime <= endTime;
}
}
private formatAlertCondition(condition: string): string {
const conditions: Record<string, string> = {
above: 'reached target price',
below: 'dropped to target price',
crosses_above: 'crossed above',
crosses_below: 'crossed below',
};
return conditions[condition] || condition;
}
private transformNotification(row: NotificationRow): Notification {
return {
id: row.id,
userId: row.user_id,
type: row.type as NotificationType,
title: row.title,
message: row.message,
priority: row.priority as NotificationPriority,
data: row.data ? JSON.parse(row.data) : undefined,
actionUrl: row.action_url || undefined,
iconType: row.icon_type,
channels: row.channels as DeliveryChannel[],
isRead: row.is_read,
readAt: row.read_at ? new Date(row.read_at) : undefined,
createdAt: new Date(row.created_at),
};
}
}
// ============================================================================
// Email Templates
// ============================================================================
function getBaseEmailStyles(): string {
return `
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background: #f3f4f6; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); padding: 30px; text-align: center; }
.logo { color: white; font-size: 24px; font-weight: bold; }
.content { padding: 30px; }
.alert-box { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 16px; margin: 20px 0; }
.success-box { background: #d1fae5; border: 1px solid #10b981; border-radius: 8px; padding: 16px; margin: 20px 0; }
.info-box { background: #dbeafe; border: 1px solid #3b82f6; border-radius: 8px; padding: 16px; margin: 20px 0; }
.button { display: inline-block; background: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500; margin-top: 20px; }
.footer { background: #f9fafb; padding: 20px; text-align: center; color: #6b7280; font-size: 14px; }
.stat { display: inline-block; margin: 10px 20px; text-align: center; }
.stat-value { font-size: 24px; font-weight: bold; color: #4f46e5; }
.stat-label { font-size: 12px; color: #6b7280; text-transform: uppercase; }
`;
}
function getAlertEmailTemplate(payload: NotificationPayload): string {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>${getBaseEmailStyles()}</style></head>
<body>
<div class="container">
<div class="header"><div class="logo">Trading Platform</div></div>
<div class="content">
<h2>Price Alert Triggered</h2>
<div class="alert-box">
<strong>${payload.title}</strong>
<p>${payload.message}</p>
</div>
${payload.data?.note ? `<p><em>Note: ${payload.data.note}</em></p>` : ''}
<a href="${config.app.frontendUrl}${payload.actionUrl || '/trading'}" class="button">View Chart</a>
</div>
<div class="footer">&copy; ${new Date().getFullYear()} Trading Platform</div>
</div>
</body>
</html>
`;
}
function getTradeEmailTemplate(payload: NotificationPayload): string {
const data = payload.data as { symbol: string; side: string; quantity: number; price: number; total: number } | undefined;
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>${getBaseEmailStyles()}</style></head>
<body>
<div class="container">
<div class="header"><div class="logo">Trading Platform</div></div>
<div class="content">
<h2>Trade Executed</h2>
<div class="success-box">
<strong>${payload.title}</strong>
<p>${payload.message}</p>
</div>
${data ? `
<div style="text-align: center; margin: 20px 0;">
<div class="stat"><div class="stat-value">${data.quantity}</div><div class="stat-label">Quantity</div></div>
<div class="stat"><div class="stat-value">$${data.price.toFixed(2)}</div><div class="stat-label">Price</div></div>
<div class="stat"><div class="stat-value">$${data.total.toFixed(2)}</div><div class="stat-label">Total</div></div>
</div>
` : ''}
<a href="${config.app.frontendUrl}/trading/history" class="button">View History</a>
</div>
<div class="footer">&copy; ${new Date().getFullYear()} Trading Platform</div>
</div>
</body>
</html>
`;
}
function getTransactionEmailTemplate(payload: NotificationPayload, type: 'deposit' | 'withdrawal'): string {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>${getBaseEmailStyles()}</style></head>
<body>
<div class="container">
<div class="header"><div class="logo">Trading Platform</div></div>
<div class="content">
<h2>${type === 'deposit' ? 'Deposit Confirmed' : 'Withdrawal Completed'}</h2>
<div class="success-box">
<strong>${payload.title}</strong>
<p>${payload.message}</p>
</div>
<a href="${config.app.frontendUrl}/investment/transactions" class="button">View Transactions</a>
</div>
<div class="footer">&copy; ${new Date().getFullYear()} Trading Platform</div>
</div>
</body>
</html>
`;
}
function getDistributionEmailTemplate(payload: NotificationPayload): string {
const data = payload.data as { productName: string; amount: number; newBalance: number } | undefined;
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>${getBaseEmailStyles()}</style></head>
<body>
<div class="container">
<div class="header"><div class="logo">Trading Platform</div></div>
<div class="content">
<h2>Investment Returns</h2>
<div class="success-box">
<strong>${payload.title}</strong>
<p>${payload.message}</p>
</div>
${data ? `
<div style="text-align: center; margin: 20px 0;">
<div class="stat"><div class="stat-value">+$${data.amount.toFixed(2)}</div><div class="stat-label">Returns</div></div>
<div class="stat"><div class="stat-value">$${data.newBalance.toFixed(2)}</div><div class="stat-label">New Balance</div></div>
</div>
` : ''}
<a href="${config.app.frontendUrl}/investment/accounts" class="button">View Account</a>
</div>
<div class="footer">&copy; ${new Date().getFullYear()} Trading Platform</div>
</div>
</body>
</html>
`;
}
function getSystemEmailTemplate(payload: NotificationPayload): string {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>${getBaseEmailStyles()}</style></head>
<body>
<div class="container">
<div class="header"><div class="logo">Trading Platform</div></div>
<div class="content">
<h2>${payload.title}</h2>
<div class="info-box">
<p>${payload.message}</p>
</div>
${payload.actionUrl ? `<a href="${config.app.frontendUrl}${payload.actionUrl}" class="button">Learn More</a>` : ''}
</div>
<div class="footer">&copy; ${new Date().getFullYear()} Trading Platform</div>
</div>
</body>
</html>
`;
}
function getSecurityEmailTemplate(payload: NotificationPayload): string {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>${getBaseEmailStyles()}</style></head>
<body>
<div class="container">
<div class="header"><div class="logo">Trading Platform</div></div>
<div class="content">
<h2>Security Alert</h2>
<div class="alert-box">
<strong>${payload.title}</strong>
<p>${payload.message}</p>
</div>
<p>If this wasn't you, please secure your account immediately.</p>
<a href="${config.app.frontendUrl}/settings/security" class="button">Review Security</a>
</div>
<div class="footer">&copy; ${new Date().getFullYear()} Trading Platform</div>
</div>
</body>
</html>
`;
}
function getAccountEmailTemplate(payload: NotificationPayload): string {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>${getBaseEmailStyles()}</style></head>
<body>
<div class="container">
<div class="header"><div class="logo">Trading Platform</div></div>
<div class="content">
<h2>Account Update</h2>
<div class="info-box">
<strong>${payload.title}</strong>
<p>${payload.message}</p>
</div>
<a href="${config.app.frontendUrl}/settings" class="button">View Settings</a>
</div>
<div class="footer">&copy; ${new Date().getFullYear()} Trading Platform</div>
</div>
</body>
</html>
`;
}
// Export singleton instance
export const notificationService = new NotificationService();

View File

@ -0,0 +1,346 @@
/**
* Firebase Client
* Client for sending push notifications via Firebase Cloud Messaging (FCM)
*/
import { config } from '../../config';
import { logger } from '../utils/logger';
// ============================================================================
// Types
// ============================================================================
export interface PushNotificationPayload {
title: string;
body: string;
data?: Record<string, string>;
imageUrl?: string;
}
export interface SendResult {
successCount: number;
failureCount: number;
failedTokens: string[];
}
interface FCMMessage {
notification: {
title: string;
body: string;
image?: string;
};
data?: Record<string, string>;
token: string;
}
interface FCMBatchResponse {
responses: Array<{
success: boolean;
error?: { code: string; message: string };
}>;
successCount: number;
failureCount: number;
}
// ============================================================================
// Firebase Client Class
// ============================================================================
class FirebaseClient {
private initialized = false;
private accessToken: string | null = null;
private tokenExpiry: number = 0;
/**
* Initialize the Firebase client
* Note: In production, use firebase-admin SDK. This is a simplified HTTP implementation.
*/
initialize(): void {
if (this.initialized) {
return;
}
if (!config.firebase.projectId) {
logger.warn('[FirebaseClient] Firebase not configured - push notifications disabled');
return;
}
this.initialized = true;
logger.info('[FirebaseClient] Initialized', {
projectId: config.firebase.projectId,
});
}
/**
* Check if Firebase is configured and ready
*/
isReady(): boolean {
return this.initialized && !!config.firebase.projectId;
}
/**
* Send push notification to a single device
*/
async sendToDevice(token: string, payload: PushNotificationPayload): Promise<boolean> {
if (!this.isReady()) {
logger.debug('[FirebaseClient] Not configured, skipping push notification');
return false;
}
try {
const message: FCMMessage = {
notification: {
title: payload.title,
body: payload.body,
image: payload.imageUrl,
},
data: payload.data,
token,
};
const response = await this.sendFCMMessage(message);
return response;
} catch (error) {
logger.error('[FirebaseClient] Failed to send notification:', {
error: (error as Error).message,
});
return false;
}
}
/**
* Send push notification to multiple devices
*/
async sendToMultiple(tokens: string[], payload: PushNotificationPayload): Promise<SendResult> {
if (!this.isReady()) {
logger.debug('[FirebaseClient] Not configured, skipping push notifications');
return {
successCount: 0,
failureCount: tokens.length,
failedTokens: tokens,
};
}
if (tokens.length === 0) {
return { successCount: 0, failureCount: 0, failedTokens: [] };
}
const result: SendResult = {
successCount: 0,
failureCount: 0,
failedTokens: [],
};
// Send to each token (FCM HTTP v1 requires individual messages)
const promises = tokens.map(async (token, index) => {
try {
const success = await this.sendToDevice(token, payload);
if (success) {
result.successCount++;
} else {
result.failureCount++;
result.failedTokens.push(token);
}
} catch (error) {
result.failureCount++;
result.failedTokens.push(token);
}
});
await Promise.all(promises);
logger.info('[FirebaseClient] Batch send completed:', {
total: tokens.length,
success: result.successCount,
failed: result.failureCount,
});
return result;
}
/**
* Deactivate invalid tokens in the database
*/
async deactivateInvalidTokens(tokens: string[]): Promise<void> {
if (tokens.length === 0) {
return;
}
// Import db here to avoid circular dependency
const { db } = await import('../database/index.js');
try {
await db.query(
`UPDATE auth.user_push_tokens
SET is_active = FALSE, updated_at = NOW()
WHERE token = ANY($1)`,
[tokens]
);
logger.info('[FirebaseClient] Deactivated invalid tokens:', {
count: tokens.length,
});
} catch (error) {
logger.error('[FirebaseClient] Failed to deactivate tokens:', {
error: (error as Error).message,
});
}
}
/**
* Send message via FCM HTTP v1 API
*/
private async sendFCMMessage(message: FCMMessage): Promise<boolean> {
// For development/testing, just log the message
// In production, this would use the Firebase Admin SDK or HTTP v1 API
if (process.env.NODE_ENV === 'development' && !config.firebase.serviceAccountKey) {
logger.debug('[FirebaseClient] [DEV] Would send push notification:', {
token: message.token.substring(0, 20) + '...',
title: message.notification.title,
});
return true;
}
try {
// Get access token for FCM API
const accessToken = await this.getAccessToken();
if (!accessToken) {
return false;
}
const response = await fetch(
`https://fcm.googleapis.com/v1/projects/${config.firebase.projectId}/messages:send`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
}
);
if (!response.ok) {
const errorData = await response.json() as { error?: { details?: Array<{ errorCode?: string }> } };
const errorCode = errorData?.error?.details?.[0]?.errorCode;
// Handle unregistered token
if (errorCode === 'UNREGISTERED' || response.status === 404) {
logger.debug('[FirebaseClient] Token unregistered:', {
token: message.token.substring(0, 20) + '...',
});
return false;
}
throw new Error(`FCM error: ${response.status} - ${JSON.stringify(errorData)}`);
}
return true;
} catch (error) {
logger.error('[FirebaseClient] FCM API error:', {
error: (error as Error).message,
});
return false;
}
}
/**
* Get OAuth2 access token for FCM API
*/
private async getAccessToken(): Promise<string | null> {
// Check if we have a valid cached token
if (this.accessToken && Date.now() < this.tokenExpiry) {
return this.accessToken;
}
if (!config.firebase.serviceAccountKey) {
logger.warn('[FirebaseClient] No service account key configured');
return null;
}
try {
// Parse service account key
const serviceAccount = JSON.parse(config.firebase.serviceAccountKey);
// Create JWT for OAuth2
const jwt = await this.createServiceAccountJWT(serviceAccount);
// Exchange JWT for access token
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
});
if (!response.ok) {
throw new Error(`OAuth error: ${response.status}`);
}
const data = await response.json() as { access_token: string; expires_in: number };
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + (data.expires_in - 60) * 1000; // Refresh 1 minute before expiry
return this.accessToken;
} catch (error) {
logger.error('[FirebaseClient] Failed to get access token:', {
error: (error as Error).message,
});
return null;
}
}
/**
* Create JWT for service account authentication
*/
private async createServiceAccountJWT(serviceAccount: {
client_email: string;
private_key: string;
}): Promise<string> {
const header = {
alg: 'RS256',
typ: 'JWT',
};
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: serviceAccount.client_email,
sub: serviceAccount.client_email,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
scope: 'https://www.googleapis.com/auth/firebase.messaging',
};
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
const signatureInput = `${encodedHeader}.${encodedPayload}`;
// Sign with RSA
const crypto = await import('crypto');
const sign = crypto.createSign('RSA-SHA256');
sign.update(signatureInput);
const signature = sign.sign(serviceAccount.private_key);
const encodedSignature = this.base64UrlEncode(signature.toString('base64'));
return `${signatureInput}.${encodedSignature}`;
}
/**
* Base64 URL encode
*/
private base64UrlEncode(data: string): string {
return Buffer.from(data)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
}
// Export singleton instance
export const firebaseClient = new FirebaseClient();

View File

@ -11,3 +11,4 @@
export * from './trading-agents.client';
export * from './ml-engine.client';
export * from './llm-agent.client';
export * from './firebase.client';