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:
parent
e45591a0ef
commit
35a94f0529
2671
package-lock.json
generated
2671
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
379
src/modules/investment/jobs/__tests__/distribution.job.spec.ts
Normal file
379
src/modules/investment/jobs/__tests__/distribution.job.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
292
src/modules/notifications/controllers/notification.controller.ts
Normal file
292
src/modules/notifications/controllers/notification.controller.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
7
src/modules/notifications/index.ts
Normal file
7
src/modules/notifications/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Notifications Module
|
||||
* Exports notification service, routes, and types
|
||||
*/
|
||||
|
||||
export * from './services/notification.service';
|
||||
export * from './notification.routes';
|
||||
74
src/modules/notifications/notification.routes.ts
Normal file
74
src/modules/notifications/notification.routes.ts
Normal 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 };
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
967
src/modules/notifications/services/notification.service.ts
Normal file
967
src/modules/notifications/services/notification.service.ts
Normal 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">© ${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">© ${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">© ${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">© ${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">© ${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">© ${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">© ${new Date().getFullYear()} Trading Platform</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const notificationService = new NotificationService();
|
||||
346
src/shared/clients/firebase.client.ts
Normal file
346
src/shared/clients/firebase.client.ts
Normal 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();
|
||||
@ -11,3 +11,4 @@
|
||||
export * from './trading-agents.client';
|
||||
export * from './ml-engine.client';
|
||||
export * from './llm-agent.client';
|
||||
export * from './firebase.client';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user