diff --git a/indexer/packages/notifications/__tests__/message.test.ts b/indexer/packages/notifications/__tests__/message.test.ts index 74e60f6dce..c635c93058 100644 --- a/indexer/packages/notifications/__tests__/message.test.ts +++ b/indexer/packages/notifications/__tests__/message.test.ts @@ -41,7 +41,10 @@ describe('sendFirebaseMessage', () => { }); it('should send a Firebase message successfully', async () => { - await sendFirebaseMessage([defaultToken.token], mockNotification); + await sendFirebaseMessage( + [{ token: defaultToken.token, language: defaultToken.language }], + mockNotification, + ); expect(sendMulticast).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ @@ -63,7 +66,10 @@ describe('sendFirebaseMessage', () => { const mockedSendMulticast = sendMulticast as jest.MockedFunction; mockedSendMulticast.mockRejectedValueOnce(new Error('Send failed')); - await sendFirebaseMessage([defaultToken.token], mockNotification); + await sendFirebaseMessage( + [{ token: defaultToken.token, language: defaultToken.language }], + mockNotification, + ); expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to send Firebase message', diff --git a/indexer/packages/notifications/src/localizedMessages.ts b/indexer/packages/notifications/src/localizedMessages.ts index 5d0d55c80a..3bc980f7e4 100644 --- a/indexer/packages/notifications/src/localizedMessages.ts +++ b/indexer/packages/notifications/src/localizedMessages.ts @@ -66,4 +66,4 @@ export const LOCALIZED_MESSAGES: Record { // Re-add once stats are implemented @@ -24,11 +24,15 @@ export async function sendFirebaseMessage( return; } - const { title, body } = deriveLocalizedNotificationMessage(notification); + const language = tokens[0].language; + const { title, body } = deriveLocalizedNotificationMessage( + notification, + language as LanguageCode, + ); const link = notification.deeplink; const message: MulticastMessage = { - tokens, + tokens: tokens.map((token) => token.token), notification: { title, body, diff --git a/indexer/packages/notifications/src/types.ts b/indexer/packages/notifications/src/types.ts index f7145c9183..a41b80eb35 100644 --- a/indexer/packages/notifications/src/types.ts +++ b/indexer/packages/notifications/src/types.ts @@ -149,4 +149,4 @@ export function createNotification( default: throw new Error('Unknown notification type'); } -} \ No newline at end of file +} diff --git a/indexer/packages/postgres/__tests__/helpers/constants.ts b/indexer/packages/postgres/__tests__/helpers/constants.ts index 00a8b54530..3751be9812 100644 --- a/indexer/packages/postgres/__tests__/helpers/constants.ts +++ b/indexer/packages/postgres/__tests__/helpers/constants.ts @@ -902,5 +902,6 @@ export const defaultLeaderboardPnlOneDayToUpsert: LeaderboardPnlCreateObject = { export const defaultToken = { token: 'DEFAULT_TOKEN', address: defaultAddress, + language: 'en', updatedAt: createdDateTime.toISO(), }; diff --git a/indexer/packages/postgres/__tests__/stores/token-table.test.ts b/indexer/packages/postgres/__tests__/stores/token-table.test.ts index 80b10b8091..bb4c3062b2 100644 --- a/indexer/packages/postgres/__tests__/stores/token-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/token-table.test.ts @@ -38,7 +38,7 @@ describe('Token store', () => { expect(token).toEqual(expect.objectContaining(defaultToken)); // Upsert again to test update functionality - const updatedToken = { ...defaultToken, updatedAt: new Date().toISOString() }; + const updatedToken = { ...defaultToken, updatedAt: new Date().toISOString(), language: 'es' }; await TokenTable.upsert(updatedToken); token = await TokenTable.findByToken(defaultToken.token); @@ -50,6 +50,7 @@ describe('Token store', () => { const additionalToken = { token: 'fake_token', address: defaultAddress2, + language: 'en', updatedAt: new Date().toISOString(), }; diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20240809153326_create_tokens_table.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20240809153326_create_tokens_table.ts index bdf7b472a3..045df1e1e0 100644 --- a/indexer/packages/postgres/src/db/migrations/migration_files/20240809153326_create_tokens_table.ts +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20240809153326_create_tokens_table.ts @@ -6,6 +6,7 @@ export async function up(knex: Knex): Promise { table.string('token').notNullable().unique(); table.string('address').notNullable(); table.foreign('address').references('wallets.address').onDelete('CASCADE'); + table.string('language').notNullable(); table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()); }); } diff --git a/indexer/packages/postgres/src/models/token-model.ts b/indexer/packages/postgres/src/models/token-model.ts index 962b420036..b1d63fd14a 100644 --- a/indexer/packages/postgres/src/models/token-model.ts +++ b/indexer/packages/postgres/src/models/token-model.ts @@ -10,6 +10,7 @@ class TokenModel extends Model { token!: string; address!: string; updatedAt!: IsoString; + language!: string; static relationMappings = { wallet: { diff --git a/indexer/packages/postgres/src/stores/token-table.ts b/indexer/packages/postgres/src/stores/token-table.ts index ee79b4942e..f2881e1cb3 100644 --- a/indexer/packages/postgres/src/stores/token-table.ts +++ b/indexer/packages/postgres/src/stores/token-table.ts @@ -117,6 +117,7 @@ export async function findByToken( export async function registerToken( token: string, address: string, + language: string, options: Options = { txId: undefined }, ): Promise { return upsert( @@ -124,6 +125,7 @@ export async function registerToken( token, address, updatedAt: DateTime.now().toISO(), + language, }, options, ); diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index f5a7bd1a39..42e60a28ab 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -272,6 +272,7 @@ export interface TokenFromDatabase { address: WalletFromDatabase['address'], token: string, updatedAt: IsoString, + language: string, } export type SubaccountAssetNetTransferMap = { [subaccountId: string]: diff --git a/indexer/packages/postgres/src/types/token-types.ts b/indexer/packages/postgres/src/types/token-types.ts index 2f8743a4c5..041742358f 100644 --- a/indexer/packages/postgres/src/types/token-types.ts +++ b/indexer/packages/postgres/src/types/token-types.ts @@ -5,17 +5,20 @@ type IsoString = string; export interface TokenCreateObject { token: string, address: string, + language: string, updatedAt: IsoString, } export interface TokenUpdateObject { token: string, address: string, + language: string, updatedAt: IsoString, } export enum TokenColumns { token = 'token', address = 'address', + language = 'language', updatedAt = 'updatedAt', } 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 f631fed5e9..bf83d3019d 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 @@ -579,10 +579,11 @@ describe('addresses-controller#V4', () => { 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 }, + body: { token, language }, expectedStatus: 200, }); @@ -596,14 +597,15 @@ describe('addresses-controller#V4', () => { it('Post /:address/registerToken with valid params calls TokenTable registerToken', async () => { jest.spyOn(TokenTable, 'registerToken'); const token = 'validToken'; + const language = 'en'; await sendRequest({ type: RequestMethod.POST, path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`, - body: { token }, + body: { token, language }, expectedStatus: 200, }); expect(TokenTable.registerToken).toHaveBeenCalledWith( - token, testConstants.defaultAddress, + token, testConstants.defaultAddress, language, ); expect(stats.increment).toHaveBeenCalledWith('comlink.addresses-controller.response_status_code.200', 1, { path: '/:address/registerToken', @@ -633,12 +635,36 @@ describe('addresses-controller#V4', () => { }); }); - it('Post /:address/registerToken with no token in body returns 400', async () => { - const token = ''; + 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 }, + 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, }); @@ -646,9 +672,9 @@ describe('addresses-controller#V4', () => { errors: [ { location: 'body', - msg: 'Token cannot be empty', - param: 'token', - value: '', + msg: errorMsg, + param: errorParam, + value: token, }, ], }); diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md index cd231df8be..cdf613580a 100644 --- a/indexer/services/comlink/public/api-documentation.md +++ b/indexer/services/comlink/public/api-documentation.md @@ -455,6 +455,7 @@ print(r.json()) ```javascript const inputBody = '{ + "language": "string", "token": "string" }'; const headers = { @@ -485,6 +486,7 @@ fetch(`${baseURL}/addresses/{address}/registerToken`, ```json { + "language": "string", "token": "string" } ``` @@ -495,6 +497,7 @@ fetch(`${baseURL}/addresses/{address}/registerToken`, |---|---|---|---|---| |address|path|string|true|none| |body|body|object|true|none| +|» language|body|string|true|none| |» token|body|string|true|none| ### Responses diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json index 5a48943fdb..260da43dd7 100644 --- a/indexer/services/comlink/public/swagger.json +++ b/indexer/services/comlink/public/swagger.json @@ -1488,11 +1488,15 @@ "application/json": { "schema": { "properties": { + "language": { + "type": "string" + }, "token": { "type": "string" } }, "required": [ + "language", "token" ], "type": "object" 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 9786172b6a..5325238f65 100644 --- a/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts @@ -44,7 +44,7 @@ import { import { getReqRateLimiter } from '../../../caches/rate-limiters'; import config from '../../../config'; import { complianceAndGeoCheck } from '../../../lib/compliance-and-geo-check'; -import { BadRequestError, DatabaseError, NotFoundError } from '../../../lib/errors'; +import { DatabaseError, NotFoundError } from '../../../lib/errors'; import { adjustUSDCAssetPosition, calculateEquityAndFreeCollateral, @@ -362,13 +362,9 @@ class AddressesController extends Controller { @Post('/:address/registerToken') public async registerToken( @Path() address: string, - @Body() body: { token: string }, + @Body() body: { token: string, language: string }, ): Promise { - const { token } = body; - if (!token) { - throw new BadRequestError('Invalid Token in request'); - } - + const { token, language } = body; const foundAddress = await WalletTable.findById(address); if (!foundAddress) { throw new NotFoundError(`No address found with address: ${address}`); @@ -378,6 +374,7 @@ class AddressesController extends Controller { await TokenTable.registerToken( token, address, + language, ); } catch (error) { throw new DatabaseError(`Error registering token: ${error}`); @@ -393,8 +390,7 @@ class AddressesController extends Controller { if (!wallet) { throw new NotFoundError(`No wallet found for address: ${address}`); } - const allTokens = await TokenTable.findAll({ address: wallet.address }, []) - .then((tokens) => tokens.map((token) => token.token)); + const allTokens = await TokenTable.findAll({ address: wallet.address }, []); if (allTokens.length === 0) { throw new NotFoundError(`No tokens found for address: ${address}`); } @@ -548,11 +544,12 @@ router.post( handleValidationErrors, ExportResponseCodeStats({ controllerName }), async (req: express.Request, res: express.Response) => { - const { address, token } = matchedData(req) as RegisterTokenRequest; + 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 }); + await controller.registerToken(address, { token, language }); return res.status(200).send({}); } catch (error) { return handleControllerError( @@ -562,6 +559,11 @@ router.post( req, res, ); + } finally { + stats.timing( + `${config.SERVICE_NAME}.${controllerName}.post_registerToken.timing`, + Date.now() - start, + ); } }, ); diff --git a/indexer/services/comlink/src/lib/errors.ts b/indexer/services/comlink/src/lib/errors.ts index 621283433c..6a4e9c2801 100644 --- a/indexer/services/comlink/src/lib/errors.ts +++ b/indexer/services/comlink/src/lib/errors.ts @@ -32,3 +32,10 @@ export class DatabaseError extends Error { this.name = 'DatabaseError'; } } + +export class InvalidParamError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + } +} diff --git a/indexer/services/comlink/src/lib/helpers.ts b/indexer/services/comlink/src/lib/helpers.ts index efcf0a358f..ab15955dfb 100644 --- a/indexer/services/comlink/src/lib/helpers.ts +++ b/indexer/services/comlink/src/lib/helpers.ts @@ -36,7 +36,7 @@ import { Risk, } from '../types'; import { ZERO, ZERO_USDC_POSITION } from './constants'; -import { NotFoundError } from './errors'; +import { InvalidParamError, NotFoundError } from './errors'; /* ------- GENERIC HELPERS ------- */ @@ -57,6 +57,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, @@ -89,6 +92,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 384300f222..d954f31cec 100644 --- a/indexer/services/comlink/src/lib/validation/schemas.ts +++ b/indexer/services/comlink/src/lib/validation/schemas.ts @@ -1,3 +1,4 @@ +import { isValidLanguageCode } from '@dydxprotocol-indexer/notifications'; import { perpetualMarketRefresher, MAX_PARENT_SUBACCOUNTS, @@ -215,9 +216,19 @@ export const CheckTransferBetweenSchema = checkSchema(transferBetweenSchemaRecor export const RegisterTokenValidationSchema = [ body('token') - .exists().withMessage('Token is required') // Check if the token exists + .exists().withMessage('Token is required') .isString() - .withMessage('Token must be a string') // Ensure the token is a string + .withMessage('Token must be a string') .notEmpty() - .withMessage('Token cannot be empty'), // Ensure the token is not an empty string + .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 7a691d2cb7..cd6485b4dc 100644 --- a/indexer/services/comlink/src/types.ts +++ b/indexer/services/comlink/src/types.ts @@ -552,6 +552,7 @@ export interface HistoricalFundingRequest extends LimitAndEffectiveBeforeRequest export interface RegisterTokenRequest { address: string, token: string, + language: string, } /* ------- COLLATERALIZATION TYPES ------- */ diff --git a/indexer/services/ender/__tests__/helpers/notification-functions.test.ts b/indexer/services/ender/__tests__/helpers/notification-functions.test.ts index a8b5a4b096..0be17844bc 100644 --- a/indexer/services/ender/__tests__/helpers/notification-functions.test.ts +++ b/indexer/services/ender/__tests__/helpers/notification-functions.test.ts @@ -97,9 +97,15 @@ describe('notification functions', () => { }, ); - // Assert that sendFirebaseMessage was called with correct arguments, default wallet - // is expected because mockOrder uses defaultSubaccountId - expect(sendFirebaseMessage).toHaveBeenCalledWith([defaultToken.token], undefined); + expect(sendFirebaseMessage).toHaveBeenCalledWith( + [ + expect.objectContaining({ + token: defaultToken.token, + language: defaultToken.language, + }), + ], + undefined, + ); }); describe('sendOrderTriggeredNotification', () => { @@ -137,7 +143,12 @@ describe('notification functions', () => { }, ); - expect(sendFirebaseMessage).toHaveBeenCalledWith([defaultToken.token], undefined); + expect(sendFirebaseMessage).toHaveBeenCalledWith([expect.objectContaining( + { + token: defaultToken.token, + language: defaultToken.language, + }, + )], undefined); }); }); }); diff --git a/indexer/services/ender/src/helpers/notifications/notifications-functions.ts b/indexer/services/ender/src/helpers/notifications/notifications-functions.ts index 376135e530..cdfc4f1845 100644 --- a/indexer/services/ender/src/helpers/notifications/notifications-functions.ts +++ b/indexer/services/ender/src/helpers/notifications/notifications-functions.ts @@ -23,8 +23,7 @@ export async function sendOrderFilledNotification( throw new Error(`Subaccount not found for id ${order.subaccountId}`); } - const tokens = (await TokenTable.findAll({ address: subaccount.address }, [])) - .map((token) => token.token); + const tokens = (await TokenTable.findAll({ address: subaccount.address }, [])); if (tokens.length === 0) { throw new Error(`No token found for address ${subaccount.address}`); } @@ -54,8 +53,7 @@ export async function sendOrderTriggeredNotification( subaccount: SubaccountFromDatabase, ) { try { - const tokens = (await TokenTable.findAll({ address: subaccount.address }, [])) - .map((token) => token.token); + const tokens = (await TokenTable.findAll({ address: subaccount.address }, [])); if (tokens.length === 0) { throw new Error(`No tokens found for address ${subaccount.address}`); }