-
Notifications
You must be signed in to change notification settings - Fork 115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Push Notifications Package #2081
Changes from all commits
a72f374
fd9010d
09440d3
8558d45
ce9ce9a
f813892
4ac8f09
b753be5
92a8b72
456c5dc
3a136e4
e211b3e
3e6a674
9925faa
e02b84d
da7ccb1
b63a9a8
6f6c82c
45b3a9f
a8e8933
6a938d9
79a8b25
fb9cbb3
0dbf10a
6270e79
78bd29c
865312e
1cd65b6
0afa13f
e2b14ba
6b068bc
9a980a6
12f6871
6243d74
ccb1f77
48b1be9
00c396f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Service Level Variables | ||
|
||
SERVICE_NAME=notifications |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
DB_NAME=dydx_dev | ||
DB_USERNAME=dydx_dev | ||
DB_PASSWORD=dydxserver123 | ||
PG_POOL_MAX=2 | ||
PG_POOL_MIN=1 | ||
DB_HOSTNAME=postgres | ||
DB_READONLY_HOSTNAME=postgres | ||
DB_PORT=5432 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
SERVICE_NAME=notifications | ||
|
||
FIREBASE_PROJECT_ID=projectID | ||
FIREBASE_PRIVATE_KEY='-----BEGIN RSA PRIVATE KEY----------END RSA PRIVATE KEY-----' | ||
[email protected] | ||
|
||
DB_NAME=dydx_test | ||
DB_USERNAME=dydx_test | ||
DB_PASSWORD=dydxserver123 | ||
DB_PORT=5436 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
const baseConfig = require('./node_modules/@dydxprotocol-indexer/dev/.eslintrc'); | ||
|
||
module.exports = { | ||
...baseConfig, | ||
|
||
// Override the base configuraiton to set the correct tsconfigRootDir. | ||
parserOptions: { | ||
...baseConfig.parserOptions, | ||
tsconfigRootDir: __dirname, | ||
}, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Notifications | ||
|
||
Notification package to create and send push notifications |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { | ||
deriveLocalizedNotificationMessage, | ||
} from '../src/localization'; | ||
import { | ||
NotificationType, | ||
NotificationDynamicFieldKey, | ||
createNotification, | ||
isValidLanguageCode, | ||
} from '../src/types'; | ||
|
||
describe('deriveLocalizedNotificationMessage', () => { | ||
test('should generate a correct message for DepositSuccessNotification', () => { | ||
const notification = createNotification(NotificationType.DEPOSIT_SUCCESS, { | ||
[NotificationDynamicFieldKey.AMOUNT]: '1000', | ||
[NotificationDynamicFieldKey.MARKET]: 'USDT', | ||
}); | ||
|
||
const expected = { | ||
title: 'Deposit Successful', | ||
body: 'You have successfully deposited 1000 USDT to your dYdX account.', | ||
}; | ||
|
||
const result = deriveLocalizedNotificationMessage(notification); | ||
expect(result).toEqual(expected); | ||
}); | ||
|
||
test('should generate a correct message for OrderFilledNotification', () => { | ||
const notification = createNotification(NotificationType.ORDER_FILLED, { | ||
[NotificationDynamicFieldKey.MARKET]: 'BTC/USD', | ||
[NotificationDynamicFieldKey.AVERAGE_PRICE]: '45000', | ||
[NotificationDynamicFieldKey.AMOUNT]: '1000', | ||
}); | ||
|
||
const expected = { | ||
title: 'Order Filled', | ||
body: 'Your order for 1000 BTC/USD was filled at $45000', | ||
}; | ||
|
||
const result = deriveLocalizedNotificationMessage(notification); | ||
expect(result).toEqual(expected); | ||
}); | ||
|
||
describe('isValidLanguageCode', () => { | ||
test('should return true for valid language codes', () => { | ||
const validCodes = ['en', 'es', 'fr', 'de', 'it', 'ja', 'ko', 'zh']; | ||
validCodes.forEach((code) => { | ||
expect(isValidLanguageCode(code)).toBe(true); | ||
}); | ||
}); | ||
|
||
test('should return false for invalid language codes', () => { | ||
const invalidCodes = ['', 'EN', 'eng', 'esp', 'fra', 'deu', 'ita', 'jpn', 'kor', 'zho', 'xx']; | ||
invalidCodes.forEach((code) => { | ||
expect(isValidLanguageCode(code)).toBe(false); | ||
}); | ||
}); | ||
|
||
test('should return false for non-string inputs', () => { | ||
const nonStringInputs = [null, undefined, 123, {}, []]; | ||
nonStringInputs.forEach((input) => { | ||
expect(isValidLanguageCode(input as any)).toBe(false); | ||
}); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { logger } from '@dydxprotocol-indexer/base'; | ||
import { sendFirebaseMessage } from '../src/message'; | ||
import { sendMulticast } from '../src/lib/firebase'; | ||
import { createNotification, NotificationType } from '../src/types'; | ||
import { testMocks, dbHelpers } from '@dydxprotocol-indexer/postgres'; | ||
import { defaultToken } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; | ||
|
||
jest.mock('../src/lib/firebase', () => ({ | ||
sendMulticast: jest.fn(), | ||
})); | ||
|
||
describe('sendFirebaseMessage', () => { | ||
let loggerInfoSpy: jest.SpyInstance; | ||
let loggerWarnSpy: jest.SpyInstance; | ||
let loggerErrorSpy: jest.SpyInstance; | ||
|
||
beforeAll(() => { | ||
loggerInfoSpy = jest.spyOn(logger, 'info').mockImplementation(); | ||
loggerWarnSpy = jest.spyOn(logger, 'warning').mockImplementation(); | ||
loggerErrorSpy = jest.spyOn(logger, 'error').mockImplementation(); | ||
}); | ||
|
||
afterAll(() => { | ||
loggerInfoSpy.mockRestore(); | ||
loggerWarnSpy.mockRestore(); | ||
loggerErrorSpy.mockRestore(); | ||
}); | ||
|
||
beforeEach(async () => { | ||
await testMocks.seedData(); | ||
}); | ||
|
||
afterEach(async () => { | ||
await dbHelpers.clearData(); | ||
}); | ||
|
||
const mockNotification = createNotification(NotificationType.ORDER_FILLED, { | ||
AMOUNT: '10', | ||
MARKET: 'BTC-USD', | ||
AVERAGE_PRICE: '100.50', | ||
}); | ||
|
||
it('should send a Firebase message successfully', async () => { | ||
await sendFirebaseMessage( | ||
[{ token: defaultToken.token, language: defaultToken.language }], | ||
mockNotification, | ||
); | ||
|
||
expect(sendMulticast).toHaveBeenCalled(); | ||
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ | ||
message: 'Firebase message sent successfully', | ||
notificationType: mockNotification.type, | ||
})); | ||
}); | ||
|
||
it('should log a warning if user has no registration tokens', async () => { | ||
await sendFirebaseMessage([], mockNotification); | ||
|
||
expect(logger.warning).toHaveBeenCalledWith(expect.objectContaining({ | ||
message: 'Attempted to send Firebase message to user with no registration tokens', | ||
notificationType: mockNotification.type, | ||
})); | ||
}); | ||
|
||
it('should log an error if sending the message fails', async () => { | ||
const mockedSendMulticast = sendMulticast as jest.MockedFunction<typeof sendMulticast>; | ||
mockedSendMulticast.mockRejectedValueOnce(new Error('Send failed')); | ||
|
||
await sendFirebaseMessage( | ||
[{ token: defaultToken.token, language: defaultToken.language }], | ||
mockNotification, | ||
); | ||
|
||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ | ||
message: 'Failed to send Firebase message', | ||
notificationType: mockNotification.type, | ||
})); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
// Use the base configuration as-is. | ||
module.exports = require('./node_modules/@dydxprotocol-indexer/dev/jest.config'); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
// This function runs once before all tests. | ||
module.exports = () => { | ||
// This loads the environment variables for tests. | ||
// eslint-disable-next-line global-require | ||
require('dotenv-flow/config'); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
// This file runs before each test file. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
{ | ||
"name": "@dydxprotocol-indexer/notifications", | ||
"version": "0.0.1", | ||
"description": "", | ||
"main": "build/src/index.js", | ||
"devDependencies": { | ||
"@dydxprotocol-indexer/dev": "workspace:^0.0.1", | ||
"@types/jest": "^28.1.4", | ||
"jest": "^28.1.2", | ||
"typescript": "^4.7.4", | ||
"ts-node": "^10.8.2" | ||
}, | ||
"scripts": { | ||
"lint": "eslint --ext .ts,.js .", | ||
"lint:fix": "eslint --ext .ts,.js . --fix", | ||
"build": "rm -rf build/ && tsc", | ||
"build:prod": "pnpm run build", | ||
"build:watch": "pnpm run build -- --watch", | ||
"test": "NODE_ENV=test jest --runInBand --forceExit", | ||
"testNotification": "SERVICE_NAME=notifications ts-node src/local_test.ts TEST_ADDRESS" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/dydxprotocol/indexer.git" | ||
}, | ||
"author": "", | ||
"license": "AGPL-3.0", | ||
"bugs": { | ||
"url": "https://github.com/dydxprotocol/indexer/issues" | ||
}, | ||
"homepage": "https://github.com/dydxprotocol/indexer#readme", | ||
"dependencies": { | ||
"@dydxprotocol-indexer/base": "workspace:^0.0.1", | ||
"@dydxprotocol-indexer/postgres": "workspace:^0.0.1", | ||
"dotenv-flow": "^3.2.0" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
/** | ||
* Environment variables required for Notifications module. | ||
*/ | ||
|
||
import { | ||
parseString, | ||
parseSchema, | ||
baseConfigSchema, | ||
} from '@dydxprotocol-indexer/base'; | ||
|
||
export const notificationsConfigSchema = { | ||
...baseConfigSchema, | ||
|
||
// Private Key for the Google Firebase Messaging project | ||
FIREBASE_PRIVATE_KEY_BASE64: parseString({ default: 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClBMQUNFSE9MREVSX0tFWV9GT1JfREVWRUxPUE1FTlQKLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo=' }), | ||
|
||
// APP ID for the Google Firebase Messaging project | ||
FIREBASE_PROJECT_ID: parseString({ default: 'dydx-v4' }), | ||
|
||
// Client email for the Google Firebase Messaging project | ||
FIREBASE_CLIENT_EMAIL: parseString({ default: '[email protected]' }), | ||
}; | ||
|
||
export default parseSchema(notificationsConfigSchema); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export * from './lib/firebase'; | ||
export * from './localization'; | ||
export * from './types'; | ||
export * from './message'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import { logger } from '@dydxprotocol-indexer/base'; | ||
import { | ||
App, | ||
cert, | ||
initializeApp, | ||
ServiceAccount, | ||
} from 'firebase-admin/app'; | ||
import { getMessaging } from 'firebase-admin/messaging'; | ||
|
||
import config from '../config'; | ||
|
||
const initializeFirebaseApp = () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. existing syntax for functions is |
||
const defaultGoogleApplicationCredentials: { [key: string]: string } = { | ||
project_id: config.FIREBASE_PROJECT_ID, | ||
private_key: Buffer.from(config.FIREBASE_PRIVATE_KEY_BASE64, 'base64').toString('ascii').replace(/\\n/g, '\n'), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. comment for what this is doing/why needed |
||
client_email: config.FIREBASE_CLIENT_EMAIL, | ||
}; | ||
|
||
logger.info({ | ||
at: 'notifications#firebase', | ||
message: 'Initializing Firebase App', | ||
}); | ||
|
||
const serviceAccount: ServiceAccount = defaultGoogleApplicationCredentials; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need this line? combine with l13? |
||
|
||
let firebaseApp: App; | ||
try { | ||
firebaseApp = initializeApp({ | ||
credential: cert(serviceAccount), | ||
}); | ||
} catch (error) { | ||
logger.error({ | ||
at: 'notifications#firebase', | ||
message: 'Failed to initialize Firebase App', | ||
error, | ||
}); | ||
return undefined; | ||
} | ||
|
||
logger.info({ | ||
at: 'notifications#firebase', | ||
message: 'Firebase App initialized successfully', | ||
}); | ||
|
||
return firebaseApp; | ||
}; | ||
|
||
const firebaseApp = initializeFirebaseApp(); | ||
// Initialize Firebase Messaging if the app was initialized successfully | ||
let firebaseMessaging = null; | ||
if (firebaseApp) { | ||
try { | ||
firebaseMessaging = getMessaging(firebaseApp); | ||
logger.info({ | ||
at: 'notifications#firebase', | ||
message: 'Firebase Messaging initialized successfully', | ||
}); | ||
} catch (error) { | ||
logger.error({ | ||
at: 'notifications#firebase', | ||
message: 'Firebase Messaging failed to initialize', | ||
}); | ||
} | ||
} | ||
|
||
export const sendMulticast = firebaseMessaging | ||
? firebaseMessaging.sendMulticast.bind(firebaseMessaging) | ||
: () => { | ||
logger.error({ | ||
at: 'notifications#firebase', | ||
message: 'Firebase Messaging is not initialized, sendMulticast is a no-op', | ||
}); | ||
return Promise.resolve(null); | ||
}; | ||
export { BatchResponse, getMessaging, MulticastMessage } from 'firebase-admin/messaging'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does eng need access to this (eventually)?