diff --git a/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts b/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts index dd9214e904..b767230b0b 100644 --- a/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts +++ b/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts @@ -21,6 +21,7 @@ export async function findAll( { address, limit, + updatedBeforeOrAt, }: FirebaseNotificationTokenQueryConfig, requiredFields: QueryableField[], options: Options = DEFAULT_POSTGRES_OPTIONS, @@ -42,6 +43,10 @@ export async function findAll( baseQuery = baseQuery.where(FirebaseNotificationTokenColumns.address, address); } + if (updatedBeforeOrAt) { + baseQuery = baseQuery.where(FirebaseNotificationTokenColumns.updatedAt, '<=', updatedBeforeOrAt); + } + if (options.orderBy !== undefined) { for (const [column, order] of options.orderBy) { baseQuery = baseQuery.orderBy( diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts index 66e059fb36..d52f90ca12 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -342,4 +342,5 @@ export interface AffiliateInfoQueryConfig extends QueryConfig { export interface FirebaseNotificationTokenQueryConfig extends QueryConfig { [QueryableField.ADDRESS]?: string, [QueryableField.TOKEN]?: string, + [QueryableField.UPDATED_BEFORE_OR_AT]?: IsoString, } diff --git a/indexer/pnpm-lock.yaml b/indexer/pnpm-lock.yaml index 25c6981e7e..238ed2e048 100644 --- a/indexer/pnpm-lock.yaml +++ b/indexer/pnpm-lock.yaml @@ -448,6 +448,7 @@ importers: '@dydxprotocol-indexer/base': workspace:^0.0.1 '@dydxprotocol-indexer/compliance': workspace:^0.0.1 '@dydxprotocol-indexer/dev': workspace:^0.0.1 + '@dydxprotocol-indexer/notifications': workspace:^0.0.1 '@dydxprotocol-indexer/postgres': workspace:^0.0.1 '@dydxprotocol-indexer/redis': workspace:^0.0.1 '@dydxprotocol-indexer/v4-proto-parser': workspace:^0.0.1 @@ -499,6 +500,7 @@ importers: '@cosmjs/encoding': 0.32.3 '@dydxprotocol-indexer/base': link:../../packages/base '@dydxprotocol-indexer/compliance': link:../../packages/compliance + '@dydxprotocol-indexer/notifications': link:../../packages/notifications '@dydxprotocol-indexer/postgres': link:../../packages/postgres '@dydxprotocol-indexer/redis': link:../../packages/redis '@dydxprotocol-indexer/v4-proto-parser': link:../../packages/v4-proto-parser diff --git a/indexer/services/comlink/.env.test b/indexer/services/comlink/.env.test index 167901ba36..30ae74517a 100644 --- a/indexer/services/comlink/.env.test +++ b/indexer/services/comlink/.env.test @@ -7,3 +7,6 @@ DB_PORT=5436 RATE_LIMIT_ENABLED=false INDEXER_LEVEL_GEOBLOCKING_ENABLED=false EXPOSE_SET_COMPLIANCE_ENDPOINT=true +FIREBASE_PROJECT_ID=projectID +FIREBASE_PRIVATE_KEY='-----BEGIN RSA PRIVATE KEY----------END RSA PRIVATE KEY-----' +FIREBASE_CLIENT_EMAIL=clientEmail@test.com diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts index 9930eb80df..6a7cb0d8b6 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts @@ -10,11 +10,13 @@ import { BlockTable, liquidityTierRefresher, SubaccountTable, + FirebaseNotificationTokenTable, } from '@dydxprotocol-indexer/postgres'; import { RequestMethod } from '../../../../src/types'; import request from 'supertest'; import { getFixedRepresentation, sendRequest } from '../../../helpers/helpers'; import { stats } from '@dydxprotocol-indexer/base'; +import config from '../../../../src/config'; describe('addresses-controller#V4', () => { const latestHeight: string = '3'; @@ -42,6 +44,7 @@ describe('addresses-controller#V4', () => { afterEach(async () => { await dbHelpers.clearData(); + jest.clearAllMocks(); }); const invalidAddress: string = 'invalidAddress'; @@ -574,4 +577,142 @@ describe('addresses-controller#V4', () => { }); }); + describe('/:address/testNotification', () => { + it('Post /:address/testNotification throws error in production', async () => { + // Mock the config to simulate production environment + const originalNodeEnv = config.NODE_ENV; + config.NODE_ENV = 'production'; + + const response: request.Response = await sendRequest({ + type: RequestMethod.POST, + path: `/v4/addresses/${testConstants.defaultAddress}/testNotification`, + expectedStatus: 404, + }); + + expect(response.statusCode).toEqual(404); + // Restore the original NODE_ENV + config.NODE_ENV = originalNodeEnv; + }); + }); + + describe('/:address/registerToken', () => { + it('Post /:address/registerToken with valid params returns 200', async () => { + const token = 'validToken'; + const language = 'en'; + const response: request.Response = await sendRequest({ + type: RequestMethod.POST, + path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`, + body: { token, language }, + expectedStatus: 200, + }); + + expect(response.body).toEqual({}); + expect(stats.increment).toHaveBeenCalledWith('comlink.addresses-controller.response_status_code.200', 1, { + path: '/:address/registerToken', + method: 'POST', + }); + }); + + it('should register a new token', async () => { + // Register a new token + const newToken = 'newToken'; + const language = 'en'; + await sendRequest({ + type: RequestMethod.POST, + path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`, + body: { token: newToken, language }, + expectedStatus: 200, + }); + + // Check that old tokens are deleted and new token is registered + const remainingTokens = await FirebaseNotificationTokenTable.findAll({}, []); + expect(remainingTokens.map((t) => t.token)).toContain(newToken); + }); + + it('Post /:address/registerToken with valid params calls TokenTable registerToken', async () => { + jest.spyOn(FirebaseNotificationTokenTable, 'registerToken'); + const token = 'validToken'; + const language = 'en'; + await sendRequest({ + type: RequestMethod.POST, + path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`, + body: { token, language }, + expectedStatus: 200, + }); + expect(FirebaseNotificationTokenTable.registerToken).toHaveBeenCalledWith( + token, testConstants.defaultAddress, language, + ); + expect(stats.increment).toHaveBeenCalledWith('comlink.addresses-controller.response_status_code.200', 1, { + path: '/:address/registerToken', + method: 'POST', + }); + }); + + it('Post /:address/registerToken with invalid address returns 404', async () => { + const token = 'validToken'; + const response: request.Response = await sendRequest({ + type: RequestMethod.POST, + path: `/v4/addresses/${invalidAddress}/registerToken`, + body: { token }, + expectedStatus: 404, + }); + + expect(response.body).toEqual({ + errors: [ + { + msg: 'No wallet found with address: invalidAddress', + }, + ], + }); + expect(stats.increment).toHaveBeenCalledWith('comlink.addresses-controller.response_status_code.404', 1, { + path: '/:address/registerToken', + method: 'POST', + }); + }); + + it.each([ + ['validToken', '', 'Invalid language code', 'language'], + ['validToken', 'qq', 'Invalid language code', 'language'], + ])('Post /:address/registerToken with bad language params returns 400', async (token, language, errorMsg, errorParam) => { + const response: request.Response = await sendRequest({ + type: RequestMethod.POST, + path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`, + body: { token, language }, + expectedStatus: 400, + }); + + expect(response.body).toEqual({ + errors: [ + { + location: 'body', + msg: errorMsg, + param: errorParam, + value: language, + }, + ], + }); + }); + + it.each([ + ['', 'en', 'Token cannot be empty', 'token'], + ])('Post /:address/registerToken with bad token params returns 400', async (token, language, errorMsg, errorParam) => { + const response: request.Response = await sendRequest({ + type: RequestMethod.POST, + path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`, + body: { token, language }, + expectedStatus: 400, + }); + + expect(response.body).toEqual({ + errors: [ + { + location: 'body', + msg: errorMsg, + param: errorParam, + value: token, + }, + ], + }); + }); + }); }); diff --git a/indexer/services/comlink/package.json b/indexer/services/comlink/package.json index ba12e7064b..29ea1def60 100644 --- a/indexer/services/comlink/package.json +++ b/indexer/services/comlink/package.json @@ -33,6 +33,7 @@ "@dydxprotocol-indexer/v4-proto-parser": "workspace:^0.0.1", "@dydxprotocol-indexer/v4-protos": "workspace:^0.0.1", "@keplr-wallet/cosmos": "^0.12.122", + "@dydxprotocol-indexer/notifications": "workspace:^0.0.1", "@tsoa/runtime": "^5.0.0", "big.js": "^6.2.1", "body-parser": "^1.20.0", diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md index b047e1291a..0439c0e85f 100644 --- a/indexer/services/comlink/public/api-documentation.md +++ b/indexer/services/comlink/public/api-documentation.md @@ -368,6 +368,141 @@ fetch(`${baseURL}/addresses/{address}/parentSubaccountNumber/{parentSubaccountNu This operation does not require authentication +## RegisterToken + + + +> Code samples + +```python +import requests +headers = { + 'Content-Type': 'application/json' +} + +# For the deployment by DYDX token holders, use +# baseURL = 'https://indexer.dydx.trade/v4' +baseURL = 'https://dydx-testnet.imperator.co/v4' + +r = requests.post(f'{baseURL}/addresses/{address}/registerToken', headers = headers) + +print(r.json()) + +``` + +```javascript +const inputBody = '{ + "language": "string", + "token": "string" +}'; +const headers = { + 'Content-Type':'application/json' +}; + +// For the deployment by DYDX token holders, use +// const baseURL = 'https://indexer.dydx.trade/v4'; +const baseURL = 'https://dydx-testnet.imperator.co/v4'; + +fetch(`${baseURL}/addresses/{address}/registerToken`, +{ + method: 'POST', + body: inputBody, + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`POST /addresses/{address}/registerToken` + +> Body parameter + +```json +{ + "language": "string", + "token": "string" +} +``` + +### Parameters + +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|address|path|string|true|none| +|body|body|object|true|none| +|» language|body|string|true|none| +|» token|body|string|true|none| + +### Responses + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|204|[No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5)|No content|None| + + + +## TestNotification + + + +> Code samples + +```python +import requests + +# For the deployment by DYDX token holders, use +# baseURL = 'https://indexer.dydx.trade/v4' +baseURL = 'https://dydx-testnet.imperator.co/v4' + +r = requests.post(f'{baseURL}/addresses/{address}/testNotification') + +print(r.json()) + +``` + +```javascript + +// For the deployment by DYDX token holders, use +// const baseURL = 'https://indexer.dydx.trade/v4'; +const baseURL = 'https://dydx-testnet.imperator.co/v4'; + +fetch(`${baseURL}/addresses/{address}/testNotification`, +{ + method: 'POST' + +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`POST /addresses/{address}/testNotification` + +### Parameters + +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|address|path|string|true|none| + +### Responses + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|204|[No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5)|No content|None| + + + ## GetMetadata diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json index 628991933a..5fc7a5cff8 100644 --- a/indexer/services/comlink/public/swagger.json +++ b/indexer/services/comlink/public/swagger.json @@ -1679,6 +1679,70 @@ ] } }, + "/addresses/{address}/registerToken": { + "post": { + "operationId": "RegisterToken", + "responses": { + "204": { + "description": "No content" + } + }, + "security": [], + "parameters": [ + { + "in": "path", + "name": "address", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "language": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "language", + "token" + ], + "type": "object" + } + } + } + } + } + }, + "/addresses/{address}/testNotification": { + "post": { + "operationId": "TestNotification", + "responses": { + "204": { + "description": "No content" + } + }, + "security": [], + "parameters": [ + { + "in": "path", + "name": "address", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/affiliates/metadata": { "get": { "operationId": "GetMetadata", diff --git a/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts b/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts index 7847a1cf16..509daf0fcd 100644 --- a/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts @@ -1,4 +1,7 @@ -import { stats } from '@dydxprotocol-indexer/base'; +import { logger, NodeEnv, stats } from '@dydxprotocol-indexer/base'; +import { + createNotification, NotificationType, NotificationDynamicFieldKey, sendFirebaseMessage, +} from '@dydxprotocol-indexer/notifications'; import { AssetPositionFromDatabase, BlockTable, @@ -20,6 +23,7 @@ import { WalletTable, WalletFromDatabase, perpetualMarketRefresher, + FirebaseNotificationTokenTable, } from '@dydxprotocol-indexer/postgres'; import Big from 'big.js'; import express from 'express'; @@ -28,12 +32,14 @@ import { } from 'express-validator'; import { Route, Get, Path, Controller, + Post, + Body, } from 'tsoa'; import { getReqRateLimiter } from '../../../caches/rate-limiters'; import config from '../../../config'; import { complianceAndGeoCheck } from '../../../lib/compliance-and-geo-check'; -import { NotFoundError } from '../../../lib/errors'; +import { DatabaseError, NotFoundError } from '../../../lib/errors'; import { getFundingIndexMaps, handleControllerError, @@ -41,7 +47,12 @@ import { getSubaccountResponse, } from '../../../lib/helpers'; import { rateLimiterMiddleware } from '../../../lib/rate-limit'; -import { CheckAddressSchema, CheckParentSubaccountSchema, CheckSubaccountSchema } from '../../../lib/validation/schemas'; +import { + CheckAddressSchema, + CheckParentSubaccountSchema, + CheckSubaccountSchema, + RegisterTokenValidationSchema, +} from '../../../lib/validation/schemas'; import { handleValidationErrors } from '../../../request-helpers/error-handler'; import ExportResponseCodeStats from '../../../request-helpers/export-response-code-stats'; import { @@ -51,6 +62,7 @@ import { AddressResponse, ParentSubaccountResponse, ParentSubaccountRequest, + RegisterTokenRequest, } from '../../../types'; const router: express.Router = express.Router(); @@ -294,6 +306,59 @@ class AddressesController extends Controller { childSubaccounts: subaccountResponses, }; } + + @Post('/:address/registerToken') + public async registerToken( + @Path() address: string, + @Body() body: { token: string, language: string }, + ): Promise { + const { token, language } = body; + const wallet = await WalletTable.findById(address); + if (!wallet) { + throw new NotFoundError(`No wallet found with address: ${address}`); + } + try { + // Register the new token + await FirebaseNotificationTokenTable.registerToken( + token, + wallet.address, + language, + ); + } catch (error) { + throw new DatabaseError(`Error registering token: ${error}`); + } + } + + @Post('/:address/testNotification') + public async testNotification( + @Path() address: string, + ): Promise { + try { + const wallet = await WalletTable.findById(address); + if (!wallet) { + throw new NotFoundError(`No wallet found for address: ${address}`); + } + const allTokens = await FirebaseNotificationTokenTable.findAll( + { address: wallet.address }, [], + ); + if (allTokens.length === 0) { + throw new NotFoundError(`No tokens found for address: ${address}`); + } + + const notification = createNotification(NotificationType.ORDER_FILLED, { + [NotificationDynamicFieldKey.MARKET]: 'BTC/USD', + [NotificationDynamicFieldKey.AMOUNT]: '100', + [NotificationDynamicFieldKey.AVERAGE_PRICE]: '1000', + }); + await sendFirebaseMessage(allTokens, notification); + } catch (error) { + logger.error({ + at: 'addresses-controller#testNotification', + message: error.message, + error, + }); + } + } } router.get( @@ -426,6 +491,67 @@ router.get( }, ); +router.post( + '/:address/registerToken', + CheckAddressSchema, + RegisterTokenValidationSchema, + handleValidationErrors, + ExportResponseCodeStats({ controllerName }), + async (req: express.Request, res: express.Response) => { + const start: number = Date.now(); + const { address, token, language = 'en' } = matchedData(req) as RegisterTokenRequest; + + try { + const controller: AddressesController = new AddressesController(); + await controller.registerToken(address, { token, language }); + return res.status(200).send({}); + } catch (error) { + return handleControllerError( + 'AddressesController POST /:address/registerToken', + 'Addresses error', + error, + req, + res, + ); + } finally { + stats.timing( + `${config.SERVICE_NAME}.${controllerName}.post_registerToken.timing`, + Date.now() - start, + ); + } + }, +); + +router.post( + '/:address/testNotification', + rateLimiterMiddleware(getReqRateLimiter), + ...CheckAddressSchema, + handleValidationErrors, + ExportResponseCodeStats({ controllerName }), + async (req: express.Request, res: express.Response) => { + // This endpoint should only be avaliable in testnet / staging + if (config.NODE_ENV === NodeEnv.PRODUCTION) { + return res.status(404).send(); + } + + const { address } = matchedData(req) as AddressRequest; + + try { + const controller: AddressesController = new AddressesController(); + await controller.testNotification(address); + return res.status(200).send({ message: 'Test notification sent successfully' }); + } catch (error) { + return handleControllerError( + 'AddressesController POST /:address/testNotification', + 'Test notification error', + error, + req, + res, + ); + } + }, +); + // eslint-disable-next-line @typescript-eslint/require-await async function getOpenPerpetualPositionsForSubaccount( subaccountId: string, diff --git a/indexer/services/comlink/src/lib/errors.ts b/indexer/services/comlink/src/lib/errors.ts index 8081c3fee4..19a0971dee 100644 --- a/indexer/services/comlink/src/lib/errors.ts +++ b/indexer/services/comlink/src/lib/errors.ts @@ -18,3 +18,24 @@ export class NotFoundError extends Error { this.name = 'NotFoundError'; } } + +export class BadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = 'BadRequestError'; + } +} + +export class DatabaseError extends Error { + constructor(message: string) { + super(message); + this.name = 'DatabaseError'; + } +} + +export class InvalidParamError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidParamError'; + } +} diff --git a/indexer/services/comlink/src/lib/helpers.ts b/indexer/services/comlink/src/lib/helpers.ts index 5041c6fb11..6da4106907 100644 --- a/indexer/services/comlink/src/lib/helpers.ts +++ b/indexer/services/comlink/src/lib/helpers.ts @@ -47,7 +47,7 @@ import { SubaccountResponseObject, } from '../types'; import { ZERO, ZERO_USDC_POSITION } from './constants'; -import { NotFoundError } from './errors'; +import { InvalidParamError, NotFoundError } from './errors'; /* ------- GENERIC HELPERS ------- */ @@ -68,6 +68,9 @@ export function handleControllerError( if (error instanceof NotFoundError) { return handleNotFoundError(error.message, res); } + if (error instanceof InvalidParamError) { + return handleInvalidParamError(error.message, res); + } return handleInternalServerError( at, message, @@ -100,6 +103,17 @@ function handleInternalServerError( return createInternalServerErrorResponse(res); } +function handleInvalidParamError( + message: string, + res: express.Response, +): express.Response { + return res.status(400).json({ + errors: [{ + msg: message, + }], + }); +} + function handleNotFoundError( message: string, res: express.Response, diff --git a/indexer/services/comlink/src/lib/validation/schemas.ts b/indexer/services/comlink/src/lib/validation/schemas.ts index 6a2d79d11b..d954f31cec 100644 --- a/indexer/services/comlink/src/lib/validation/schemas.ts +++ b/indexer/services/comlink/src/lib/validation/schemas.ts @@ -1,9 +1,10 @@ +import { isValidLanguageCode } from '@dydxprotocol-indexer/notifications'; import { perpetualMarketRefresher, MAX_PARENT_SUBACCOUNTS, CHILD_SUBACCOUNT_MULTIPLIER, } from '@dydxprotocol-indexer/postgres'; -import { checkSchema, ParamSchema } from 'express-validator'; +import { body, checkSchema, ParamSchema } from 'express-validator'; import config from '../../config'; @@ -212,3 +213,22 @@ export const CheckHistoricalBlockTradingRewardsSchema = checkSchema({ }); export const CheckTransferBetweenSchema = checkSchema(transferBetweenSchemaRecord); + +export const RegisterTokenValidationSchema = [ + body('token') + .exists().withMessage('Token is required') + .isString() + .withMessage('Token must be a string') + .notEmpty() + .withMessage('Token cannot be empty'), + body('language') + .optional() + .isString() + .withMessage('Language must be a string') + .custom((value: string) => { + if (!isValidLanguageCode(value)) { + throw new Error('Invalid language code'); + } + return true; + }), +]; diff --git a/indexer/services/comlink/src/types.ts b/indexer/services/comlink/src/types.ts index 9e502e7cd6..2867e0827b 100644 --- a/indexer/services/comlink/src/types.ts +++ b/indexer/services/comlink/src/types.ts @@ -550,6 +550,12 @@ export interface HistoricalFundingRequest extends LimitAndEffectiveBeforeRequest ticker: string, } +export interface RegisterTokenRequest { + address: string, + token: string, + language: string, +} + /* ------- COLLATERALIZATION TYPES ------- */ export interface Risk { diff --git a/indexer/services/roundtable/__tests__/tasks/delete-old-firebase-notification-tokens.test.ts b/indexer/services/roundtable/__tests__/tasks/delete-old-firebase-notification-tokens.test.ts new file mode 100644 index 0000000000..3b3b3ff581 --- /dev/null +++ b/indexer/services/roundtable/__tests__/tasks/delete-old-firebase-notification-tokens.test.ts @@ -0,0 +1,64 @@ +import { stats } from '@dydxprotocol-indexer/base'; +import { dbHelpers, FirebaseNotificationTokenTable, testMocks } from '@dydxprotocol-indexer/postgres'; + +import config from '../../src/config'; +import runTask from '../../src/tasks/delete-old-firebase-notification-tokens'; +import { defaultWallet } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; + +describe('delete-old-firebase-notification-tokens', () => { + beforeAll(async () => { + await dbHelpers.migrate(); + jest.spyOn(stats, 'timing'); + }); + + beforeEach(async () => { + await testMocks.seedData(); + }); + + afterEach(async () => { + await dbHelpers.clearData(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await dbHelpers.teardown(); + jest.resetAllMocks(); + }); + + it('deletes old Firebase notification tokens', async () => { + // Create test data + const currentDate = new Date(); + const oldDate = new Date(currentDate.getTime() - 40 * 24 * 60 * 60 * 1000); // 40 days ago + const recentDate = new Date(currentDate.getTime() - 15 * 24 * 60 * 60 * 1000); // 15 days ago + + await FirebaseNotificationTokenTable.create({ + token: 'old_token', + updatedAt: oldDate.toISOString(), + address: defaultWallet.address, + language: 'en', + }); + await FirebaseNotificationTokenTable.create({ + token: 'recent_token', + updatedAt: recentDate.toISOString(), + address: defaultWallet.address, + language: 'fr', + }); + + const initialTokens = await FirebaseNotificationTokenTable.findAll({}, []); + expect(initialTokens.length).toBe(3); + + // Run the task + await runTask(); + + // Check if old token was deleted and recent token remains + const remainingTokens = await FirebaseNotificationTokenTable.findAll({}, []); + expect(remainingTokens.length).toBe(2); + expect(remainingTokens[0].token).toBe('recent_token'); + + // Check if stats.timing was called + expect(stats.timing).toHaveBeenCalledWith( + expect.stringContaining(`${config.SERVICE_NAME}.delete_old_firebase_notification_tokens`), + expect.any(Number), + ); + }); +}); diff --git a/indexer/services/roundtable/src/config.ts b/indexer/services/roundtable/src/config.ts index a0f3a03bb5..2e712c54ac 100644 --- a/indexer/services/roundtable/src/config.ts +++ b/indexer/services/roundtable/src/config.ts @@ -58,6 +58,7 @@ export const configSchema = { LOOPS_ENABLED_LEADERBOARD_PNL_MONTHLY: parseBoolean({ default: false }), LOOPS_ENABLED_LEADERBOARD_PNL_YEARLY: parseBoolean({ default: false }), LOOPS_ENABLED_UPDATE_WALLET_TOTAL_VOLUME: parseBoolean({ default: true }), + LOOPS_ENABLED_DELETE_OLD_FIREBASE_NOTIFICATION_TOKENS: parseBoolean({ default: true }), // Loop Timing LOOPS_INTERVAL_MS_MARKET_UPDATER: parseInteger({ @@ -129,6 +130,9 @@ export const configSchema = { LOOPS_INTERVAL_MS_UPDATE_WALLET_TOTAL_VOLUME: parseInteger({ default: THIRTY_SECONDS_IN_MILLISECONDS, }), + LOOPS_INTERVAL_MS_DELETE_FIREBASE_NOTIFICATION_TOKENS_MONTHLY: parseInteger({ + default: 30 * ONE_DAY_IN_MILLISECONDS, + }), // Start delay START_DELAY_ENABLED: parseBoolean({ default: true }), diff --git a/indexer/services/roundtable/src/index.ts b/indexer/services/roundtable/src/index.ts index c36af62b5a..09b83c5e77 100644 --- a/indexer/services/roundtable/src/index.ts +++ b/indexer/services/roundtable/src/index.ts @@ -14,6 +14,7 @@ import cancelStaleOrdersTask from './tasks/cancel-stale-orders'; import createLeaderboardTask from './tasks/create-leaderboard'; import createPnlTicksTask from './tasks/create-pnl-ticks'; import deleteOldFastSyncSnapshots from './tasks/delete-old-fast-sync-snapshots'; +import deleteOldFirebaseNotificationTokensTask from './tasks/delete-old-firebase-notification-tokens'; import deleteZeroPriceLevelsTask from './tasks/delete-zero-price-levels'; import marketUpdaterTask from './tasks/market-updater'; import orderbookInstrumentationTask from './tasks/orderbook-instrumentation'; @@ -256,6 +257,14 @@ async function start(): Promise { ); } + if (config.LOOPS_ENABLED_DELETE_OLD_FIREBASE_NOTIFICATION_TOKENS) { + startLoop( + deleteOldFirebaseNotificationTokensTask, + 'delete-old-firebase-notification-tokens', + config.LOOPS_INTERVAL_MS_DELETE_FIREBASE_NOTIFICATION_TOKENS_MONTHLY, + ); + } + logger.info({ at: 'index', message: 'Successfully started', diff --git a/indexer/services/roundtable/src/tasks/delete-old-firebase-notification-tokens.ts b/indexer/services/roundtable/src/tasks/delete-old-firebase-notification-tokens.ts new file mode 100644 index 0000000000..a29148dad5 --- /dev/null +++ b/indexer/services/roundtable/src/tasks/delete-old-firebase-notification-tokens.ts @@ -0,0 +1,38 @@ +import { logger, stats } from '@dydxprotocol-indexer/base'; +import { FirebaseNotificationTokenTable } from '@dydxprotocol-indexer/postgres'; + +import config from '../config'; + +const statStart: string = `${config.SERVICE_NAME}.delete_old_firebase_notification_tokens`; + +export default async function runTask(): Promise { + const at: string = 'delete-old-firebase-notification-tokens#runTask'; + const startDeleteOldFirebase: number = Date.now(); + // Delete old snapshots. + stats.timing(statStart, Date.now() - startDeleteOldFirebase); + + try { + // Delete tokens older than a month + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + const tokensToDelete = await FirebaseNotificationTokenTable.findAll( + { + updatedBeforeOrAt: oneMonthAgo.toISOString(), + }, + [], + ); + + if (tokensToDelete.length > 0) { + await FirebaseNotificationTokenTable.deleteMany( + tokensToDelete.map((tokenRecord) => tokenRecord.token), + ).then((count) => { + stats.increment(`${config.SERVICE_NAME}.firebase_notification_tokens_deleted`, count); + }); + } + } catch (error) { + logger.info({ at, error, message: 'Failed to delete old Firebase notification tokens' }); + } finally { + stats.timing(statStart, Date.now() - startDeleteOldFirebase); + } +}