diff --git a/package.json b/package.json index 83dab75a7..5bfde13ae 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "test:userRepository": "NODE_ENV=test mocha -t 30000 --exit -r ts-node/register ./test/pre-test-scripts.ts ./src/repositories/userRepository.test.ts", "test:statusReasonRepository": "NODE_ENV=test mocha -t 30000 --exit -r ts-node/register ./test/pre-test-scripts.ts ./src/repositories/statusReasonRepository.test.ts", "test:donationRepository": "NODE_ENV=test mocha -t 30000 --exit -r ts-node/register ./test/pre-test-scripts.ts ./src/repositories/donationRepository.test.ts", + "test:reactionRepository": "NODE_ENV=test mocha -t 30000 --exit -r ts-node/register ./test/pre-test-scripts.ts ./src/repositories/reactionRepository.test.ts", "test:socialProfileRepository": "NODE_ENV=test mocha -t 30000 --exit -r ts-node/register ./test/pre-test-scripts.ts ./src/repositories/socialProfileRepository.test.ts", "test:powerBalanceSnapshotRepository": "NODE_ENV=test mocha -t 30000 --exit -r ts-node/register ./test/pre-test-scripts.ts ./src/repositories/powerBalanceSnapshotRepository.test.ts", "test:projectVerificationRepository": "NODE_ENV=test mocha -t 30000 --exit -r ts-node/register ./test/pre-test-scripts.ts ./src/repositories/projectVerificationRepository.test.ts", diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index 66dd27f15..e825c23c5 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -7,10 +7,39 @@ import { User } from '../../entities/user'; import { createBasicAuthentication } from '../../utils/utils'; import { logger } from '../../utils/logger'; import { NOTIFICATIONS_EVENT_NAMES } from '../../analytics/analytics'; +import Bull from 'bull'; +import { redisConfig } from '../../redis'; +import config from '../../config'; +import { findUsersWhoDonatedToProject } from '../../repositories/donationRepository'; +import { findUsersWhoLikedProject } from '../../repositories/reactionRepository'; const notificationCenterUsername = process.env.NOTIFICATION_CENTER_USERNAME; const notificationCenterPassword = process.env.NOTIFICATION_CENTER_PASSWORD; const notificationCenterBaseUrl = process.env.NOTIFICATION_CENTER_BASE_URL; +const numberOfSendNotificationsConcurrentJob = + Number( + config.get('NUMBER_OF_FILLING_POWER_SNAPSHOT_BALANCE_CONCURRENT_JOB'), + ) || 30; + +interface ProjectRelatedNotificationsQueue { + project: Project; + eventName: NOTIFICATIONS_EVENT_NAMES; + metadata?: any; + user?: { + walletAddress: string; + email?: string; + }; +} + +const sendProjectRelatedNotificationsBalanceQueue = + new Bull( + 'send-project-related-notifications', + { + redis: redisConfig, + }, + ); +let isProcessingQueueEventsEnabled = false; + interface SendNotificationBody { sendEmail?: boolean; eventName: string; @@ -27,6 +56,38 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { userName: notificationCenterUsername, password: notificationCenterPassword, }); + if (!isProcessingQueueEventsEnabled) { + // We send notifications to project owners immediately, but as donors and people + // who liked project can be thousands or more we enqueue them and send it by that to manage + // load on notification-center and make sure all of notifications would arrive + this.processSendingNotifications(); + isProcessingQueueEventsEnabled = true; + } + } + + processSendingNotifications() { + logger.debug('processSendingNotifications() has been called ', { + numberOfSendNotificationsConcurrentJob, + }); + sendProjectRelatedNotificationsBalanceQueue.process( + numberOfSendNotificationsConcurrentJob, + async (job, done) => { + logger.debug('processing send notification job', job.data); + const { project, metadata, eventName, user } = job.data; + try { + await this.sendProjectRelatedNotification({ + project, + eventName, + metadata, + user, + }); + } catch (e) { + logger.error('processSendingNotifications >> error', e); + } finally { + done(); + } + }, + ); } async donationReceived(params: { @@ -78,33 +139,98 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { return Promise.resolve(undefined); } - projectCancelled(params: { project: Project }): Promise { + async projectCancelled(params: { project: Project }): Promise { const { project } = params; - return this.sendProjectRelatedNotification({ + + const donors = await findUsersWhoDonatedToProject(project.id); + donors.map(user => + sendProjectRelatedNotificationsBalanceQueue.add({ + project, + eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_CANCELLED_DONORS, + user, + }), + ); + + const usersWhoLiked = await findUsersWhoLikedProject(project.id); + usersWhoLiked.map(user => + sendProjectRelatedNotificationsBalanceQueue.add({ + project, + eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_CANCELLED_USERS_WHO_LIKED, + user, + }), + ); + + await this.sendProjectRelatedNotification({ project, eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_CANCELLED, }); } - projectDeListed(params: { project: Project }): Promise { + async projectDeListed(params: { project: Project }): Promise { const { project } = params; - return this.sendProjectRelatedNotification({ + + const donors = await findUsersWhoDonatedToProject(project.id); + donors.map(user => + sendProjectRelatedNotificationsBalanceQueue.add({ + project, + eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_UNLISTED_DONORS, + user, + }), + ); + + const usersWhoLiked = await findUsersWhoLikedProject(project.id); + usersWhoLiked.map(user => + sendProjectRelatedNotificationsBalanceQueue.add({ + project, + eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_UNLISTED_USERS_WHO_LIKED, + user, + }), + ); + await this.sendProjectRelatedNotification({ project, eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_UNLISTED, }); } - projectDeactivated(params: { project: Project }): Promise { - const { project } = params; - return this.sendProjectRelatedNotification({ + async projectDeactivated(params: { + project: Project; + reason?: string; + }): Promise { + const { project, reason } = params; + const metadata = { + reason, + }; + const donors = await findUsersWhoDonatedToProject(project.id); + donors.map(user => + sendProjectRelatedNotificationsBalanceQueue.add({ + project, + eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_DEACTIVATED_DONORS, + user, + metadata, + }), + ); + + const usersWhoLiked = await findUsersWhoLikedProject(project.id); + usersWhoLiked.map(user => + sendProjectRelatedNotificationsBalanceQueue.add({ + project, + eventName: + NOTIFICATIONS_EVENT_NAMES.PROJECT_DEACTIVATED_USERS_WHO_LIKED, + user, + metadata, + }), + ); + await this.sendProjectRelatedNotification({ project, eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_DEACTIVATED, + metadata, }); } - projectListed(params: { project: Project }): Promise { + async projectListed(params: { project: Project }): Promise { const { project } = params; - return this.sendProjectRelatedNotification({ + + await this.sendProjectRelatedNotification({ project, eventName: NOTIFICATIONS_EVENT_NAMES.PROJECT_LISTED, }); @@ -146,14 +272,19 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { project: Project; eventName: NOTIFICATIONS_EVENT_NAMES; metadata?: any; + user?: { + walletAddress: string; + email?: string; + }; }): Promise { - const { project, eventName, metadata } = params; + const { project, eventName, metadata, user } = params; + const receivedUser = user || (project.adminUser as User); return this.callSendNotification({ eventName, - email: project.adminUser?.email, + email: receivedUser.email, // currently Segment handle sending emails, so notification-center doesnt need to send that sendEmail: false, - userWalletAddress: String(project.adminUser?.walletAddress), + userWalletAddress: receivedUser.walletAddress as string, projectId: String(project.id), metadata: { projectTitle: project.title, diff --git a/src/analytics/analytics.ts b/src/analytics/analytics.ts index d53396bc5..161547239 100644 --- a/src/analytics/analytics.ts +++ b/src/analytics/analytics.ts @@ -6,6 +6,8 @@ export enum NOTIFICATIONS_EVENT_NAMES { DRAFTED_PROJECT_ACTIVATED = 'Draft published', PROJECT_LISTED = 'Project listed', PROJECT_UNLISTED = 'Project unlisted', + PROJECT_UNLISTED_DONORS = 'Project unlisted - Donors', + PROJECT_UNLISTED_USERS_WHO_LIKED = 'Project unlisted - Users Who Liked', PROJECT_EDITED = 'Project edited', PROJECT_BADGE_REVOKED = 'Project badge revoked', PROJECT_BADGE_REVOKE_REMINDER = 'Project badge revoke reminder', @@ -20,14 +22,19 @@ export enum NOTIFICATIONS_EVENT_NAMES { PROJECT_UNVERIFIED = 'Project unverified', PROJECT_ACTIVATED = 'Project activated', PROJECT_DEACTIVATED = 'Project deactivated', + PROJECT_DEACTIVATED_DONORS = 'Project deactivated - Donors', + PROJECT_DEACTIVATED_USERS_WHO_LIKED = 'Project deactivated - Users Who Liked', + PROJECT_CANCELLED = 'Project cancelled', + PROJECT_CANCELLED_DONORS = 'Project cancelled - Donors', + PROJECT_CANCELLED_USERS_WHO_LIKED = 'Project cancelled - Users Who Liked', SEND_EMAIL_CONFIRMATION = 'Send email confirmation', MADE_DONATION = 'Made donation', DONATION_RECEIVED = 'Donation received', PROJECT_RECEIVED_HEART = 'project liked', PROJECT_UPDATED_DONOR = 'Project updated - donor', PROJECT_UPDATED_OWNER = 'Project updated - owner', - PROJECT_CREATED = 'Project created', + PROJECT_CREATED = 'The project saved as draft', UPDATED_PROFILE = 'Updated profile', GET_DONATION_PRICE_FAILED = 'Get Donation Price Failed', VERIFICATION_FORM_GOT_DRAFT_BY_ADMIN = 'Verification form got draft by admin', diff --git a/src/entities/reaction.ts b/src/entities/reaction.ts index 348faa9d8..203244ba7 100644 --- a/src/entities/reaction.ts +++ b/src/entities/reaction.ts @@ -10,6 +10,7 @@ import { Index, } from 'typeorm'; import { Project, ProjectUpdate } from './project'; +import { User } from './user'; @Entity() @ObjectType() @@ -29,8 +30,12 @@ export class Reaction extends BaseEntity { @Column({ nullable: true }) projectUpdateId: number; + @Field(type => User, { nullable: true }) + @ManyToOne(type => User, { nullable: true }) + user: User; @Field(type => ID) @Column() + @RelationId((reaction: Reaction) => reaction.user) userId: number; @Field(type => String) diff --git a/src/repositories/donationRepository.test.ts b/src/repositories/donationRepository.test.ts index 6b910c975..f0a377e1f 100644 --- a/src/repositories/donationRepository.test.ts +++ b/src/repositories/donationRepository.test.ts @@ -5,6 +5,7 @@ import { generateRandomTxHash, saveDonationDirectlyToDb, saveProjectDirectlyToDb, + saveUserDirectlyToDb, SEED_DATA, } from '../../test/testUtils'; import { User, UserRole } from '../entities/user'; @@ -13,6 +14,7 @@ import { createDonation, findDonationById, findDonationsByTransactionId, + findUsersWhoDonatedToProject, } from './donationRepository'; describe('createDonation test cases', () => { @@ -55,6 +57,10 @@ describe( findDonationsByTransactionIdTestCases, ); describe('findDonationById() test cases', findDonationByIdTestCases); +describe( + 'findUsersWhoDonatedToProject() test cases', + findUsersWhoDonatedToProjectTestCases, +); function findDonationsByTransactionIdTestCases() { it('should return donation with txHash ', async () => { @@ -119,3 +125,50 @@ function findDonationByIdTestCases() { assert.isNotOk(fetchedDonation); }); } + +function findUsersWhoDonatedToProjectTestCases() { + it('should find wallet addresses of who donated to a project', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const donor1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const donor2 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const donor3 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + await saveDonationDirectlyToDb(createDonationData(), donor1.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor2.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor3.id, project.id); + const users = await findUsersWhoDonatedToProject(project.id); + assert.equal(users.length, 3); + assert.isOk( + users.find(user => user.walletAddress === donor1.walletAddress), + ); + assert.isOk( + users.find(user => user.walletAddress === donor2.walletAddress), + ); + assert.isOk( + users.find(user => user.walletAddress === donor3.walletAddress), + ); + }); + it('should find wallet addresses of who donated to a project, not include repetitive items', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const donor1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const donor2 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const donor3 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + await saveDonationDirectlyToDb(createDonationData(), donor1.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor1.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor1.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor2.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor2.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor3.id, project.id); + await saveDonationDirectlyToDb(createDonationData(), donor3.id, project.id); + const users = await findUsersWhoDonatedToProject(project.id); + assert.equal(users.length, 3); + assert.isOk( + users.find(user => user.walletAddress === donor1.walletAddress), + ); + assert.isOk( + users.find(user => user.walletAddress === donor2.walletAddress), + ); + assert.isOk( + users.find(user => user.walletAddress === donor3.walletAddress), + ); + }); +} diff --git a/src/repositories/donationRepository.ts b/src/repositories/donationRepository.ts index 1cf81408f..935fedb3e 100644 --- a/src/repositories/donationRepository.ts +++ b/src/repositories/donationRepository.ts @@ -72,3 +72,23 @@ export const findDonationById = async ( .leftJoinAndSelect('donation.project', 'project') .getOne(); }; + +export const findUsersWhoDonatedToProject = async ( + projectId: number, +): Promise<{ walletAddress: string; email?: string }[]> => { + const donations = await Donation.createQueryBuilder('donation') + .leftJoin('donation.user', 'user') + .addSelect(['user.walletAddress', 'user.email']) + .distinctOn(['user.walletAddress']) + .where(`"projectId"=:projectId`, { + projectId, + }) + .getMany(); + + return donations.map(donation => { + return { + walletAddress: donation.user.walletAddress?.toLowerCase() as string, + email: donation.user.email, + }; + }); +}; diff --git a/src/repositories/reactionRepository.test.ts b/src/repositories/reactionRepository.test.ts new file mode 100644 index 000000000..16914c4ad --- /dev/null +++ b/src/repositories/reactionRepository.test.ts @@ -0,0 +1,56 @@ +import { + createProjectData, + generateRandomEtheriumAddress, + saveProjectDirectlyToDb, + saveUserDirectlyToDb, +} from '../../test/testUtils'; +import { assert } from 'chai'; +import { findUsersWhoLikedProject } from './reactionRepository'; +import { Reaction } from '../entities/reaction'; + +describe( + 'findUsersWhoLikedProject() test cases', + findUsersWhoLikedProjectTestCases, +); + +function findUsersWhoLikedProjectTestCases() { + it('should find wallet addresses of who liked to a project', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const firstUser1 = await saveUserDirectlyToDb( + generateRandomEtheriumAddress(), + ); + const firstUser2 = await saveUserDirectlyToDb( + generateRandomEtheriumAddress(), + ); + const firstUser3 = await saveUserDirectlyToDb( + generateRandomEtheriumAddress(), + ); + await Reaction.create({ + project, + user: firstUser1, + reaction: 'heart', + }).save(); + await Reaction.create({ + project, + user: firstUser2, + reaction: 'heart', + }).save(); + await Reaction.create({ + project, + user: firstUser3, + reaction: 'heart', + }).save(); + + const users = await findUsersWhoLikedProject(project.id); + assert.equal(users.length, 3); + assert.isOk( + users.find(user => user.walletAddress === firstUser1.walletAddress), + ); + assert.isOk( + users.find(user => user.walletAddress === firstUser2.walletAddress), + ); + assert.isOk( + users.find(user => user.walletAddress === firstUser3.walletAddress), + ); + }); +} diff --git a/src/repositories/reactionRepository.ts b/src/repositories/reactionRepository.ts new file mode 100644 index 000000000..e2ab00663 --- /dev/null +++ b/src/repositories/reactionRepository.ts @@ -0,0 +1,20 @@ +import { Reaction } from '../entities/reaction'; + +export const findUsersWhoLikedProject = async ( + projectId: number, +): Promise<{ walletAddress: string; email?: string }[]> => { + const reactions = await Reaction.createQueryBuilder('reaction') + .leftJoin('reaction.user', 'user') + .addSelect(['user.walletAddress']) + .where(`"projectId"=:projectId`, { + projectId, + }) + .getMany(); + + return reactions.map(reaction => { + return { + walletAddress: reaction.user.walletAddress?.toLowerCase() as string, + email: reaction.user.email, + }; + }); +}; diff --git a/src/services/powerBoostingService.ts b/src/services/powerBoostingService.ts deleted file mode 100644 index e69de29bb..000000000