Skip to content
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

Closed
wants to merge 37 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a72f374
Add notifications package
adamfraser Jul 25, 2024
fd9010d
Add firebase-admin dependency
adamfraser Jul 25, 2024
09440d3
Add register token endpoint
adamfraser Aug 8, 2024
8558d45
Add Tokens table
adamfraser Aug 8, 2024
ce9ce9a
Register token with postgres from comlink
adamfraser Aug 9, 2024
f813892
Can create and send notifications
adamfraser Aug 9, 2024
4ac8f09
Send notification test from ender
adamfraser Aug 13, 2024
b753be5
Wip deploy to dev4
adamfraser Aug 13, 2024
92a8b72
Rename app_id to project_id
adamfraser Aug 13, 2024
456c5dc
Fix lint error
adamfraser Aug 13, 2024
3a136e4
Dont use default firebase keys
adamfraser Aug 13, 2024
e211b3e
Logging
adamfraser Aug 13, 2024
3e6a674
Remove unused log
adamfraser Aug 13, 2024
9925faa
Add local env variables
adamfraser Aug 13, 2024
e02b84d
Hardcode account to test push
adamfraser Aug 14, 2024
da7ccb1
Add initial logging
adamfraser Aug 14, 2024
b63a9a8
Always init firebase
adamfraser Aug 15, 2024
6f6c82c
Revert "Hardcode account to test push"
adamfraser Aug 15, 2024
45b3a9f
Add test endpoint to trigger notification in comlink
adamfraser Aug 15, 2024
a8e8933
Logging name cleanup
adamfraser Aug 15, 2024
6a938d9
Remove notifications from ender
adamfraser Aug 15, 2024
79a8b25
Add fake test private key
adamfraser Aug 15, 2024
fb9cbb3
Parse private key as base64
adamfraser Aug 15, 2024
0dbf10a
Alternative key parsing approach
adamfraser Aug 15, 2024
6270e79
Test not trimming chars in container-run
adamfraser Aug 15, 2024
78bd29c
Revert "Test not trimming chars in container-run"
adamfraser Aug 16, 2024
865312e
Encode private key as base64
adamfraser Aug 16, 2024
1cd65b6
Dont log credentials
adamfraser Aug 16, 2024
0afa13f
Add initial tests for ender notification integrations
adamfraser Aug 19, 2024
e2b14ba
Fix import ordering
adamfraser Aug 19, 2024
6b068bc
Add order triggered notification and tests
adamfraser Aug 19, 2024
9a980a6
Lint fixes
adamfraser Aug 20, 2024
12f6871
Check if user has tokens before sending notification
adamfraser Aug 20, 2024
6243d74
Refactor
adamfraser Aug 20, 2024
ccb1f77
Add localization strings
adamfraser Aug 21, 2024
48b1be9
Add language to Token table
adamfraser Aug 21, 2024
00c396f
Revert "Wip deploy to dev4"
adamfraser Aug 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions indexer/Dockerfile.bazooka.remote
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ COPY ./packages/kafka/ ./packages/kafka/
COPY ./packages/redis/ ./packages/redis/
COPY ./services/bazooka/ ./services/bazooka/
COPY ./packages/v4-proto-parser/ ./packages/v4-proto-parser/
COPY ./packages/notifications/ ./packages/notifications/

# Copy tsconfig in order to build typescript into javascript
COPY tsconfig.json ./
Expand Down
2 changes: 2 additions & 0 deletions indexer/Dockerfile.service.local
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ COPY ./packages/redis/package.json ./packages/redis/
COPY ./packages/v4-protos/package.json ./packages/v4-protos/
COPY ./packages/v4-proto-parser/package.json ./packages/v4-proto-parser/package.json
COPY ./packages/compliance/package.json ./packages/compliance/
COPY ./packages/notifications/package.json ./packages/notifications/

# Copy build files from all packages being run
COPY ./packages/base/build ./packages/base/build/
Expand All @@ -33,6 +34,7 @@ COPY ./packages/redis/build ./packages/redis/build/
COPY ./packages/v4-protos/build ./packages/v4-protos/build/
COPY ./packages/v4-proto-parser/build ./packages/v4-proto-parser/build/
COPY ./packages/compliance/build ./packages/compliance/build/
COPY ./packages/notifications/build ./packages/notifications/build/

# Copy package.json, build files, and environment files from service being run
COPY ./services/${service}/package.json ./services/${service}/
Expand Down
2 changes: 2 additions & 0 deletions indexer/Dockerfile.service.remote
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ COPY ./packages/redis/package.json ./packages/redis/
COPY ./packages/v4-protos/package.json ./packages/v4-protos/
COPY ./packages/v4-proto-parser/package.json ./packages/v4-proto-parser/package.json
COPY ./packages/compliance/package.json ./packages/compliance/
COPY ./packages/notifications/package.json ./packages/notifications/

# Copy build files from all packages being run
COPY ./packages/base/build ./packages/base/build/
Expand All @@ -33,6 +34,7 @@ COPY ./packages/redis/build ./packages/redis/build/
COPY ./packages/v4-protos/build ./packages/v4-protos/build/
COPY ./packages/v4-proto-parser/build ./packages/v4-proto-parser/build/
COPY ./packages/compliance/build ./packages/compliance/build/
COPY ./packages/notifications/build ./packages/notifications/build/

# Copy package.json, build files, and environment files from service being run
COPY ./services/${service}/package.json ./services/${service}/
Expand Down
3 changes: 2 additions & 1 deletion indexer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@milahu/patch-package": "6.4.14"
"@milahu/patch-package": "6.4.14",
"firebase-admin": "^10.3.0"
},
"devDependencies": {
"@types/ws": "8.5.10"
Expand Down
3 changes: 3 additions & 0 deletions indexer/packages/notifications/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Service Level Variables

SERVICE_NAME=notifications
8 changes: 8 additions & 0 deletions indexer/packages/notifications/.env.development
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
Empty file.
10 changes: 10 additions & 0 deletions indexer/packages/notifications/.env.test
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
11 changes: 11 additions & 0 deletions indexer/packages/notifications/.eslintrc.js
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,
},
};
3 changes: 3 additions & 0 deletions indexer/packages/notifications/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Notifications

Notification package to create and send push notifications
65 changes: 65 additions & 0 deletions indexer/packages/notifications/__tests__/localization.test.ts
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);
});
});
});
});
79 changes: 79 additions & 0 deletions indexer/packages/notifications/__tests__/message.test.ts
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,
}));
});
});
2 changes: 2 additions & 0 deletions indexer/packages/notifications/jest.config.js
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');
6 changes: 6 additions & 0 deletions indexer/packages/notifications/jest.globalSetup.js
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');
};
1 change: 1 addition & 0 deletions indexer/packages/notifications/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// This file runs before each test file.
37 changes: 37 additions & 0 deletions indexer/packages/notifications/package.json
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"
}
}
24 changes: 24 additions & 0 deletions indexer/packages/notifications/src/config.ts
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' }),
Copy link
Contributor

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)?


// Client email for the Google Firebase Messaging project
FIREBASE_CLIENT_EMAIL: parseString({ default: '[email protected]' }),
};

export default parseSchema(notificationsConfigSchema);
4 changes: 4 additions & 0 deletions indexer/packages/notifications/src/index.ts
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';
75 changes: 75 additions & 0 deletions indexer/packages/notifications/src/lib/firebase.ts
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 = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

existing syntax for functions is function initializeFirebaseApp(): void {

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'),
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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';
Loading
Loading