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",
|
"cors": "^2.8.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
|
"firebase-admin": "^13.6.0",
|
||||||
"google-auth-library": "^9.4.1",
|
"google-auth-library": "^9.4.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@ -40,6 +42,7 @@
|
|||||||
"passport-github2": "^0.1.12",
|
"passport-github2": "^0.1.12",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"pdfkit": "^0.15.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
@ -48,6 +51,7 @@
|
|||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"twilio": "^4.19.3",
|
"twilio": "^4.19.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
@ -68,6 +72,7 @@
|
|||||||
"@types/passport-github2": "^1.2.9",
|
"@types/passport-github2": "^1.2.9",
|
||||||
"@types/passport-google-oauth20": "^2.0.14",
|
"@types/passport-google-oauth20": "^2.0.14",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"@types/pdfkit": "^0.13.4",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/speakeasy": "^2.0.10",
|
"@types/speakeasy": "^2.0.10",
|
||||||
@ -75,6 +80,7 @@
|
|||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
|
|||||||
@ -58,6 +58,17 @@ export const config = {
|
|||||||
timeout: parseInt(process.env.ML_ENGINE_TIMEOUT || '5000', 10),
|
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: {
|
rateLimit: {
|
||||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 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 './trading-agents.client';
|
||||||
export * from './ml-engine.client';
|
export * from './ml-engine.client';
|
||||||
export * from './llm-agent.client';
|
export * from './llm-agent.client';
|
||||||
|
export * from './firebase.client';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user