From 5fdb0733938f64988b7114422292579d5cbb96a7 Mon Sep 17 00:00:00 2001 From: Mohammad Ranjbar Z Date: Tue, 27 Dec 2022 14:39:12 +0330 Subject: [PATCH 1/4] Implement sending bulk notifications related to #24 --- .../1660716115917-seedNotificationType.ts | 9 +- .../jwtAuthentication/mockJwtAdapter.ts | 6 +- .../v1/notificationSettingsController.ts | 6 +- src/controllers/v1/notificationsController.ts | 171 +++++----------- .../notificationSettingRepository.ts | 10 +- src/routes/v1/notificationRouter.test.ts | 189 +++++++++++++++++- src/routes/v1/notificationRouter.ts | 19 ++ src/services/notificationService.ts | 115 +++++++++++ src/types/requestResponses.ts | 55 ++--- src/utils/errorMessages.ts | 2 +- src/validators/schemaValidators.ts | 18 +- 11 files changed, 427 insertions(+), 173 deletions(-) create mode 100644 src/services/notificationService.ts diff --git a/migrations/1660716115917-seedNotificationType.ts b/migrations/1660716115917-seedNotificationType.ts index 5ea6109..4237866 100644 --- a/migrations/1660716115917-seedNotificationType.ts +++ b/migrations/1660716115917-seedNotificationType.ts @@ -1,4 +1,4 @@ -import {Column, MigrationInterface, QueryRunner} from 'typeorm'; +import { Column, MigrationInterface, QueryRunner } from 'typeorm'; import { NotificationType, SCHEMA_VALIDATORS_NAMES, @@ -1662,7 +1662,6 @@ export const GivethNotificationTypes = { type: 'p', content: ' unlocked.', }, - ], content: '{amount} unlocked.', }, @@ -1766,7 +1765,8 @@ export const GivethNotificationTypes = { PROJECT_STATUS_GROUP: { name: 'Project status', title: 'Your project status', - description: 'When your own project has been listed, unlisted, cancelled, activated or deactivated', + description: + 'When your own project has been listed, unlisted, cancelled, activated or deactivated', showOnSettingPage: true, isEmailEditable: true, isWebEditable: false, @@ -1863,7 +1863,8 @@ export const GivethNotificationTypes = { LIKED_BY_YOU_PROJECT_GROUP: { name: 'Project status - Users Who Liked', title: 'Your liked project status', - description: 'When your liked Project has been listed, unlisted, cancelled, activated or deactivated', + description: + 'When your liked Project has been listed, unlisted, cancelled, activated or deactivated', showOnSettingPage: true, microService: MICRO_SERVICES.givethio, schemaValidator: null, diff --git a/src/adapters/jwtAuthentication/mockJwtAdapter.ts b/src/adapters/jwtAuthentication/mockJwtAdapter.ts index ea71c82..8fe38f4 100644 --- a/src/adapters/jwtAuthentication/mockJwtAdapter.ts +++ b/src/adapters/jwtAuthentication/mockJwtAdapter.ts @@ -2,13 +2,17 @@ import { JwtAuthenticationInterface } from './JwtAuthenticationInterface'; import { decode, JwtPayload } from 'jsonwebtoken'; import { errorMessages } from '../../utils/errorMessages'; import { logger } from '../../utils/logger'; +import { StandardError } from '../../types/StandardError'; export class MockJwtAdapter implements JwtAuthenticationInterface { verifyJwt(token: string): Promise { try { const decodedJwt = decode(token) as JwtPayload; if (!decodedJwt.publicAddress) { - throw new Error(errorMessages.UN_AUTHORIZED); + throw new StandardError({ + message: errorMessages.UN_AUTHORIZED, + httpStatusCode: 401, + }); } return decodedJwt.publicAddress.toLowerCase(); } catch (e: any) { diff --git a/src/controllers/v1/notificationSettingsController.ts b/src/controllers/v1/notificationSettingsController.ts index 5c35320..656937c 100644 --- a/src/controllers/v1/notificationSettingsController.ts +++ b/src/controllers/v1/notificationSettingsController.ts @@ -13,6 +13,7 @@ import { updateOneNotificationSetting, validateWithJoiSchema, } from '../../validators/schemaValidators'; +import { StandardError } from '../../types/StandardError'; interface SettingParams { id: number; @@ -86,7 +87,10 @@ export class NotificationSettingsController { const notificationSetting = await findNotificationSettingById(id); if (!notificationSetting) { - throw new Error(errorMessages.NOTIFICATION_SETTING_NOT_FOUND); + throw new StandardError({ + message: errorMessages.NOTIFICATION_SETTING_NOT_FOUND, + httpStatusCode: 400, + }); } const newSettingData = { notificationSettingId: id, diff --git a/src/controllers/v1/notificationsController.ts b/src/controllers/v1/notificationsController.ts index c877a49..59e2ff0 100644 --- a/src/controllers/v1/notificationsController.ts +++ b/src/controllers/v1/notificationsController.ts @@ -9,12 +9,12 @@ import { Query, Put, Path, - Example, } from 'tsoa'; import { logger } from '../../utils/logger'; import { countUnreadValidator, + sendBulkNotificationValidator, sendNotificationValidator, validateWithJoiSchema, } from '../../validators/schemaValidators'; @@ -22,33 +22,23 @@ import { CountUnreadNotificationsResponse, GetNotificationsResponse, ReadAllNotificationsRequestType, - ReadAllNotificationsResponse, ReadSingleNotificationResponse, - SendNotificationRequest, SendNotificationResponse, - SendNotificationTypeRequest, + SendNotificationRequest, + sendBulkNotificationRequest, } from '../../types/requestResponses'; -import { errorMessages, errorMessagesEnum } from '../../utils/errorMessages'; -import { StandardError } from '../../types/StandardError'; -import { User } from '../../types/general'; +import { errorMessages } from '../../utils/errorMessages'; import { countUnreadNotifications, - createNotification, - findNotificationByTrackId, getNotifications, markNotificationGroupAsRead, markNotificationsAsRead, } from '../../repositories/notificationRepository'; import { UserAddress } from '../../entities/userAddress'; -import { - getNotificationTypeByEventName, - getNotificationTypeByEventNameAndMicroservice, -} from '../../repositories/notificationTypeRepository'; -import { EMAIL_STATUSES, Notification } from '../../entities/notification'; +import { Notification } from '../../entities/notification'; import { createNewUserAddressIfNotExists } from '../../repositories/userAddressRepository'; -import { SEGMENT_METADATA_SCHEMA_VALIDATOR } from '../../utils/validators/segmentAndMetadataValidators'; -import { findNotificationSettingByNotificationTypeAndUserAddress } from '../../repositories/notificationSettingRepository'; -import { SegmentAnalyticsSingleton } from '../../services/segment/segmentAnalyticsSingleton'; +import { sendNotification } from '../../services/notificationService'; +import { StandardError } from '../../types/StandardError'; @Route('/v1') @Tags('Notification') @@ -57,105 +47,16 @@ export class NotificationsController { @Security('basicAuth') public async sendNotification( @Body() - body: SendNotificationTypeRequest, + body: SendNotificationRequest, @Inject() params: { microService: string; }, ): Promise { const { microService } = params; - const { userWalletAddress, projectId } = body; try { validateWithJoiSchema(body, sendNotificationValidator); - if (body.trackId && (await findNotificationByTrackId(body.trackId))) { - // We dont throw error in this case but dont create new notification neither - return { - success: true, - message: errorMessages.DUPLICATED_TRACK_ID, - }; - } - const userAddress = await createNewUserAddressIfNotExists( - userWalletAddress as string, - ); - const notificationType = - await getNotificationTypeByEventNameAndMicroservice({ - eventName: body.eventName, - microService, - }); - - if (!notificationType) { - throw new Error(errorMessages.INVALID_NOTIFICATION_TYPE); - } - const notificationSetting = - await findNotificationSettingByNotificationTypeAndUserAddress({ - notificationTypeId: notificationType.id, - userAddressId: userAddress.id, - }); - - logger.debug('notificationController.sendNotification()', { - notificationSetting, - notificationType, - }); - - const shouldSendEmail = - body.sendEmail && notificationSetting?.allowEmailNotification; - let emailStatus = shouldSendEmail - ? EMAIL_STATUSES.WAITING_TO_BE_SEND - : EMAIL_STATUSES.NO_NEED_TO_SEND; - - const segmentValidator = - SEGMENT_METADATA_SCHEMA_VALIDATOR[ - notificationType?.schemaValidator as string - ]?.segment; - - if (shouldSendEmail && body.sendSegment && segmentValidator) { - //TODO Currently sending email and segment event are tightly coupled, we can't send segment event without sending email - // And it's not good, we should find another solution to separate sending segment and email - const segmentData = body.segment?.payload; - validateWithJoiSchema(segmentData, segmentValidator); - await SegmentAnalyticsSingleton.getInstance().track({ - eventName: notificationType.emailNotificationId as string, - anonymousId: body?.segment?.anonymousId, - properties: segmentData, - analyticsUserId: body?.segment?.analyticsUserId, - }); - emailStatus = EMAIL_STATUSES.SENT; - } - - const metadataValidator = - SEGMENT_METADATA_SCHEMA_VALIDATOR[ - notificationType?.schemaValidator as string - ]?.metadata; - - if (metadataValidator) { - validateWithJoiSchema(body.metadata, metadataValidator); - } - - if (!notificationSetting?.allowDappPushNotification) { - //TODO In future we can add a create notification but with disabledNotification:true - // So we can exclude them in list of notifications - return { - success: true, - message: errorMessages.USER_TURNED_OF_THIS_NOTIFICATION_TYPE, - }; - } - const notificationData: Partial = { - notificationType, - userAddress, - email: body.email, - emailStatus, - trackId: body?.trackId, - metadata: body?.metadata, - segmentData: body.segment, - projectId, - }; - if (body.creationTime) { - // creationTime is optional and it's timestamp in milliseconds format - notificationData.createdAt = new Date(body.creationTime); - } - await createNotification(notificationData); - - return { success: true }; + return sendNotification(body, microService); // add if and logic for push notification (not in mvp) } catch (e) { logger.error('sendNotification() error', { @@ -167,20 +68,45 @@ export class NotificationsController { } } + @Post('/thirdParty/notificationsBulk') + @Security('basicAuth') + public async sendBulkNotification( + @Body() + body: sendBulkNotificationRequest, + @Inject() + params: { + microService: string; + }, + ): Promise { + const { microService } = params; + try { + validateWithJoiSchema(body, sendBulkNotificationValidator); + await Promise.all( + body.notifications.map(item => sendNotification(item, microService)), + ); + return { success: true }; + } catch (e) { + logger.error('sendBulkNotification() error', { + error: e, + }); + throw e; + } + } + // https://tsoa-community.github.io/docs/examples.html#parameter-examples /** - * @example limit "20" - * @example offset "0" - * @example category "" - * @example category "projectRelated" - * @example category "givEconomyRelated" - * @example category "general" - * @example isRead "" - * @example isRead "false" - * @example isRead "true" - * @example startTime "1659356987" - - */ + * @example limit "20" + * @example offset "0" + * @example category "" + * @example category "projectRelated" + * @example category "givEconomyRelated" + * @example category "general" + * @example isRead "" + * @example isRead "false" + * @example isRead "true" + * @example startTime "1659356987" + + */ @Get('/notifications/') @Security('JWT') public async getNotifications( @@ -258,7 +184,10 @@ export class NotificationsController { userAddressId: user.id, }); if (!notification) { - throw new Error(errorMessages.NOTIFICATION_NOT_FOUND); + throw new StandardError({ + message: errorMessages.NOTIFICATION_NOT_FOUND, + httpStatusCode: 404, + }); } return { notification, diff --git a/src/repositories/notificationSettingRepository.ts b/src/repositories/notificationSettingRepository.ts index 98feb94..16d083b 100644 --- a/src/repositories/notificationSettingRepository.ts +++ b/src/repositories/notificationSettingRepository.ts @@ -4,6 +4,7 @@ import { NotificationSetting } from '../entities/notificationSetting'; import { errorMessages } from '../utils/errorMessages'; import { createQueryBuilder } from 'typeorm'; import { logger } from '../utils/logger'; +import { StandardError } from '../types/StandardError'; export const createNotificationSettingsForNewUser = async ( user: UserAddress, @@ -120,9 +121,12 @@ export const updateUserNotificationSetting = async (params: { ) .getOne(); - if (!notificationSetting) - throw new Error(errorMessages.NOTIFICATION_SETTING_NOT_FOUND); - + if (!notificationSetting) { + throw new StandardError({ + message: errorMessages.NOTIFICATION_SETTING_NOT_FOUND, + httpStatusCode: 400, + }); + } if (notificationSetting.notificationType?.isEmailEditable) { notificationSetting.allowEmailNotification = params.allowEmailNotification; } diff --git a/src/routes/v1/notificationRouter.test.ts b/src/routes/v1/notificationRouter.test.ts index 54bb295..0ef0b6b 100644 --- a/src/routes/v1/notificationRouter.test.ts +++ b/src/routes/v1/notificationRouter.test.ts @@ -11,11 +11,14 @@ import axios from 'axios'; import { assert } from 'chai'; import { errorMessages, errorMessagesEnum } from '../../utils/errorMessages'; import { findNotificationByTrackId } from '../../repositories/notificationRepository'; +import { generateRandomString } from '../../utils/utils'; describe('/notifications POST test cases', sendNotificationTestCases); +describe('/notificationsBulk POST test cases', sendBulkNotificationsTestCases); describe('/notifications GET test cases', getNotificationTestCases); const sendNotificationUrl = `${serverUrl}/v1/thirdParty/notifications`; +const sendBulkNotificationsUrl = `${serverUrl}/v1/thirdParty/notificationsBulk`; const getNotificationUrl = `${serverUrl}/v1/notifications`; const projectTitle = 'project title'; const projectLink = 'https://giveth.io/project/project-title'; @@ -1368,14 +1371,13 @@ function sendNotificationTestCases() { assert.isTrue(false); } catch (e: any) { assert.equal( - e.response.data.message, - errorMessagesEnum.IMPACT_GRAPH_VALIDATION_ERROR.message, + e.response.data.message, + errorMessagesEnum.IMPACT_GRAPH_VALIDATION_ERROR.message, ); assert.equal(e.response.data.description, '"amount" is required'); } }); - it('should create *UnStake* notification, success, segment is off', async () => { const data = { eventName: 'UnStake', @@ -2296,3 +2298,184 @@ function sendNotificationTestCases() { assert.equal(createdNotification?.createdAt.getTime(), creationTime); }); } + +function sendBulkNotificationsTestCases() { + it('should create two *project liked* notifications, success, segment is off', async () => { + const data = { + notifications: [ + { + eventName: 'project liked', + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + trackId: `${new Date().getTime()}-${generateRandomString(30)}`, + metadata: { + projectTitle, + projectLink, + }, + }, + { + eventName: 'project liked', + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + trackId: `${new Date().getTime()}-${generateRandomString(30)}`, + metadata: { + projectTitle, + projectLink, + }, + }, + ], + }; + const result = await axios.post(sendBulkNotificationsUrl, data, { + headers: { + authorization: getGivethIoBasicAuth(), + }, + }); + assert.equal(result.status, 200); + assert.isOk(result.data); + assert.isTrue(result.data.success); + assert.isOk( + await findNotificationByTrackId(data.notifications[0].trackId as string), + ); + assert.isOk( + await findNotificationByTrackId(data.notifications[1].trackId as string), + ); + }); + it('should create two *project liked* notifications, success 100 notification', async () => { + const notifications = []; + for (let i = 0; i < 100; i++) { + notifications.push({ + eventName: 'project liked', + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + trackId: `${new Date().getTime()}-${generateRandomString(30)}`, + metadata: { + projectTitle, + projectLink, + }, + }); + } + const data = { + notifications, + }; + const result = await axios.post(sendBulkNotificationsUrl, data, { + headers: { + authorization: getGivethIoBasicAuth(), + }, + }); + assert.equal(result.status, 200); + assert.isOk(result.data); + assert.isTrue(result.data.success); + for (const notification of data.notifications) { + assert.isOk( + await findNotificationByTrackId(notification.trackId as string), + ); + } + }); + it('should create two *project liked* notifications, failed for more than 100', async () => { + const notifications = []; + for (let i = 0; i < 101; i++) { + notifications.push({ + eventName: 'project liked', + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + trackId: `${new Date().getTime()}-${generateRandomString(30)}`, + metadata: { + projectTitle, + projectLink, + }, + }); + } + const data = { + notifications, + }; + try { + await axios.post(sendBulkNotificationsUrl, data, { + headers: { + authorization: getGivethIoBasicAuth(), + }, + }); + assert.isTrue(false); + } catch (e: any) { + assert.equal( + e.response.data.message, + errorMessagesEnum.IMPACT_GRAPH_VALIDATION_ERROR.message, + ); + assert.equal( + e.response.data.description, + '"notifications" must contain less than or equal to 100 items', + ); + } + }); + it('should create two *project liked* notifications, failed for empty array', async () => { + const data = { + notifications: [], + }; + try { + await axios.post(sendBulkNotificationsUrl, data, { + headers: { + authorization: getGivethIoBasicAuth(), + }, + }); + assert.isTrue(false); + } catch (e: any) { + assert.equal( + e.response.data.message, + errorMessagesEnum.IMPACT_GRAPH_VALIDATION_ERROR.message, + ); + assert.equal( + e.response.data.description, + '"notifications" must contain at least 1 items', + ); + } + }); + it('should create two *project liked* notifications, failed if one notification had problem (but correct notification would be saved in DB)', async () => { + const data = { + notifications: [ + { + eventName: 'project liked', + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + trackId: `${new Date().getTime()}-${generateRandomString(30)}`, + metadata: { + projectTitle, + projectLink, + }, + }, + { + eventName: 'project liked2', + trackId: `${new Date().getTime()}-${generateRandomString(30)}`, + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + metadata: { + projectTitle, + projectLink, + }, + }, + ], + }; + try { + await axios.post(sendBulkNotificationsUrl, data, { + headers: { + authorization: getGivethIoBasicAuth(), + }, + }); + assert.isTrue(false); + } catch (e: any) { + assert.equal( + e.response.data.message, + errorMessages.INVALID_NOTIFICATION_TYPE, + ); + assert.isOk( + await findNotificationByTrackId( + data.notifications[0].trackId as string, + ), + ); + } + }); +} diff --git a/src/routes/v1/notificationRouter.ts b/src/routes/v1/notificationRouter.ts index 6b2cd69..d34263e 100644 --- a/src/routes/v1/notificationRouter.ts +++ b/src/routes/v1/notificationRouter.ts @@ -27,6 +27,25 @@ notificationRouter.post( }, ); +notificationRouter.post( + '/thirdParty/notificationsBulk', + authenticateThirdPartyServiceToken, + async (req: Request, res: Response, next) => { + const { microService } = res.locals; + try { + const result = await notificationsController.sendBulkNotification( + req.body, + { + microService, + }, + ); + return sendStandardResponse({ res, result }); + } catch (e) { + next(e); + } + }, +); + notificationRouter.get( '/notifications', validateAuthMicroserviceJwt, diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts new file mode 100644 index 0000000..87e6c2b --- /dev/null +++ b/src/services/notificationService.ts @@ -0,0 +1,115 @@ +import { + createNotification, + findNotificationByTrackId, +} from '../repositories/notificationRepository'; +import { errorMessages } from '../utils/errorMessages'; +import { createNewUserAddressIfNotExists } from '../repositories/userAddressRepository'; +import { getNotificationTypeByEventNameAndMicroservice } from '../repositories/notificationTypeRepository'; +import { findNotificationSettingByNotificationTypeAndUserAddress } from '../repositories/notificationSettingRepository'; +import { logger } from '../utils/logger'; +import { EMAIL_STATUSES, Notification } from '../entities/notification'; +import { SEGMENT_METADATA_SCHEMA_VALIDATOR } from '../utils/validators/segmentAndMetadataValidators'; +import { validateWithJoiSchema } from '../validators/schemaValidators'; +import { SegmentAnalyticsSingleton } from './segment/segmentAnalyticsSingleton'; +import { SendNotificationRequest } from '../types/requestResponses'; +import { StandardError } from '../types/StandardError'; + +export const sendNotification = async ( + body: SendNotificationRequest, + microService: string, +): Promise<{ + success: boolean; + message?: string; +}> => { + const { userWalletAddress, projectId } = body; + if (body.trackId && (await findNotificationByTrackId(body.trackId))) { + // We dont throw error in this case but dont create new notification neither + return { + success: true, + message: errorMessages.DUPLICATED_TRACK_ID, + }; + } + const userAddress = await createNewUserAddressIfNotExists( + userWalletAddress as string, + ); + const notificationType = await getNotificationTypeByEventNameAndMicroservice({ + eventName: body.eventName, + microService, + }); + + if (!notificationType) { + throw new StandardError({ + message: errorMessages.INVALID_NOTIFICATION_TYPE, + httpStatusCode: 400, + }); + } + const notificationSetting = + await findNotificationSettingByNotificationTypeAndUserAddress({ + notificationTypeId: notificationType.id, + userAddressId: userAddress.id, + }); + + logger.debug('notificationController.sendNotification()', { + notificationSetting, + notificationType, + }); + + const shouldSendEmail = + body.sendEmail && notificationSetting?.allowEmailNotification; + let emailStatus = shouldSendEmail + ? EMAIL_STATUSES.WAITING_TO_BE_SEND + : EMAIL_STATUSES.NO_NEED_TO_SEND; + + const segmentValidator = + SEGMENT_METADATA_SCHEMA_VALIDATOR[ + notificationType?.schemaValidator as string + ]?.segment; + + if (shouldSendEmail && body.sendSegment && segmentValidator) { + //TODO Currently sending email and segment event are tightly coupled, we can't send segment event without sending email + // And it's not good, we should find another solution to separate sending segment and email + const segmentData = body.segment?.payload; + validateWithJoiSchema(segmentData, segmentValidator); + await SegmentAnalyticsSingleton.getInstance().track({ + eventName: notificationType.emailNotificationId as string, + anonymousId: body?.segment?.anonymousId, + properties: segmentData, + analyticsUserId: body?.segment?.analyticsUserId, + }); + emailStatus = EMAIL_STATUSES.SENT; + } + + const metadataValidator = + SEGMENT_METADATA_SCHEMA_VALIDATOR[ + notificationType?.schemaValidator as string + ]?.metadata; + + if (metadataValidator) { + validateWithJoiSchema(body.metadata, metadataValidator); + } + + if (!notificationSetting?.allowDappPushNotification) { + //TODO In future we can add a create notification but with disabledNotification:true + // So we can exclude them in list of notifications + return { + success: true, + message: errorMessages.USER_TURNED_OF_THIS_NOTIFICATION_TYPE, + }; + } + const notificationData: Partial = { + notificationType, + userAddress, + email: body.email, + emailStatus, + trackId: body?.trackId, + metadata: body?.metadata, + segmentData: body.segment, + projectId, + }; + if (body.creationTime) { + // creationTime is optional and it's timestamp in milliseconds format + notificationData.createdAt = new Date(body.creationTime); + } + await createNotification(notificationData); + return { success: true }; +}; diff --git a/src/types/requestResponses.ts b/src/types/requestResponses.ts index bd9a879..33ac26e 100644 --- a/src/types/requestResponses.ts +++ b/src/types/requestResponses.ts @@ -4,15 +4,12 @@ interface BaseNotificationResponse { trackId?: string; } -export type SendNotificationTypeRequest = { +interface BaseSendNotificationTypeRequest { /** * @example "Made donation" */ eventName: string; - /** - * @example "Should be unique" - */ - trackId?: string; + /** * @example false */ @@ -57,51 +54,33 @@ export type SendNotificationTypeRequest = { anonymousId?: string; }; metadata?: NotificationMetadata; -}; - -export type SendNotificationRequest = { - /** - * @example "1" - */ - userId: string; - - /** - * @example "1" - */ - projectId: string; - - metadata: { - /** - * @example "https://giveth.io/project/test-project - */ - projectLink?: string; - - /** - * @example "Test project" - */ - projectTitle?: string; - }; +} +export interface SendNotificationRequest + extends BaseSendNotificationTypeRequest { /** - * @example "y@giveth.io" + * @example "Should be unique" */ - email: string; + trackId?: string; +} +interface SendNotificationTypeRequestBulkItem + extends BaseSendNotificationTypeRequest { /** - * @example "projectListed" + * @example "Should be unique" */ - notificationTemplate: number; + trackId: string; +} - /** - * @example true - */ - sendEmail: boolean; -}; +export interface sendBulkNotificationRequest { + notifications: SendNotificationTypeRequestBulkItem[]; +} export interface SendNotificationResponse extends BaseNotificationResponse { success: boolean; message?: string; } + export interface GetNotificationsResponse extends BaseNotificationResponse { notifications: Notification[]; count: number; diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index fa35f07..ce7424b 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -92,7 +92,7 @@ export const errorMessages = { JUST_ACTIVE_PROJECTS_ACCEPT_DONATION: 'Just active projects accept donation', CATEGORIES_LENGTH_SHOULD_NOT_BE_MORE_THAN_FIVE: 'Please select no more than 5 categories', - INVALID_NOTIFICATION_TYPE: 'Notification type invalid', + INVALID_NOTIFICATION_TYPE: 'Notification type is invalid', DUPLICATED_TRACK_ID: 'Duplicated trackId', CATEGORIES_MUST_BE_FROM_THE_FRONTEND_SUBSELECTION: 'This category is not valid', diff --git a/src/validators/schemaValidators.ts b/src/validators/schemaValidators.ts index 2f58f69..55fa091 100644 --- a/src/validators/schemaValidators.ts +++ b/src/validators/schemaValidators.ts @@ -24,7 +24,9 @@ const throwHttpErrorIfJoiValidatorFails = ( export const countUnreadValidator = Joi.object({ walletAddress: Joi.string().pattern(ethereumWalletAddressRegex).required(), }); + export const sendNotificationValidator = Joi.object({ + trackId: Joi.string(), projectId: Joi.string(), analyticsUserId: Joi.string(), anonymousId: Joi.string(), @@ -33,7 +35,6 @@ export const sendNotificationValidator = Joi.object({ sendEmail: Joi.boolean(), sendSegment: Joi.boolean(), email: Joi.string(), - trackId: Joi.string(), creationTime: Joi.number(), userWalletAddress: Joi.string() .pattern(ethereumWalletAddressRegex) @@ -75,6 +76,20 @@ export const sendNotificationValidator = Joi.object({ }), }); +export const sendBulkNotificationValidator = Joi.object({ + notifications: Joi.array() + .required() + .min(1) + .max(100) + .items( + sendNotificationValidator.concat( + Joi.object({ + trackId: Joi.string().required(), + }), + ), + ), +}); + export const updateNotificationSettings = Joi.object({ settings: Joi.array() .required() @@ -86,6 +101,7 @@ export const updateNotificationSettings = Joi.object({ }), ), }); + export const updateOneNotificationSetting = Joi.object({ id: Joi.number().required(), allowEmailNotification: Joi.boolean().required(), From d7a58cf000d1e34e6debd64e79e39655559e2598 Mon Sep 17 00:00:00 2001 From: Mohammad Ranjbar Z Date: Tue, 27 Dec 2022 16:42:34 +0330 Subject: [PATCH 2/4] Fix sendBulkNotification test cases --- src/routes/v1/notificationRouter.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/routes/v1/notificationRouter.test.ts b/src/routes/v1/notificationRouter.test.ts index 0ef0b6b..b253c27 100644 --- a/src/routes/v1/notificationRouter.test.ts +++ b/src/routes/v1/notificationRouter.test.ts @@ -2432,7 +2432,7 @@ function sendBulkNotificationsTestCases() { ); } }); - it('should create two *project liked* notifications, failed if one notification had problem (but correct notification would be saved in DB)', async () => { + it('should create two *project liked* notifications, failed if one notification had problem', async () => { const data = { notifications: [ { @@ -2471,11 +2471,6 @@ function sendBulkNotificationsTestCases() { e.response.data.message, errorMessages.INVALID_NOTIFICATION_TYPE, ); - assert.isOk( - await findNotificationByTrackId( - data.notifications[0].trackId as string, - ), - ); } }); } From 398f385b54165a3b7259a4a625a77696b2c328c9 Mon Sep 17 00:00:00 2001 From: Mohammad Ranjbar Z Date: Wed, 28 Dec 2022 14:47:02 +0330 Subject: [PATCH 3/4] Add validation for uniqueness of trackIds in input data for sending bulk notifications related to #24 --- src/controllers/v1/notificationsController.ts | 10 +++++ src/routes/v1/notificationRouter.test.ts | 44 +++++++++++++++++++ src/types/requestResponses.ts | 18 +++----- src/utils/errorMessages.ts | 1 + 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/controllers/v1/notificationsController.ts b/src/controllers/v1/notificationsController.ts index 59e2ff0..4a868ce 100644 --- a/src/controllers/v1/notificationsController.ts +++ b/src/controllers/v1/notificationsController.ts @@ -39,6 +39,7 @@ import { Notification } from '../../entities/notification'; import { createNewUserAddressIfNotExists } from '../../repositories/userAddressRepository'; import { sendNotification } from '../../services/notificationService'; import { StandardError } from '../../types/StandardError'; +import { not } from 'joi'; @Route('/v1') @Tags('Notification') @@ -81,6 +82,15 @@ export class NotificationsController { const { microService } = params; try { validateWithJoiSchema(body, sendBulkNotificationValidator); + const trackIdsSet = new Set( + body.notifications.map(notification => notification.trackId), + ); + if (trackIdsSet.size !== body.notifications.length) { + throw new StandardError({ + message: errorMessages.THERE_IS_SOME_ITEMS_WITH_SAME_TRACK_ID, + httpStatusCode: 400, + }); + } await Promise.all( body.notifications.map(item => sendNotification(item, microService)), ); diff --git a/src/routes/v1/notificationRouter.test.ts b/src/routes/v1/notificationRouter.test.ts index b253c27..ce02b10 100644 --- a/src/routes/v1/notificationRouter.test.ts +++ b/src/routes/v1/notificationRouter.test.ts @@ -2410,6 +2410,50 @@ function sendBulkNotificationsTestCases() { ); } }); + it('should create two *project liked* notifications, failed because two notifications have same trackIds', async () => { + const trackId = `${new Date().getTime()}-${generateRandomString(30)}`; + const notifications = [ + { + eventName: 'project liked', + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + trackId, + metadata: { + projectTitle, + projectLink, + }, + }, + { + eventName: 'project liked', + sendEmail: false, + sendSegment: false, + userWalletAddress: generateRandomEthereumAddress(), + trackId, + metadata: { + projectTitle, + projectLink, + }, + }, + ]; + try { + await axios.post( + sendBulkNotificationsUrl, + { notifications }, + { + headers: { + authorization: getGivethIoBasicAuth(), + }, + }, + ); + assert.isTrue(false); + } catch (e: any) { + assert.equal( + e.response.data.message, + errorMessages.THERE_IS_SOME_ITEMS_WITH_SAME_TRACK_ID, + ); + } + }); it('should create two *project liked* notifications, failed for empty array', async () => { const data = { notifications: [], diff --git a/src/types/requestResponses.ts b/src/types/requestResponses.ts index 33ac26e..c63a123 100644 --- a/src/types/requestResponses.ts +++ b/src/types/requestResponses.ts @@ -4,7 +4,12 @@ interface BaseNotificationResponse { trackId?: string; } -interface BaseSendNotificationTypeRequest { +export interface SendNotificationRequest { + /** + * @example "Should be unique" + */ + trackId?: string; + /** * @example "Made donation" */ @@ -56,16 +61,7 @@ interface BaseSendNotificationTypeRequest { metadata?: NotificationMetadata; } -export interface SendNotificationRequest - extends BaseSendNotificationTypeRequest { - /** - * @example "Should be unique" - */ - trackId?: string; -} - -interface SendNotificationTypeRequestBulkItem - extends BaseSendNotificationTypeRequest { +interface SendNotificationTypeRequestBulkItem extends SendNotificationRequest { /** * @example "Should be unique" */ diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index ce7424b..b1ad8a7 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -93,6 +93,7 @@ export const errorMessages = { CATEGORIES_LENGTH_SHOULD_NOT_BE_MORE_THAN_FIVE: 'Please select no more than 5 categories', INVALID_NOTIFICATION_TYPE: 'Notification type is invalid', + THERE_IS_SOME_ITEMS_WITH_SAME_TRACK_ID: 'There is some ids with same trackId', DUPLICATED_TRACK_ID: 'Duplicated trackId', CATEGORIES_MUST_BE_FROM_THE_FRONTEND_SUBSELECTION: 'This category is not valid', From fbd389e129574f16f23da7744a57b9888d458c9b Mon Sep 17 00:00:00 2001 From: Amin Latifi Date: Wed, 28 Dec 2022 17:05:22 +0300 Subject: [PATCH 4/4] Removed unused imports --- migrations/1660539566228-createUserAddress.ts | 1 - migrations/1660540253547-createNotificationType.ts | 1 - migrations/1660716115917-seedNotificationType.ts | 2 +- src/controllers/v1/notificationSettingsController.ts | 1 - src/controllers/v1/notificationsController.ts | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/migrations/1660539566228-createUserAddress.ts b/migrations/1660539566228-createUserAddress.ts index 464d949..c790df0 100644 --- a/migrations/1660539566228-createUserAddress.ts +++ b/migrations/1660539566228-createUserAddress.ts @@ -2,7 +2,6 @@ import { MigrationInterface, QueryRunner, Table, - TableForeignKey, } from 'typeorm'; export class createUserAddress1660539566228 implements MigrationInterface { diff --git a/migrations/1660540253547-createNotificationType.ts b/migrations/1660540253547-createNotificationType.ts index 54a2ff0..42f1134 100644 --- a/migrations/1660540253547-createNotificationType.ts +++ b/migrations/1660540253547-createNotificationType.ts @@ -2,7 +2,6 @@ import { MigrationInterface, QueryRunner, Table, - TableForeignKey, TableIndex, } from 'typeorm'; diff --git a/migrations/1660716115917-seedNotificationType.ts b/migrations/1660716115917-seedNotificationType.ts index 4237866..281edb9 100644 --- a/migrations/1660716115917-seedNotificationType.ts +++ b/migrations/1660716115917-seedNotificationType.ts @@ -1,4 +1,4 @@ -import { Column, MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm'; import { NotificationType, SCHEMA_VALIDATORS_NAMES, diff --git a/src/controllers/v1/notificationSettingsController.ts b/src/controllers/v1/notificationSettingsController.ts index 656937c..7d7ab3a 100644 --- a/src/controllers/v1/notificationSettingsController.ts +++ b/src/controllers/v1/notificationSettingsController.ts @@ -8,7 +8,6 @@ import { import { UserAddress } from '../../entities/userAddress'; import { errorMessages } from '../../utils/errorMessages'; import { - sendNotificationValidator, updateNotificationSettings, updateOneNotificationSetting, validateWithJoiSchema, diff --git a/src/controllers/v1/notificationsController.ts b/src/controllers/v1/notificationsController.ts index 4a868ce..cd4936a 100644 --- a/src/controllers/v1/notificationsController.ts +++ b/src/controllers/v1/notificationsController.ts @@ -39,7 +39,6 @@ import { Notification } from '../../entities/notification'; import { createNewUserAddressIfNotExists } from '../../repositories/userAddressRepository'; import { sendNotification } from '../../services/notificationService'; import { StandardError } from '../../types/StandardError'; -import { not } from 'joi'; @Route('/v1') @Tags('Notification')