From 0c449f43a88ae89fefe62d6f170b6d855cbaf021 Mon Sep 17 00:00:00 2001 From: Gosha Date: Sun, 17 Mar 2024 12:55:37 +0200 Subject: [PATCH 01/48] chore(ws): update query fetch by environment id --- .../src/shared/subscriber-online/subscriber-online.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ws/src/shared/subscriber-online/subscriber-online.service.ts b/apps/ws/src/shared/subscriber-online/subscriber-online.service.ts index 513e6578767..8a68cd02ab5 100644 --- a/apps/ws/src/shared/subscriber-online/subscriber-online.service.ts +++ b/apps/ws/src/shared/subscriber-online/subscriber-online.service.ts @@ -30,7 +30,7 @@ export class SubscriberOnlineService { private async updateOnlineStatus(subscriber: ISubscriberJwt, updatePayload: IUpdateSubscriberPayload) { await this.subscriberRepository.update( - { _id: subscriber._id, _organizationId: subscriber.organizationId }, + { _id: subscriber._id, _environmentId: subscriber.environmentId }, { $set: updatePayload, } From 8aff2d86583c4e9b52d6fb4fa8b9e93076afcdba Mon Sep 17 00:00:00 2001 From: Gosha Date: Sun, 17 Mar 2024 12:56:04 +0200 Subject: [PATCH 02/48] chore(dal): remove redundant indexes --- libs/dal/src/repositories/subscriber/subscriber.schema.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index 36d2f474583..2e596ecb757 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -10,12 +10,10 @@ const subscriberSchema = new Schema( _organizationId: { type: Schema.Types.ObjectId, ref: 'Organization', - index: true, }, _environmentId: { type: Schema.Types.ObjectId, ref: 'Environment', - index: true, }, firstName: Schema.Types.String, lastName: Schema.Types.String, From ac064cedec06c7e159cc842417409264c972530b Mon Sep 17 00:00:00 2001 From: Gosha Date: Sun, 17 Mar 2024 12:56:25 +0200 Subject: [PATCH 03/48] feat(dal): add unique index --- .../repositories/subscriber/subscriber.schema.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index 2e596ecb757..267a0b58cf9 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -163,11 +163,14 @@ subscriberSchema.index({ * subscriberId: /on-boarding-subscriber/i, * }); */ -subscriberSchema.index({ - subscriberId: 1, - _environmentId: 1, - _id: 1, -}); +subscriberSchema.index( + { + subscriberId: 1, + _environmentId: 1, + _id: 1, + }, + { unique: true } +); subscriberSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); From 23ca77b0c36536c2ade09f10860b44f0422e47b4 Mon Sep 17 00:00:00 2001 From: Gosha Date: Sun, 17 Mar 2024 18:38:15 +0200 Subject: [PATCH 04/48] feat(dal): add deleted index and type safety index --- .../repositories/subscriber/subscriber.schema.ts | 13 ++++++++++--- libs/dal/src/shared/types/index.ts | 1 + libs/dal/src/shared/types/index.type.ts | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 libs/dal/src/shared/types/index.ts create mode 100644 libs/dal/src/shared/types/index.type.ts diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index 267a0b58cf9..7c0ee1955c0 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -1,9 +1,10 @@ import * as mongoose from 'mongoose'; -import { Schema } from 'mongoose'; +import { IndexOptions, Schema } from 'mongoose'; import * as mongooseDelete from 'mongoose-delete'; import { schemaOptions } from '../schema-default.options'; -import { SubscriberDBModel } from './subscriber.entity'; +import { SubscriberDBModel, SubscriberEntity } from './subscriber.entity'; +import { IndexDefinition } from '../../shared/types'; const subscriberSchema = new Schema( { @@ -163,10 +164,12 @@ subscriberSchema.index({ * subscriberId: /on-boarding-subscriber/i, * }); */ -subscriberSchema.index( + +index( { subscriberId: 1, _environmentId: 1, + deleted: 1, _id: 1, }, { unique: true } @@ -178,3 +181,7 @@ subscriberSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, over export const Subscriber = (mongoose.models.Subscriber as mongoose.Model) || mongoose.model('Subscriber', subscriberSchema); + +function index(fields: IndexDefinition, options?: IndexOptions) { + subscriberSchema.index(fields, options); +} diff --git a/libs/dal/src/shared/types/index.ts b/libs/dal/src/shared/types/index.ts new file mode 100644 index 00000000000..4686f8ab840 --- /dev/null +++ b/libs/dal/src/shared/types/index.ts @@ -0,0 +1 @@ +export * from './index.type'; diff --git a/libs/dal/src/shared/types/index.type.ts b/libs/dal/src/shared/types/index.type.ts new file mode 100644 index 00000000000..9a14db2cab2 --- /dev/null +++ b/libs/dal/src/shared/types/index.type.ts @@ -0,0 +1,3 @@ +import { IndexDirection } from 'mongoose'; + +export type IndexDefinition = Partial>; From 6c33f45862395ee075be3dc8e02b28f3c3a3a357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Wed, 20 Mar 2024 15:17:13 +0100 Subject: [PATCH 05/48] feat: add annual toggle for business plan --- .source | 2 +- apps/api/src/app.module.ts | 2 +- ...-intent.e2e-ee.ts => upsert-setup-intent.e2e-ee.ts} | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) rename apps/api/src/app/testing/billing/{create-setup-intent.e2e-ee.ts => upsert-setup-intent.e2e-ee.ts} (94%) diff --git a/.source b/.source index 0d071faf25c..921288715ce 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 0d071faf25cf2e54cd6e60117aabb36fbb5110d9 +Subproject commit 921288715cefc5e86ba5dd78f0b495b5986e8b99 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cc6a90d62fd..aca7f1d7e8a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -45,7 +45,7 @@ const enterpriseImports = (): Array { +describe('Upsert setup intent', () => { const eeBilling = require('@novu/ee-billing'); - if (!eeBilling.CreateSetupIntent) { - throw new Error("CreateSetupIntent doesn't exist"); + if (!eeBilling.UpsertSetupIntent) { + throw new Error("UpsertSetupIntent doesn't exist"); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { CreateSetupIntent } = eeBilling; + const { UpsertSetupIntent } = eeBilling; const stubObject = { setupIntents: { @@ -54,7 +54,7 @@ describe('Create setup intent', () => { }); const createUseCase = () => { - const useCase = new CreateSetupIntent(stubObject, getCustomerUsecase, userRepository); + const useCase = new UpsertSetupIntent(stubObject, getCustomerUsecase, userRepository); return useCase; }; From 6001cd23788ec2705e35b0b6cd8a271bb5c72275 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Thu, 21 Mar 2024 12:14:24 +0530 Subject: [PATCH 06/48] fix: tooltip border --- .../activities/components/ActivityGraphGlobalStyles.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/src/pages/activities/components/ActivityGraphGlobalStyles.tsx b/apps/web/src/pages/activities/components/ActivityGraphGlobalStyles.tsx index e8268c8ec07..01f53c738f7 100644 --- a/apps/web/src/pages/activities/components/ActivityGraphGlobalStyles.tsx +++ b/apps/web/src/pages/activities/components/ActivityGraphGlobalStyles.tsx @@ -38,11 +38,9 @@ function chartStyles(isTriggerSent: boolean, isDark: boolean) { .tooltip-title { display: flex; - justify-content: center; height: 17px; margin-bottom: 4px; - border-width: 22px; color: ${colors.B60}; } @@ -51,10 +49,7 @@ function chartStyles(isTriggerSent: boolean, isDark: boolean) { display: flex; justify-content: center; font-weight: 700; - height: 17px; - border-width: 22px; - color: #ff512f; background: -webkit-linear-gradient(90deg, #dd2476 0%, #ff512f 100%); -webkit-background-clip: text; From b20f41d96f1d06c784ead4744b8873c56d4d26d4 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 22 Mar 2024 08:53:38 +0000 Subject: [PATCH 07/48] fix(web): Change billing tab name for simplicity --- apps/web/src/pages/settings/SettingsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/settings/SettingsPage.tsx b/apps/web/src/pages/settings/SettingsPage.tsx index 1291c6d666b..88d84bde12b 100644 --- a/apps/web/src/pages/settings/SettingsPage.tsx +++ b/apps/web/src/pages/settings/SettingsPage.tsx @@ -66,7 +66,7 @@ export function SettingsPage() { API Keys Email Settings - Billing Plans + Billing Permissions SSO From fdebe557626eb2e30817295a88d1fe438827f254 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 22 Mar 2024 08:54:17 +0000 Subject: [PATCH 08/48] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 921288715ce..ca645fc736c 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 921288715cefc5e86ba5dd78f0b495b5986e8b99 +Subproject commit ca645fc736c287ad8ad74ea15ac9fae18afd1e71 From 266e46cf764cb13f100a1d7532e71c057a8965bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 10:07:16 +0100 Subject: [PATCH 09/48] feat: add webhook listener for customer subscription updated --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index ca645fc736c..8de1f71d40b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit ca645fc736c287ad8ad74ea15ac9fae18afd1e71 +Subproject commit 8de1f71d40bafc80adef152cdc8afc15c118424a From 5f6c96d8293e4b056cffa14b5e5d68f8ab36180f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 10:09:43 +0100 Subject: [PATCH 10/48] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 8de1f71d40b..fda3d0652b7 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 8de1f71d40bafc80adef152cdc8afc15c118424a +Subproject commit fda3d0652b7c7b2f71d19f1df2a363cbd71cef7d From 6eee419c78cdd3ee5cdb7e167e2f966a0c645659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 11:02:26 +0100 Subject: [PATCH 11/48] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index fda3d0652b7..f985ad54300 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit fda3d0652b7c7b2f71d19f1df2a363cbd71cef7d +Subproject commit f985ad543009d7e68bd9e59a57ddb05efad287f6 From c119b4f2af5736ae0a24c901a15deacaba90cb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 11:19:50 +0100 Subject: [PATCH 12/48] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index f985ad54300..7eacb1fa76c 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit f985ad543009d7e68bd9e59a57ddb05efad287f6 +Subproject commit 7eacb1fa76c84e4e718a0806fc53756e9fe63de0 From 2a3ed6cded31f70f9638296525c6740c3d62866a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Sun, 24 Mar 2024 07:06:09 +0100 Subject: [PATCH 13/48] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 7eacb1fa76c..6947a6f8fe1 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 7eacb1fa76c84e4e718a0806fc53756e9fe63de0 +Subproject commit 6947a6f8fe1bfa7ac0823bf0a9ff79025eb6d345 From f07fcbe0c68c3a06f6ee2752dd5b359f22f80187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Sun, 24 Mar 2024 07:06:31 +0100 Subject: [PATCH 14/48] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index ca645fc736c..5838b168408 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit ca645fc736c287ad8ad74ea15ac9fae18afd1e71 +Subproject commit 5838b168408d31bc683a00a4a1f95b8bc1134373 From 57292c706a5928c5f67f93b8c8e33b88ed484086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Sun, 24 Mar 2024 07:41:06 +0100 Subject: [PATCH 15/48] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 6947a6f8fe1..b64b84657c0 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 6947a6f8fe1bfa7ac0823bf0a9ff79025eb6d345 +Subproject commit b64b84657c0a3adf739b04e72beeaea97f8c2bbd From 336570f4095aaf107d19b2e31f0feea6447836fa Mon Sep 17 00:00:00 2001 From: Gosha Date: Sun, 24 Mar 2024 17:47:00 +0200 Subject: [PATCH 16/48] feat: fetch in case race condition occurred on subscriber create --- ...e-duplicated-subscribers.migration.spec.ts | 121 ++++++++++++++++++ ...remove-duplicated-subscribers.migration.ts | 71 ++++++++++ .../subscriber/subscriber.repository.ts | 24 +++- .../subscriber/subscriber.schema.ts | 9 +- libs/dal/src/types/error.enum.ts | 3 + libs/dal/src/types/index.ts | 1 + .../create-subscriber.usecase.ts | 56 +++++--- 7 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts create mode 100644 apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts create mode 100644 libs/dal/src/types/error.enum.ts diff --git a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts new file mode 100644 index 00000000000..41beed75ec3 --- /dev/null +++ b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts @@ -0,0 +1,121 @@ +import { TopicSubscribersRepository, SubscriberRepository } from '@novu/dal'; +import { SubscribersService, UserSession } from '@novu/testing'; + +import { removeDuplicatedSubscribers } from './remove-duplicated-subscribers.migration'; +import { expect } from 'chai'; + +describe('Migration: Remove Duplicated Subscribers', () => { + let session: UserSession; + let subscriberService: SubscribersService; + const subscriberRepository = new SubscriberRepository(); + const topicSubscribersRepository = new TopicSubscribersRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + subscriberService = new SubscribersService(session.organization._id, session.environment._id); + }); + + it('should remove duplicated subscribers', async () => { + const duplicatedSubscriberId = '123'; + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'first_subscriber', + _environmentId: session.environment._id, + _organizationId: session.organization._id, + }); + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'mid_subscriber', + _environmentId: session.environment._id, + _organizationId: session.organization._id, + }); + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'last_subscriber', + _environmentId: session.environment._id, + _organizationId: session.organization._id, + }); + + const duplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + + expect(duplicates.length).to.equal(3); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0].firstName).to.equal('last_subscriber'); + }); + + it('should always keep one subscriber per environment', async () => { + const duplicatedSubscriberId = '123'; + const firstEnvironmentId = session.environment._id; + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'env_1', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'env_1', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + const secondEnvironmentId = session.organization._id; + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'env_2', + _environmentId: secondEnvironmentId, + _organizationId: session.organization._id, + }); + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'env_2', + _environmentId: secondEnvironmentId, + _organizationId: session.organization._id, + }); + + const duplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(duplicates.length).to.equal(2); + + const duplicates2 = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(duplicates2.length).to.equal(2); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0].firstName).to.equal('env_1'); + + const remainingDuplicates2 = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: secondEnvironmentId, + }); + expect(remainingDuplicates2.length).to.equal(1); + expect(remainingDuplicates2[0].firstName).to.equal('env_2'); + }); +}); diff --git a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts new file mode 100644 index 00000000000..9fadd8d76f9 --- /dev/null +++ b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts @@ -0,0 +1,71 @@ +import '../../../src/config'; +import { AppModule } from '../../../src/app.module'; + +import { NestFactory } from '@nestjs/core'; + +import { SubscriberRepository } from '@novu/dal'; + +export async function removeDuplicatedSubscribers() { + // eslint-disable-next-line no-console + console.log('start migration - remove duplicated subscribers'); + + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + const batchSize = 1000; + const subscriberRepository = app.get(SubscriberRepository); + + const pipeline = [ + // Group by subscriberId and _environmentId + { + $group: { + _id: { subscriberId: '$subscriberId', environmentId: '$_environmentId' }, + count: { $sum: 1 }, + docs: { $push: '$_id' }, // Store document IDs for removal + }, + }, + // Filter groups having more than one document (duplicates) + { + $match: { + count: { $gt: 1 }, + }, + }, + ]; + + const cursor = await subscriberRepository._model.aggregate(pipeline, { + batchSize: batchSize, + readPreference: 'secondaryPreferred', + allowDiskUse: true, + }); + + for (const group of cursor) { + const docsToRemove = group.docs.slice(0, -1); // Keep the last created document, remove others + const { subscriberId, environmentId } = group._id; + + console.log( + 'deleting', + docsToRemove.length, + 'duplicates for subscriberId:', + subscriberId, + 'environmentId:', + environmentId + ); + + try { + const result = await subscriberRepository.deleteMany({ + _id: { $in: docsToRemove }, + subscriberId: subscriberId, + _environmentId: environmentId, + }); + console.log('Documents deleted:', result.modifiedCount); + } catch (err) { + console.error('Error deleting documents:', err); + } + } + + // eslint-disable-next-line no-console + console.log('end migration- remove duplicated subscribers'); + + app.close(); +} diff --git a/libs/dal/src/repositories/subscriber/subscriber.repository.ts b/libs/dal/src/repositories/subscriber/subscriber.repository.ts index 3c2fd22aa85..5fc6defe3eb 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.repository.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.repository.ts @@ -10,6 +10,8 @@ import type { EnforceEnvOrOrgIds } from '../../types'; import { EnvironmentId, ISubscribersDefine, OrganizationId } from '@novu/shared'; type SubscriberQuery = FilterQuery & EnforceEnvOrOrgIds; +type SubscriberDeleteQuery = Pick & EnforceEnvOrOrgIds; +type SubscriberDeleteManyQuery = Pick & EnforceEnvOrOrgIds; export class SubscriberRepository extends BaseRepository { private subscriber: SoftDeleteModel; @@ -151,21 +153,31 @@ export class SubscriberRepository extends BaseRepository Date: Mon, 25 Mar 2024 07:28:50 +0100 Subject: [PATCH 17/48] feat: add tests --- .source | 2 +- .../billing/annual-subscription.spec-ee.ts | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts diff --git a/.source b/.source index b64b84657c0..24e838d4745 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit b64b84657c0a3adf739b04e72beeaea97f8c2bbd +Subproject commit 24e838d47452e57280d5788f85169c2eb892f5c4 diff --git a/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts new file mode 100644 index 00000000000..ae680bac5e0 --- /dev/null +++ b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts @@ -0,0 +1,78 @@ +describe('Billing - Annual subscription', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + }); + + it('should display monthly in modal as default', function () { + cy.intercept('POST', '**/billing/checkout', { + data: { + clientSecret: 'seti_1Mm8s8LkdIwHu7ix0OXBfTRG_secret_NXDICkPqPeiBTAFqWmkbff09lRmSVXe', + }, + }).as('checkout'); + + cy.visit('/settings/billing'); + + cy.getByTestId('upgrade-button').click(); + cy.wait(['@checkout']); + + cy.getByTestId('billing-interval-control-monthly') + .last() + .parent() + .should('have.class', 'mantine-SegmentedControl-labelActive'); + + cy.getByTestId('modal-monthly-pricing').should('exist'); + }); + + it('should display annually if it is selected before open modal', function () { + cy.intercept('POST', '**/billing/checkout', { + data: { + clientSecret: 'seti_1Mm8s8LkdIwHu7ix0OXBfTRG_secret_NXDICkPqPeiBTAFqWmkbff09lRmSVXe', + }, + }).as('checkout'); + + cy.visit('/settings/billing'); + + cy.getByTestId('billing-interval-control-annually').click(); + + cy.getByTestId('upgrade-button').click(); + cy.wait(['@checkout']); + + cy.getByTestId('billing-interval-control-annually') + .last() + .parent() + .should('have.class', 'mantine-SegmentedControl-labelActive'); + + cy.getByTestId('modal-anually-pricing').should('exist'); + }); + + it('should display billing page with billing interval control', function () { + cy.intercept('GET', '**/v1/organizations', (request) => { + request.reply((response) => { + if (!response.body.data) { + return response; + } + + response.body['data'] = [ + { + ...response.body.data[0], + apiServiceLevel: 'free', + }, + ]; + return response; + }); + }).as('organizations'); + + cy.visit('/settings/billing'); + + cy.getByTestId('billing-interval-control').should('exist'); + cy.getByTestId('billing-interval-price').should('have.text', '$250 month package / billed monthly'); + cy.getByTestId('billing-interval-control-annually').click(); + cy.getByTestId('billing-interval-control-annually') + .parent() + .should('have.class', 'mantine-SegmentedControl-labelActive'); + cy.getByTestId('billing-interval-price').should( + 'have.text', + `$${(2700).toLocaleString()} year package / billed annually` + ); + }); +}); From efa2e35c3702f2b686588fc356db7d3c91447c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Tue, 2 Apr 2024 10:46:21 +0200 Subject: [PATCH 18/48] fix: cspell error --- .cspell.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.cspell.json b/.cspell.json index f8502a905a6..7877a940759 100644 --- a/.cspell.json +++ b/.cspell.json @@ -611,14 +611,17 @@ "Pyroscope", "PYROSCOPE", "usecases", - "hbspt", + "hbspt", "prepopulating", "Vonage", "fieldtype", "usecase", "zulip", "uuidv", - "Vonage" + "Vonage", + "seti", + "NXDI", + "Wmkbff" ], "flagWords": [], "patterns": [ From 8a5e79667aec8bdc0a2ba345491cb4899066bbd5 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:19:25 +0100 Subject: [PATCH 19/48] revert: comment and whitespace --- apps/api/src/app.module.ts | 2 +- apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index aca7f1d7e8a..cc6a90d62fd 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -45,7 +45,7 @@ const enterpriseImports = (): Array { stubGetUser.rejects(new Error('User not found: user_id')); const useCase = createUseCase(); - try { await useCase.execute({ organizationId: 'organization_id', From c44410152f9beffbaad9b3c773a2df2e0b409c6c Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:24:06 +0100 Subject: [PATCH 20/48] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 25d4ce117b4..458beeafd9b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 25d4ce117b439d6c78d5f61f026b7edfd50f1e62 +Subproject commit 458beeafd9b0af4c83bd43cddf7d7f56a93c3190 From 313edab51c176ac9c61172e676f8a110b0855a10 Mon Sep 17 00:00:00 2001 From: Gosha Date: Thu, 4 Apr 2024 19:42:28 +0300 Subject: [PATCH 21/48] refactor(migration): merge subscriber metadata --- ...e-duplicated-subscribers.migration.spec.ts | 147 +++++++++++++++++- ...remove-duplicated-subscribers.migration.ts | 107 +++++++++++-- 2 files changed, 238 insertions(+), 16 deletions(-) diff --git a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts index 41beed75ec3..4ffcc06c202 100644 --- a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts +++ b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts @@ -1,4 +1,4 @@ -import { TopicSubscribersRepository, SubscriberRepository } from '@novu/dal'; +import { SubscriberRepository } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { removeDuplicatedSubscribers } from './remove-duplicated-subscribers.migration'; @@ -8,7 +8,6 @@ describe('Migration: Remove Duplicated Subscribers', () => { let session: UserSession; let subscriberService: SubscribersService; const subscriberRepository = new SubscriberRepository(); - const topicSubscribersRepository = new TopicSubscribersRepository(); beforeEach(async () => { session = new UserSession(); @@ -118,4 +117,148 @@ describe('Migration: Remove Duplicated Subscribers', () => { expect(remainingDuplicates2.length).to.equal(1); expect(remainingDuplicates2[0].firstName).to.equal('env_2'); }); + + it('should merge the metadata across duplicated subscribers', async () => { + const duplicatedSubscriberId = '123'; + const firstEnvironmentId = session.environment._id; + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'first_name', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + lastName: 'last_name', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + const duplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(duplicates.length).to.equal(2); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0].firstName).to.equal('first_name'); + expect(remainingDuplicates[0].lastName).to.equal('last_name'); + }); + + it('should merge the metadata across duplicated subscribers by latest created subscriber', async () => { + const duplicatedSubscriberId = '123'; + const firstEnvironmentId = session.environment._id; + + const firstCreatedSubscriber = await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_1', + lastName: 'last_name_1', + email: 'email_1', + phone: 'phone_1', + avatar: 'avatar_1', + locale: 'locale_1', + data: { key: 'value_1' }, + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_2', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + email: 'email_3', + phone: 'phone_3', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + avatar: 'avatar_4', + data: { newStuff: 'value_4' }, + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_5', + locale: 'locale_5', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + const duplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(duplicates.length).to.equal(5); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0]._id).to.equal(firstCreatedSubscriber._id); + expect(remainingDuplicates[0]._organizationId).to.equal(firstCreatedSubscriber._organizationId); + expect(remainingDuplicates[0]._environmentId).to.equal(firstCreatedSubscriber._environmentId); + expect(remainingDuplicates[0].__v).to.equal(firstCreatedSubscriber.__v); + + expect(remainingDuplicates[0].firstName).to.equal('first_name_5'); + expect(remainingDuplicates[0].lastName).to.equal('last_name_1'); + expect(remainingDuplicates[0].email).to.equal('email_3'); + expect(remainingDuplicates[0].phone).to.equal('phone_3'); + expect(remainingDuplicates[0].avatar).to.equal('avatar_4'); + expect(remainingDuplicates[0].locale).to.equal('locale_5'); + expect(remainingDuplicates[0].data?.key).to.be.undefined; + expect(remainingDuplicates[0].data?.newStuff).to.equal('value_4'); + }); + + it('should keep the first created subscriber after merge', async () => { + const duplicatedSubscriberId = '123'; + const firstEnvironmentId = session.environment._id; + + const firstCreatedSubscriber = await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'first_name', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + await subscriberRepository.create({ + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_2', + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + }); + + const duplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(duplicates.length).to.equal(2); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0]._id).to.equal(firstCreatedSubscriber._id); + }); }); diff --git a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts index 9fadd8d76f9..8b3f383fadb 100644 --- a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts +++ b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts @@ -1,9 +1,7 @@ import '../../../src/config'; -import { AppModule } from '../../../src/app.module'; - import { NestFactory } from '@nestjs/core'; - import { SubscriberRepository } from '@novu/dal'; +import { AppModule } from '../../../src/app.module'; export async function removeDuplicatedSubscribers() { // eslint-disable-next-line no-console @@ -22,7 +20,7 @@ export async function removeDuplicatedSubscribers() { $group: { _id: { subscriberId: '$subscriberId', environmentId: '$_environmentId' }, count: { $sum: 1 }, - docs: { $push: '$_id' }, // Store document IDs for removal + subscribers: { $push: '$$ROOT' }, // Store all documents of each group }, }, // Filter groups having more than one document (duplicates) @@ -40,32 +38,113 @@ export async function removeDuplicatedSubscribers() { }); for (const group of cursor) { - const docsToRemove = group.docs.slice(0, -1); // Keep the last created document, remove others const { subscriberId, environmentId } = group._id; + const subscribers = group.subscribers; + + if (subscribers.length <= 1) { + continue; + } + + // sort oldest subscriber first + const sortedSubscribers = subscribers.sort((a, b) => a.updatedAt - b.updatedAt); + const mergedSubscriber = mergeSubscribers(sortedSubscribers); + const subscribersToRemove = sortedSubscribers.filter((subscriber) => subscriber._id !== mergedSubscriber._id); + // eslint-disable-next-line no-console console.log( - 'deleting', - docsToRemove.length, - 'duplicates for subscriberId:', + 'Merged subscriber:', + mergedSubscriber._id.toString(), + 'subscriberId:', subscriberId, 'environmentId:', - environmentId + environmentId.toString() ); try { - const result = await subscriberRepository.deleteMany({ - _id: { $in: docsToRemove }, + await subscriberRepository.update( + { + _id: mergedSubscriber._id, + subscriberId: subscriberId, + _environmentId: environmentId, + }, + { + $set: mergedSubscriber, + } + ); + + // eslint-disable-next-line no-console + console.log( + 'Remaining subscriber updated with merged data for subscriberId:', + subscriberId, + 'subscriberId:', + mergedSubscriber._id.toString(), + 'environmentId:', + environmentId.toString() + ); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error updating remaining subscribers:', err); + } + + try { + // Delete all duplicates except the merged one + await subscriberRepository.deleteMany({ + _id: { $in: subscribersToRemove.map((subscriber) => subscriber._id) }, subscriberId: subscriberId, _environmentId: environmentId, }); - console.log('Documents deleted:', result.modifiedCount); + // eslint-disable-next-line no-console + console.log( + 'Duplicates deleted for subscriberId:', + subscriberId, + 'environmentId:', + environmentId.toString(), + 'ids:', + subscribersToRemove.map((subscriber) => subscriber._id).join() + ); } catch (err) { - console.error('Error deleting documents:', err); + // eslint-disable-next-line no-console + console.error('Error deleting duplicates:', err); } } // eslint-disable-next-line no-console - console.log('end migration- remove duplicated subscribers'); + console.log('end migration - remove duplicated subscribers'); app.close(); } + +// Function to merge subscriber information +function mergeSubscribers(subscribers) { + const mergedSubscriber = { ...subscribers[0] }; // Start with the first subscriber + + // Merge information from other subscribers + for (const subscriber of subscribers) { + const currentSubscriber = subscriber; + for (const key in currentSubscriber) { + // Skip internal and irrelevant fields + if ( + [ + '_id', + '_organizationId', + '_environmentId', + 'deleted', + 'createdAt', + 'updatedAt', + '__v', + 'isOnline', + 'lastOnlineAt', + ].includes(key) + ) { + continue; + } + + // Update with non-null/undefined values from subsequent subscribers + if (currentSubscriber[key] !== null && currentSubscriber[key] !== undefined) { + mergedSubscriber[key] = currentSubscriber[key]; + } + } + } + + return mergedSubscriber; +} From 977a5bc9c200f9e4939baee94d5e02a439242bbc Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:42:05 +0100 Subject: [PATCH 22/48] fix(worker): Skip template rendering for Echo Workflow steps (#5365) --- .../send-message/send-message-chat.usecase.ts | 14 ++++---- .../send-message-in-app.usecase.ts | 36 ++++++++++--------- .../send-message/send-message-push.usecase.ts | 26 +++++++------- .../send-message/send-message-sms.usecase.ts | 14 ++++---- 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts index 4a710fca18d..286885c7166 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts @@ -89,12 +89,14 @@ export class SendMessageChat extends SendMessageBase { let content = ''; try { - content = await this.compileTemplate.execute( - CompileTemplateCommand.create({ - template: step.template.content as string, - data: this.getCompilePayload(command.compileContext), - }) - ); + if (!command.chimeraData) { + content = await this.compileTemplate.execute( + CompileTemplateCommand.create({ + template: step.template.content as string, + data: this.getCompilePayload(command.compileContext), + }) + ); + } } catch (e) { await this.sendErrorHandlebars(command.job, e.message); diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts index 557b708b073..49648cc3b7a 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts @@ -107,25 +107,27 @@ export class SendMessageInApp extends SendMessageBase { } try { - const compiled = await this.compileInAppTemplate.execute( - CompileInAppTemplateCommand.create({ - organizationId: command.organizationId, - environmentId: command.environmentId, - payload: this.getCompilePayload(command.compileContext), - content: step.template.content as string, - cta: step.template.cta, - userId: command.userId, - }), - this.initiateTranslations.bind(this) - ); - content = compiled.content; + if (!command.chimeraData) { + const compiled = await this.compileInAppTemplate.execute( + CompileInAppTemplateCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + payload: this.getCompilePayload(command.compileContext), + content: step.template.content as string, + cta: step.template.cta, + userId: command.userId, + }), + this.initiateTranslations.bind(this) + ); + content = compiled.content; - if (step.template.cta?.data?.url) { - step.template.cta.data.url = compiled.url; - } + if (step.template.cta?.data?.url) { + step.template.cta.data.url = compiled.url; + } - if (step.template.cta?.action?.buttons) { - step.template.cta.action.buttons = compiled.ctaButtons; + if (step.template.cta?.action?.buttons) { + step.template.cta.action.buttons = compiled.ctaButtons; + } } } catch (e) { await this.sendErrorHandlebars(command.job, e.message); diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts index 3d50da8706d..070901ad582 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts @@ -91,19 +91,21 @@ export class SendMessagePush extends SendMessageBase { let title = ''; try { - content = await this.compileTemplate.execute( - CompileTemplateCommand.create({ - template: step.template?.content as string, - data, - }) - ); + if (!command.chimeraData) { + content = await this.compileTemplate.execute( + CompileTemplateCommand.create({ + template: step.template?.content as string, + data, + }) + ); - title = await this.compileTemplate.execute( - CompileTemplateCommand.create({ - template: step.template?.title as string, - data, - }) - ); + title = await this.compileTemplate.execute( + CompileTemplateCommand.create({ + template: step.template?.title as string, + data, + }) + ); + } } catch (e) { await this.sendErrorHandlebars(command.job, e.message); diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts index d26436ddc93..55cbabdd12f 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts @@ -89,12 +89,14 @@ export class SendMessageSms extends SendMessageBase { let content: string | null = ''; try { - content = await this.compileTemplate.execute( - CompileTemplateCommand.create({ - template: step.template.content as string, - data: this.getCompilePayload(command.compileContext), - }) - ); + if (!command.chimeraData) { + content = await this.compileTemplate.execute( + CompileTemplateCommand.create({ + template: step.template.content as string, + data: this.getCompilePayload(command.compileContext), + }) + ); + } } catch (e) { await this.sendErrorHandlebars(command.job, e.message); From 7c553aa244610206162e5cf4407f890eb3a6a647 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 5 Apr 2024 16:44:19 +0100 Subject: [PATCH 23/48] chore(infra): Remove CI steps for redundant general worker (#5366) --- .github/workflows/dev-deploy-worker.yml | 14 +--------- .github/workflows/prod-deploy-worker.yml | 33 ++---------------------- 2 files changed, 3 insertions(+), 44 deletions(-) diff --git a/.github/workflows/dev-deploy-worker.yml b/.github/workflows/dev-deploy-worker.yml index cfbfd53d0c9..03629d0ca8a 100644 --- a/.github/workflows/dev-deploy-worker.yml +++ b/.github/workflows/dev-deploy-worker.yml @@ -64,20 +64,8 @@ jobs: docker_name: ${{ matrix.name }} bullmq_secret: ${{ secrets.BULL_MQ_PRO_NPM_TOKEN }} - # Temporary for the migration phase - deploy_general_worker: - needs: build_dev_worker - uses: ./.github/workflows/reusable-app-service-deploy.yml - secrets: inherit - with: - environment: Development - service_name: worker - terraform_workspace: novu-dev - # This is a workaround to an issue with matrix outputs - docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }} - deploy_dev_workers: - needs: deploy_general_worker + needs: build_dev_worker uses: ./.github/workflows/reusable-workers-service-deploy.yml secrets: inherit with: diff --git a/.github/workflows/prod-deploy-worker.yml b/.github/workflows/prod-deploy-worker.yml index 8b0daade313..242c47e88fe 100644 --- a/.github/workflows/prod-deploy-worker.yml +++ b/.github/workflows/prod-deploy-worker.yml @@ -95,20 +95,8 @@ jobs: docker push ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG echo "IMAGE=ghcr.io/$REGISTRY_OWNER/$DOCKER_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT - # Temporary for the migration phase - deploy_general_worker_eu: - needs: build_prod_image - uses: ./.github/workflows/reusable-app-service-deploy.yml - secrets: inherit - with: - environment: Production - service_name: worker - terraform_workspace: novu-prod-eu - # This is a workaround to an issue with matrix outputs - docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }} - deploy_prod_workers_eu: - needs: deploy_general_worker_eu + needs: build_prod_image uses: ./.github/workflows/reusable-workers-service-deploy.yml secrets: inherit with: @@ -117,25 +105,8 @@ jobs: # This is a workaround to an issue with matrix outputs docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }} - - # Temporary for the migration phase - deploy_general_worker_us: - needs: - - deploy_prod_workers_eu - - build_prod_image - uses: ./.github/workflows/reusable-app-service-deploy.yml - secrets: inherit - with: - environment: Production - service_name: worker - terraform_workspace: novu-prod - # This is a workaround to an issue with matrix outputs - docker_image: ghcr.io/novuhq/novu/worker-ee:${{ github.sha }} - deploy_prod_workers_us: - needs: - - deploy_general_worker_us - - build_prod_image + needs: build_prod_image uses: ./.github/workflows/reusable-workers-service-deploy.yml secrets: inherit with: From b50e8187fcfb3513fb569b26ec50b5c31fcd4a6d Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:38:34 +0100 Subject: [PATCH 24/48] fix(worker): Move missing SMS content check inside echo conditional (#5369) --- .../usecases/send-message/send-message-sms.usecase.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts index 55cbabdd12f..8098303c613 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts @@ -96,6 +96,10 @@ export class SendMessageSms extends SendMessageBase { data: this.getCompilePayload(command.compileContext), }) ); + + if (!content) { + throw new PlatformException(`Unexpected error: SMS content is missing`); + } } } catch (e) { await this.sendErrorHandlebars(command.job, e.message); @@ -103,10 +107,6 @@ export class SendMessageSms extends SendMessageBase { return; } - if (!content) { - throw new PlatformException(`Unexpected error: SMS content is missing`); - } - const phone = command.payload.phone || subscriber.phone; if (!integration) { From fb18d2d2dd6367ee96e5d9657db2ab8a0eeadfb9 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 16:57:07 +0100 Subject: [PATCH 25/48] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 458beeafd9b..2a7331ac950 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 458beeafd9b0af4c83bd43cddf7d7f56a93c3190 +Subproject commit 2a7331ac9503f740b99e8c7544fb1b2bbbbe9c87 From 068668474ef33ccfeccdd4c371d01bbb76e0980f Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 17:50:55 +0100 Subject: [PATCH 26/48] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 2a7331ac950..1909332f4fd 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 2a7331ac9503f740b99e8c7544fb1b2bbbbe9c87 +Subproject commit 1909332f4fdfc703dcb4aac5f8e315a63e2083a0 From 5fd95fa9b770a2ac1acaa4f4655b62b612e2add9 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 17:57:47 +0100 Subject: [PATCH 27/48] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 1909332f4fd..aee55d1c8ad 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 1909332f4fdfc703dcb4aac5f8e315a63e2083a0 +Subproject commit aee55d1c8ad3c001d659ba18a13d41e410307cfd From 82178d9a6b02fa218117ec0fa421ce5ed94ccd25 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 18:03:24 +0100 Subject: [PATCH 28/48] fix: Ignore spelling in annual-subs spec --- .cspell.json | 3 --- apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.cspell.json b/.cspell.json index 0131b5620e3..78e40221ff3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -619,9 +619,6 @@ "zulip", "uuidv", "Vonage", - "seti", - "NXDI", - "Wmkbff", "runtimes", "cafebabe" ], diff --git a/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts index ae680bac5e0..fb20a5ada87 100644 --- a/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts +++ b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts @@ -1,3 +1,4 @@ +/** cspell:disable */ describe('Billing - Annual subscription', function () { beforeEach(function () { cy.initializeSession().as('session'); From e6852c95220472ebc6f906275eb0195465cc7fd7 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 9 Apr 2024 18:16:21 +0100 Subject: [PATCH 29/48] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index aee55d1c8ad..0955775cef6 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit aee55d1c8ad3c001d659ba18a13d41e410307cfd +Subproject commit 0955775cef6abfb262e0837dafb9f562566575b4 From c79d0bc1681543815c747641184bca94e3d5338d Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 9 Apr 2024 18:41:31 +0100 Subject: [PATCH 30/48] chore: update (#5388) --- apps/web/src/pages/auth/QuestionnairePage.tsx | 16 +++++++++++++--- .../src/types/feature-flags/feature-flags.ts | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/auth/QuestionnairePage.tsx b/apps/web/src/pages/auth/QuestionnairePage.tsx index 2afcf1ab85e..04d1c4df411 100644 --- a/apps/web/src/pages/auth/QuestionnairePage.tsx +++ b/apps/web/src/pages/auth/QuestionnairePage.tsx @@ -3,13 +3,18 @@ import AuthContainer from '../../components/layout/components/AuthContainer'; import { QuestionnaireForm } from './components/QuestionnaireForm'; import { useVercelIntegration } from '../../hooks'; import SetupLoader from './components/SetupLoader'; -import { ENV, IS_DOCKER_HOSTED } from '@novu/shared-web'; +import { ENV, IS_DOCKER_HOSTED, useFeatureFlag } from '@novu/shared-web'; import { HubspotSignupForm } from './components/HubspotSignupForm'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { When } from '@novu/design-system'; export default function QuestionnairePage() { const { isLoading } = useVercelIntegration(); + const isHubspotFormEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_HUBSPOT_ONBOARDING_ENABLED); const isNovuProd = !IS_DOCKER_HOSTED && ENV === 'production'; + const shouldUseHubspotForm = isHubspotFormEnabled && isNovuProd; + return ( {isLoading ? ( @@ -17,9 +22,14 @@ export default function QuestionnairePage() { ) : ( - {!isNovuProd ? : } + + + + + + )} diff --git a/libs/shared/src/types/feature-flags/feature-flags.ts b/libs/shared/src/types/feature-flags/feature-flags.ts index 15209ac9611..1eb4cf87b64 100644 --- a/libs/shared/src/types/feature-flags/feature-flags.ts +++ b/libs/shared/src/types/feature-flags/feature-flags.ts @@ -11,4 +11,6 @@ export enum FeatureFlagsKeysEnum { IS_ECHO_ENABLED = 'IS_ECHO_ENABLED', IS_IMPROVED_ONBOARDING_ENABLED = 'IS_IMPROVED_ONBOARDING_ENABLED', IS_NEW_MESSAGES_API_RESPONSE_ENABLED = 'IS_NEW_MESSAGES_API_RESPONSE_ENABLED', + IS_BILLING_REVERSE_TRIAL_ENABLED = 'IS_BILLING_REVERSE_TRIAL_ENABLED', + IS_HUBSPOT_ONBOARDING_ENABLED = 'IS_HUBSPOT_ONBOARDING_ENABLED', } From 43aea87870e967ac145beb4d423c38e823c036f2 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 3 Apr 2024 22:40:35 +0530 Subject: [PATCH 31/48] fix: cache validation during notification template promotion (#5352) --- ...te-notification-template-change.usecase.ts | 62 +++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts index 915c45c0f63..1a61fac3610 100644 --- a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts +++ b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts @@ -179,19 +179,7 @@ export class PromoteNotificationTemplateChange { return; } - await this.invalidateCache.invalidateByKey({ - key: buildNotificationTemplateKey({ - _id: newItem._id, - _environmentId: command.environmentId, - }), - }); - - await this.invalidateCache.invalidateByKey({ - key: buildNotificationTemplateIdentifierKey({ - templateIdentifier: newItem.triggers[0].identifier, - _environmentId: command.environmentId, - }), - }); + await this.invalidateNotificationTemplate(item, command.organizationId); return await this.notificationTemplateRepository.update( { @@ -215,15 +203,53 @@ export class PromoteNotificationTemplateChange { ); } - private async invalidateBlueprints(command: PromoteTypeChangeCommand) { - if (command.organizationId === this.blueprintOrganizationId) { - await this.invalidateCache.invalidateByKey({ - key: buildGroupedBlueprintsKey(), - }); + private async getProductionEnvironmentId(organizationId: string) { + const productionEnvironmentId = ( + await this.environmentRepository.findOrganizationEnvironments(organizationId) + )?.find((env) => env.name === 'Production')?._id; + + if (!productionEnvironmentId) { + throw new NotFoundException('Production environment not found'); } + + return productionEnvironmentId; } private get blueprintOrganizationId() { return NotificationTemplateRepository.getBlueprintOrganizationId(); } + + private async invalidateBlueprints(command: PromoteTypeChangeCommand) { + if (command.organizationId === this.blueprintOrganizationId) { + const productionEnvironmentId = await this.getProductionEnvironmentId(this.blueprintOrganizationId); + + if (productionEnvironmentId) { + await this.invalidateCache.invalidateByKey({ + key: buildGroupedBlueprintsKey(productionEnvironmentId), + }); + } + } + } + + private async invalidateNotificationTemplate(item: NotificationTemplateEntity, organizationId: string) { + const productionEnvironmentId = await this.getProductionEnvironmentId(organizationId); + + /** + * Only invalidate cache of Production environment cause the development environment cache invalidation is handled + * during the CRUD operations itself + */ + await this.invalidateCache.invalidateByKey({ + key: buildNotificationTemplateKey({ + _id: item._id, + _environmentId: productionEnvironmentId, + }), + }); + + await this.invalidateCache.invalidateByKey({ + key: buildNotificationTemplateIdentifierKey({ + templateIdentifier: item.triggers[0].identifier, + _environmentId: productionEnvironmentId, + }), + }); + } } From f7cbef4e69af8fe00b2beb16c0149da6e4d112c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Wed, 10 Apr 2024 09:46:45 +0200 Subject: [PATCH 32/48] Merge pull request #5389 from novuhq/feature/resend-reply-to feat: add reply to field in resend --- providers/resend/src/lib/resend.provider.spec.ts | 3 +++ providers/resend/src/lib/resend.provider.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/providers/resend/src/lib/resend.provider.spec.ts b/providers/resend/src/lib/resend.provider.spec.ts index 31cde396fd3..e3a492e8516 100644 --- a/providers/resend/src/lib/resend.provider.spec.ts +++ b/providers/resend/src/lib/resend.provider.spec.ts @@ -13,6 +13,7 @@ const mockNovuMessage = { to: ['test@test.com'], html: '
Mail Content
', subject: 'Test subject', + reply_to: 'no-reply@novu.co', attachments: [ { mime: 'text/plain', @@ -40,6 +41,7 @@ test('should trigger resend library correctly', async () => { html: mockNovuMessage.html, subject: mockNovuMessage.subject, attachments: mockNovuMessage.attachments, + reply_to: mockNovuMessage.reply_to, }); }); @@ -69,6 +71,7 @@ test('should trigger resend email with From Name', async () => { filename: attachment?.name, content: attachment.file, })), + reply_to: null, cc: undefined, bcc: undefined, }); diff --git a/providers/resend/src/lib/resend.provider.ts b/providers/resend/src/lib/resend.provider.ts index ec924a64c30..cf8061ab72b 100644 --- a/providers/resend/src/lib/resend.provider.ts +++ b/providers/resend/src/lib/resend.provider.ts @@ -36,6 +36,7 @@ export class ResendEmailProvider implements IEmailProvider { text: options.text, html: options.html, cc: options.cc, + reply_to: options.replyTo || null, attachments: options.attachments?.map((attachment) => ({ filename: attachment?.name, content: attachment.file, From 3300e22ea733a07c16c0e64f8ee7ee2b6fb7d49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 10 Apr 2024 10:37:23 +0200 Subject: [PATCH 33/48] chore(api): added the missing code after the cherry-pick --- .../src/app/blueprint/blueprint.controller.ts | 31 ++++++++++++++++--- .../e2e/get-grouped-blueprints.e2e.ts | 4 ++- .../get-grouped-blueprints.command.ts | 4 +-- .../get-grouped-blueprints.usecase.ts | 6 ++-- ...te-notification-template-change.usecase.ts | 2 ++ .../services/cache/key-builders/entities.ts | 4 +-- 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/api/src/app/blueprint/blueprint.controller.ts b/apps/api/src/app/blueprint/blueprint.controller.ts index b9b17deb748..0efb9b61dcb 100644 --- a/apps/api/src/app/blueprint/blueprint.controller.ts +++ b/apps/api/src/app/blueprint/blueprint.controller.ts @@ -1,8 +1,9 @@ import { ClassSerializerInterceptor, Controller, Get, Param, UseInterceptors } from '@nestjs/common'; +import { EnvironmentRepository, NotificationTemplateRepository } from '@novu/dal'; import { GroupedBlueprintResponse } from './dto/grouped-blueprint.response.dto'; import { GetBlueprint, GetBlueprintCommand } from './usecases/get-blueprint'; -import { GetGroupedBlueprints } from './usecases/get-grouped-blueprints'; +import { GetGroupedBlueprints, GetGroupedBlueprintsCommand } from './usecases/get-grouped-blueprints'; import { GetBlueprintResponse } from './dto/get-blueprint.response.dto'; import { ApiCommonResponses } from '../shared/framework/response.decorator'; @@ -10,11 +11,33 @@ import { ApiCommonResponses } from '../shared/framework/response.decorator'; @Controller('/blueprints') @UseInterceptors(ClassSerializerInterceptor) export class BlueprintController { - constructor(private getBlueprintUsecase: GetBlueprint, private getGroupedBlueprintsUsecase: GetGroupedBlueprints) {} + constructor( + private environmentRepository: EnvironmentRepository, + private getBlueprintUsecase: GetBlueprint, + private getGroupedBlueprintsUsecase: GetGroupedBlueprints + ) {} @Get('/group-by-category') - getGroupedBlueprints(): Promise { - return this.getGroupedBlueprintsUsecase.execute(); + async getGroupedBlueprints(): Promise { + const prodEnvironmentId = await this.getProdEnvironmentId(); + + return this.getGroupedBlueprintsUsecase.execute( + GetGroupedBlueprintsCommand.create({ environmentId: prodEnvironmentId }) + ); + } + + private async getProdEnvironmentId() { + const productionEnvironmentId = ( + await this.environmentRepository.findOrganizationEnvironments( + NotificationTemplateRepository.getBlueprintOrganizationId() || '' + ) + )?.find((env) => env.name === 'Production')?._id; + + if (!productionEnvironmentId) { + throw new Error('Production environment id was not found'); + } + + return productionEnvironmentId; } @Get('/:templateIdOrIdentifier') diff --git a/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts b/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts index c192b7850a1..252ccfaa04b 100644 --- a/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts +++ b/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts @@ -112,6 +112,8 @@ describe('Get grouped notification template blueprints - /blueprints/group-by-ca it('should update the static POPULAR_TEMPLATES_GROUPED with fresh data', async () => { const prodEnv = await getProductionEnvironment(); + if (!prodEnv) throw new Error('production environment was not found'); + await createTemplateFromBlueprint({ session, notificationTemplateRepository, prodEnv }); const data = await session.testAgent.get(`/v1/blueprints/group-by-category`).send(); @@ -128,7 +130,7 @@ describe('Get grouped notification template blueprints - /blueprints/group-by-ca indexModuleStub.value(mockedValue); await invalidateCache.invalidateByKey({ - key: buildGroupedBlueprintsKey(), + key: buildGroupedBlueprintsKey(prodEnv._id), }); const updatedBlueprintFromDb = (await session.testAgent.get(`/v1/blueprints/group-by-category`).send()).body.data diff --git a/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.command.ts b/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.command.ts index 75655721246..f72675eeda9 100644 --- a/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.command.ts +++ b/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.command.ts @@ -1,3 +1,3 @@ -import { BaseCommand } from '@novu/application-generic'; +import { EnvironmentLevelCommand } from '@novu/application-generic'; -export class GetGroupedBlueprintsCommand extends BaseCommand {} +export class GetGroupedBlueprintsCommand extends EnvironmentLevelCommand {} diff --git a/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.usecase.ts b/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.usecase.ts index 613c3259038..95bb8599bde 100644 --- a/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.usecase.ts +++ b/apps/api/src/app/blueprint/usecases/get-grouped-blueprints/get-grouped-blueprints.usecase.ts @@ -4,7 +4,7 @@ import { buildGroupedBlueprintsKey, CachedEntity } from '@novu/application-gener import { INotificationTemplate, IGroupedBlueprint } from '@novu/shared'; import { GroupedBlueprintResponse } from '../../dto/grouped-blueprint.response.dto'; -import { POPULAR_GROUPED_NAME, POPULAR_TEMPLATES_ID_LIST } from './index'; +import { GetGroupedBlueprintsCommand, POPULAR_GROUPED_NAME, POPULAR_TEMPLATES_ID_LIST } from './index'; const WEEK_IN_SECONDS = 60 * 60 * 24 * 7; @@ -13,10 +13,10 @@ export class GetGroupedBlueprints { constructor(private notificationTemplateRepository: NotificationTemplateRepository) {} @CachedEntity({ - builder: () => buildGroupedBlueprintsKey(), + builder: (command: GetGroupedBlueprintsCommand) => buildGroupedBlueprintsKey(command.environmentId), options: { ttl: WEEK_IN_SECONDS }, }) - async execute(): Promise { + async execute(command: GetGroupedBlueprintsCommand): Promise { const groups = await this.fetchGroupedBlueprints(); const updatePopularBlueprints = this.updatePopularBlueprints(groups); diff --git a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts index 1a61fac3610..8db69be4a37 100644 --- a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts +++ b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts @@ -7,6 +7,7 @@ import { NotificationStepEntity, NotificationGroupRepository, StepVariantEntity, + EnvironmentRepository, } from '@novu/dal'; import { ChangeEntityTypeEnum } from '@novu/shared'; import { @@ -24,6 +25,7 @@ export class PromoteNotificationTemplateChange { constructor( private invalidateCache: InvalidateCacheService, private notificationTemplateRepository: NotificationTemplateRepository, + private environmentRepository: EnvironmentRepository, private messageTemplateRepository: MessageTemplateRepository, private notificationGroupRepository: NotificationGroupRepository, @Inject(forwardRef(() => ApplyChange)) private applyChange: ApplyChange, diff --git a/packages/application-generic/src/services/cache/key-builders/entities.ts b/packages/application-generic/src/services/cache/key-builders/entities.ts index b482a3cbc10..5c45baa4af1 100644 --- a/packages/application-generic/src/services/cache/key-builders/entities.ts +++ b/packages/application-generic/src/services/cache/key-builders/entities.ts @@ -84,12 +84,12 @@ const buildEnvironmentByApiKey = ({ apiKey }: { apiKey: string }): string => identifierPrefix: IdentifierPrefixEnum.API_KEY, }); -const buildGroupedBlueprintsKey = (): string => +const buildGroupedBlueprintsKey = (environmentId: string): string => buildCommonKey({ type: CacheKeyTypeEnum.ENTITY, keyEntity: CacheKeyPrefixEnum.GROUPED_BLUEPRINTS, environmentIdPrefix: OrgScopePrefixEnum.ORGANIZATION_ID, - environmentId: process.env.BLUEPRINT_CREATOR, + environmentId: environmentId, identifierPrefix: IdentifierPrefixEnum.GROUPED_BLUEPRINT, identifier: BLUEPRINT_IDENTIFIER, }); From 596b213bd19db4a93e29651894e275ece7cae4f7 Mon Sep 17 00:00:00 2001 From: Gosha Date: Thu, 11 Apr 2024 11:12:07 +0300 Subject: [PATCH 34/48] feat(migration): add considerate channel merge --- ...e-duplicated-subscribers.migration.spec.ts | 203 ++++++++++++++++++ ...remove-duplicated-subscribers.migration.ts | 45 +++- 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts index 4ffcc06c202..e15bee7f094 100644 --- a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts +++ b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.spec.ts @@ -3,6 +3,7 @@ import { SubscribersService, UserSession } from '@novu/testing'; import { removeDuplicatedSubscribers } from './remove-duplicated-subscribers.migration'; import { expect } from 'chai'; +import { ChatProviderIdEnum, IChannelSettings, ISubscriber } from '@novu/shared'; describe('Migration: Remove Duplicated Subscribers', () => { let session: UserSession; @@ -228,6 +229,208 @@ describe('Migration: Remove Duplicated Subscribers', () => { expect(remainingDuplicates[0].data?.newStuff).to.equal('value_4'); }); + it('should merge 2 channel integration', async () => { + const duplicatedSubscriberId = '123'; + const firstEnvironmentId = session.environment._id; + + const subscriber1: ISubscriber = { + email: 'email_1', + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_1', + lastName: 'last_name_1', + channels: [{ _integrationId: '1', providerId: ChatProviderIdEnum.Slack, credentials: { webhookUrl: 'url_1' } }], + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + deleted: false, + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + }; + const firstCreatedSubscriber = await subscriberRepository.create(subscriber1); + + const subscriber2: ISubscriber = { + email: 'email_1', + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_1', + lastName: 'last_name_1', + channels: [ + { + _integrationId: '2', + providerId: ChatProviderIdEnum.Discord, + credentials: { deviceTokens: ['token_123', 'token_123'] }, + }, + ], + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + deleted: false, + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + }; + await subscriberRepository.create(subscriber2); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0]._id).to.equal(firstCreatedSubscriber._id); + expect(remainingDuplicates[0]._organizationId).to.equal(firstCreatedSubscriber._organizationId); + expect(remainingDuplicates[0]._environmentId).to.equal(firstCreatedSubscriber._environmentId); + expect(remainingDuplicates[0].email).to.equal('email_1'); + expect(remainingDuplicates[0].firstName).to.equal('first_name_1'); + expect(remainingDuplicates[0].lastName).to.equal('last_name_1'); + expect(remainingDuplicates[0].createdAt).to.equal('2021-01-01T00:00:00.000Z'); + + const firstChannel: IChannelSettings | undefined = remainingDuplicates[0].channels?.find( + (channel) => channel._integrationId === '1' + ); + expect(firstChannel?._integrationId).to.equal('1'); + expect(firstChannel?.providerId).to.equal(ChatProviderIdEnum.Slack); + expect(firstChannel?.credentials.webhookUrl).to.equal('url_1'); + + const secondChannel: IChannelSettings | undefined = remainingDuplicates[0].channels?.find( + (channel) => channel._integrationId === '2' + ); + expect(secondChannel?._integrationId).to.equal('2'); + expect(secondChannel?.providerId).to.equal(ChatProviderIdEnum.Discord); + expect(secondChannel?.credentials.deviceTokens).to.deep.equal(['token_123']); + }); + + it('should merge 2 channel same integration', async () => { + const duplicatedSubscriberId = '123'; + const firstEnvironmentId = session.environment._id; + + const subscriber1: ISubscriber = { + email: 'email_1', + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_1', + lastName: 'last_name_1', + channels: [ + { + _integrationId: '1', + providerId: ChatProviderIdEnum.Discord, + credentials: { deviceTokens: ['token_1', 'token_2'] }, + }, + ], + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + deleted: false, + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + }; + const firstCreatedSubscriber = await subscriberRepository.create(subscriber1); + + const subscriber2: ISubscriber = { + email: 'email_1', + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_1', + lastName: 'last_name_1', + channels: [ + { + _integrationId: '1', + providerId: ChatProviderIdEnum.Discord, + credentials: { deviceTokens: ['token_2', 'token_3', 'token_3'] }, + }, + ], + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + deleted: false, + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + }; + await subscriberRepository.create(subscriber2); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0]._id).to.equal(firstCreatedSubscriber._id); + expect(remainingDuplicates[0]._organizationId).to.equal(firstCreatedSubscriber._organizationId); + expect(remainingDuplicates[0]._environmentId).to.equal(firstCreatedSubscriber._environmentId); + expect(remainingDuplicates[0].email).to.equal('email_1'); + expect(remainingDuplicates[0].firstName).to.equal('first_name_1'); + expect(remainingDuplicates[0].lastName).to.equal('last_name_1'); + expect(remainingDuplicates[0].createdAt).to.equal('2021-01-01T00:00:00.000Z'); + + const firstChannel: IChannelSettings | undefined = remainingDuplicates[0].channels?.find( + (channel) => channel._integrationId === '1' + ); + expect(firstChannel?._integrationId).to.equal('1'); + expect(firstChannel?.providerId).to.equal(ChatProviderIdEnum.Discord); + expect(firstChannel?.credentials.deviceTokens).to.deep.equal(['token_1', 'token_2', 'token_3']); + }); + + it('should merge 2 channel same integration', async () => { + const duplicatedSubscriberId = '123'; + const firstEnvironmentId = session.environment._id; + + const subscriber1: ISubscriber = { + email: 'email_1', + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_1', + lastName: 'last_name_1', + channels: [ + { + _integrationId: '1', + providerId: ChatProviderIdEnum.Slack, + credentials: { webhookUrl: 'old_url_1' }, + }, + ], + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + deleted: false, + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + }; + const firstCreatedSubscriber = await subscriberRepository.create(subscriber1); + + const subscriber2: ISubscriber = { + email: 'email_1', + subscriberId: duplicatedSubscriberId, + firstName: 'first_name_1', + lastName: 'last_name_1', + channels: [ + { + _integrationId: '1', + providerId: ChatProviderIdEnum.Slack, + credentials: { webhookUrl: 'new_url_1' }, + }, + ], + _environmentId: firstEnvironmentId, + _organizationId: session.organization._id, + deleted: false, + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + }; + await subscriberRepository.create(subscriber2); + + await removeDuplicatedSubscribers(); + + const remainingDuplicates = await subscriberRepository.find({ + subscriberId: duplicatedSubscriberId, + _environmentId: session.environment._id, + }); + expect(remainingDuplicates.length).to.equal(1); + expect(remainingDuplicates[0]._id).to.equal(firstCreatedSubscriber._id); + expect(remainingDuplicates[0]._organizationId).to.equal(firstCreatedSubscriber._organizationId); + expect(remainingDuplicates[0]._environmentId).to.equal(firstCreatedSubscriber._environmentId); + expect(remainingDuplicates[0].email).to.equal('email_1'); + expect(remainingDuplicates[0].firstName).to.equal('first_name_1'); + expect(remainingDuplicates[0].lastName).to.equal('last_name_1'); + expect(remainingDuplicates[0].createdAt).to.equal('2021-01-01T00:00:00.000Z'); + + const firstChannel: IChannelSettings | undefined = remainingDuplicates[0].channels?.find( + (channel) => channel._integrationId === '1' + ); + expect(firstChannel?._integrationId).to.equal('1'); + expect(firstChannel?.providerId).to.equal(ChatProviderIdEnum.Slack); + expect(firstChannel?.credentials.webhookUrl).to.be.equal('new_url_1'); + }); + it('should keep the first created subscriber after merge', async () => { const duplicatedSubscriberId = '123'; const firstEnvironmentId = session.environment._id; diff --git a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts index 8b3f383fadb..67d503fda0b 100644 --- a/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts +++ b/apps/api/migrations/subscribers/remove-duplicated-subscribers/remove-duplicated-subscribers.migration.ts @@ -2,6 +2,7 @@ import '../../../src/config'; import { NestFactory } from '@nestjs/core'; import { SubscriberRepository } from '@novu/dal'; import { AppModule } from '../../../src/app.module'; +import { IChannelSettings, ISubscriber } from '@novu/shared'; export async function removeDuplicatedSubscribers() { // eslint-disable-next-line no-console @@ -118,6 +119,9 @@ export async function removeDuplicatedSubscribers() { function mergeSubscribers(subscribers) { const mergedSubscriber = { ...subscribers[0] }; // Start with the first subscriber + // Initialize a map to store merged channels + const mergedChannelsMap = new Map(); + // Merge information from other subscribers for (const subscriber of subscribers) { const currentSubscriber = subscriber; @@ -141,10 +145,49 @@ function mergeSubscribers(subscribers) { // Update with non-null/undefined values from subsequent subscribers if (currentSubscriber[key] !== null && currentSubscriber[key] !== undefined) { - mergedSubscriber[key] = currentSubscriber[key]; + if (key === 'channels') { + mergeSubscriberChannels(currentSubscriber, mergedChannelsMap); + } else { + // For other keys, update directly + mergedSubscriber[key] = currentSubscriber[key]; + } } } } + // Convert merged channels map back to array + mergedSubscriber.channels = [...mergedChannelsMap.values()]; + return mergedSubscriber; } + +function mergeChannels(existingChannel: IChannelSettings, newChannel: IChannelSettings) { + const result = { ...existingChannel }; + + // Merge deviceTokens + const allTokens = [ + ...(existingChannel?.credentials?.deviceTokens || []), + ...(newChannel?.credentials?.deviceTokens || []), + ]; + result.credentials.deviceTokens = [...new Set(allTokens)]; + + if (newChannel.credentials.webhookUrl) { + existingChannel.credentials.webhookUrl = newChannel.credentials.webhookUrl; + } + + return existingChannel; +} + +function mergeSubscriberChannels(subscriber: ISubscriber, mergedChannelsMap) { + for (const channel of subscriber.channels || []) { + const integrationId = channel._integrationId; + if (!mergedChannelsMap.has(integrationId)) { + // merging the same channel as a workaround just to make sure we always remove token duplications + mergedChannelsMap.set(integrationId, mergeChannels(channel, channel)); + } else { + // If the integration ID exists, merge device tokens + const existingChannel = mergedChannelsMap.get(integrationId); + mergedChannelsMap.set(integrationId, mergeChannels(existingChannel, channel)); + } + } +} From 1a379638f66686ffb987a209aa39acf955df2e4b Mon Sep 17 00:00:00 2001 From: Gosha Date: Thu, 11 Apr 2024 11:17:49 +0300 Subject: [PATCH 35/48] feat(dal): remove delete param --- libs/dal/src/repositories/subscriber/subscriber.schema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index a0bd39edda6..3cf7ffe1b1f 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -172,12 +172,13 @@ subscriberSchema.index({ * subscriberId_2022:environmentId_123:_id_123 * subscriberId_2022:environmentId_123:_id_1234 * We expect an exception to be thrown when attempting to create two subscribers with the same subscriberId (e.g., 2022) within the same environment. + * + * We can not add `deleted` field to the index the client wont be able to delete twice subsbriber with the same subscriberId. */ index( { subscriberId: 1, _environmentId: 1, - deleted: 1, }, { unique: true } ); From 0f386d8ba5cfb40e0a41a3e778a3cea4adeae6f1 Mon Sep 17 00:00:00 2001 From: Gosha Date: Thu, 11 Apr 2024 11:20:19 +0300 Subject: [PATCH 36/48] fix(dal): typo --- libs/dal/src/repositories/subscriber/subscriber.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index 3cf7ffe1b1f..1dd91dab765 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -173,7 +173,7 @@ subscriberSchema.index({ * subscriberId_2022:environmentId_123:_id_1234 * We expect an exception to be thrown when attempting to create two subscribers with the same subscriberId (e.g., 2022) within the same environment. * - * We can not add `deleted` field to the index the client wont be able to delete twice subsbriber with the same subscriberId. + * We can not add `deleted` field to the index the client wont be able to delete twice subscriber with the same subscriberId. */ index( { From 22d0ee31c6a444149a75a4619f46597d8a425136 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Thu, 11 Apr 2024 12:27:23 +0100 Subject: [PATCH 37/48] test(api): Add upsert-subscription free trial test cases --- .../billing/upsert-subscription.e2e-ee.ts | 449 ++++++++++++------ 1 file changed, 297 insertions(+), 152 deletions(-) diff --git a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts index 38bb486c7d6..2877cc3faa5 100644 --- a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts @@ -37,6 +37,7 @@ describe('UpsertSubscription', () => { data: [ { id: 'subscription_id', + billing_cycle_anchor: 123456789, items: { data: [ { @@ -93,66 +94,152 @@ describe('UpsertSubscription', () => { subscriptions: { data: [] }, }; - it('should create a single subscription with monthly prices when billingInterval is month', async () => { - const useCase = createUseCase(); - - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerNoSubscriptions as any, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.MONTH, - }) - ); - - expect(createSubscriptionStub.lastCall.args).to.deep.equal([ - { - customer: 'customer_id', - items: [ - { - price: 'price_id_notifications', - }, - { - price: 'price_id_flat', - }, - ], - }, - ]); - }); - - it('should create two subscriptions, one with monthly prices and one with annual prices when billingInterval is year', async () => { - const useCase = createUseCase(); + describe('Monthly Billing Interval', () => { + it('should create a single subscription with monthly prices', async () => { + const useCase = createUseCase(); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerNoSubscriptions as any, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.YEAR, - }) - ); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); - expect(createSubscriptionStub.callCount).to.equal(2); - expect(createSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ - [ + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ { customer: 'customer_id', items: [ + { + price: 'price_id_notifications', + }, { price: 'price_id_flat', }, ], }, - ], - [ + ]); + }); + + it('should set the trial configuration for the subscription when trial days are provided', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, + trialPeriodDays: 10, + }) + ); + + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ { customer: 'customer_id', + trial_period_days: 10, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, items: [ { price: 'price_id_notifications', }, + { + price: 'price_id_flat', + }, ], }, - ], - ]); + ]); + }); + }); + + describe('Annual Billing Interval', () => { + it('should create two subscriptions, one with monthly prices and one with annual prices', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); + + expect(createSubscriptionStub.callCount).to.equal(2); + expect(createSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ + [ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ], + [ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_notifications', + }, + ], + }, + ], + ]); + }); + + it('should set the trial configuration for both subscriptions when trial days are provided', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.YEAR, + trialPeriodDays: 10, + }) + ); + + expect(createSubscriptionStub.callCount).to.equal(2); + expect(createSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ + [ + { + customer: 'customer_id', + trial_period_days: 10, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ], + [ + { + customer: 'customer_id', + trial_period_days: 10, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, + items: [ + { + price: 'price_id_notifications', + }, + ], + }, + ], + ]); + }); }); }); @@ -177,71 +264,124 @@ describe('UpsertSubscription', () => { }, }; - it('should update the existing subscription if the customer has one subscription and billingInterval is month', async () => { - const useCase = createUseCase(); + describe('Monthly Billing Interval', () => { + it('should update the existing subscription', async () => { + const useCase = createUseCase(); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerOneSubscription as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: StripeBillingIntervalEnum.MONTH, - }) - ); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerOneSubscription as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); - expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ - 'subscription_id', - { - items: [ - { - id: 'item_id_usage_notifications', - price: 'price_id_notifications', - }, - { - id: 'item_id_flat', - price: 'price_id_flat', - }, - ], - }, - ]); + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ + 'subscription_id', + { + items: [ + { + id: 'item_id_usage_notifications', + price: 'price_id_notifications', + }, + { + id: 'item_id_flat', + price: 'price_id_flat', + }, + ], + }, + ]); + }); }); - it('should create a new annual subscription and update the existing subscription if the customer has one subscription and billingInterval is year', async () => { - const useCase = createUseCase(); + describe('Annual Billing Interval', () => { + it('should create a new annual subscription and update the existing subscription', async () => { + const useCase = createUseCase(); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerOneSubscription as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: StripeBillingIntervalEnum.YEAR, - }) - ); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerOneSubscription as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); - expect(createSubscriptionStub.lastCall.args).to.deep.equal([ - { - customer: 'customer_id', - items: [ - { - price: 'price_id_flat', - }, - ], - }, - ]); + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ]); - expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ - 'subscription_id', - { - items: [ - { - id: 'item_id_usage_notifications', - price: 'price_id_notifications', - }, - { - id: 'item_id_flat', - deleted: true, + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ + 'subscription_id', + { + items: [ + { + id: 'item_id_usage_notifications', + price: 'price_id_notifications', + }, + { + id: 'item_id_flat', + deleted: true, + }, + ], + }, + ]); + }); + + it('should set the trial configuration for the newly created annual subscription from the existing licensed subscription', async () => { + const useCase = createUseCase(); + const customer = { + ...mockCustomerBase, + subscriptions: { + data: [ + { + id: 'subscription_id', + trial_end: 123456789, + items: { + data: [ + { + id: 'item_id_usage_notifications', + price: { recurring: { usage_type: StripeUsageTypeEnum.METERED } }, + }, + { id: 'item_id_flat', price: { recurring: { usage_type: StripeUsageTypeEnum.LICENSED } } }, + ], + }, + }, + ], + }, + }; + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); + + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ + { + customer: 'customer_id', + trial_end: 123456789, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, }, - ], - }, - ]); + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ]); + }); }); it('should throw an error if the licensed subscription is not found', async () => { @@ -293,66 +433,28 @@ describe('UpsertSubscription', () => { ], }, }; - it('should delete the licensed subscription and update the metered subscription if the customer has two subscriptions and billingInterval is month', async () => { - const useCase = createUseCase(); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerTwoSubscriptions as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: StripeBillingIntervalEnum.MONTH, - }) - ); - - expect(deleteSubscriptionStub.lastCall.args).to.deep.equal(['subscription_id_1', { prorate: true }]); - - expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ - 'subscription_id_2', - { - items: [ - { - id: 'item_id_flat', - price: 'price_id_flat', - }, - { - id: 'item_id_usage_notifications', - price: 'price_id_notifications', - }, - ], - }, - ]); - }); + describe('Monthly Billing Interval', () => { + it('should delete the licensed subscription and update the metered subscription', async () => { + const useCase = createUseCase(); - it('should update the existing subscriptions if the customer has two subscriptions and billingInterval is year', async () => { - const useCase = createUseCase(); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerTwoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerTwoSubscriptions as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: StripeBillingIntervalEnum.YEAR, - }) - ); + expect(deleteSubscriptionStub.lastCall.args).to.deep.equal(['subscription_id_1', { prorate: true }]); - expect(updateSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ - [ - 'subscription_id_1', - { - items: [ - { - id: 'item_id_flat', - price: 'price_id_flat', - }, - ], - }, - ], - [ + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ 'subscription_id_2', { items: [ { id: 'item_id_flat', - deleted: true, + price: 'price_id_flat', }, { id: 'item_id_usage_notifications', @@ -360,8 +462,51 @@ describe('UpsertSubscription', () => { }, ], }, - ], - ]); + ]); + }); + }); + + describe('Annual Billing Interval', () => { + it('should update the existing subscriptions', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerTwoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); + + expect(updateSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ + [ + 'subscription_id_1', + { + items: [ + { + id: 'item_id_flat', + price: 'price_id_flat', + }, + ], + }, + ], + [ + 'subscription_id_2', + { + items: [ + { + id: 'item_id_flat', + deleted: true, + }, + { + id: 'item_id_usage_notifications', + price: 'price_id_notifications', + }, + ], + }, + ], + ]); + }); }); it('should throw an error if the licensed subscription is not found', async () => { From 9ad5cd903330d12a091504ad358ba3d94e15ddda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 12 Apr 2024 07:08:47 +0200 Subject: [PATCH 38/48] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 0955775cef6..d97044cf381 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 0955775cef6abfb262e0837dafb9f562566575b4 +Subproject commit d97044cf38148cd560831e7106d5e2f61d20d60a From bfaf4aa96d9aa3ff882e125170f8bbba793f496c Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:35:25 +0100 Subject: [PATCH 39/48] test(api): Fix setup intent webhooks test --- apps/api/src/app/testing/billing/webhook.e2e-ee.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts index b67bf4379fe..33fbf2c11b1 100644 --- a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts @@ -63,6 +63,9 @@ describe('Stripe webhooks', () => { let verifyCustomerStub: sinon.SinonStub; let upsertSubscriptionStub: sinon.SinonStub; + const analyticsServiceStub = { + track: sinon.stub(), + }; beforeEach(() => { verifyCustomerStub = sinon.stub(VerifyCustomer.prototype, 'execute').resolves({ @@ -84,7 +87,8 @@ describe('Stripe webhooks', () => { organization: { _id: 'organization_id', apiServiceLevel: ApiServiceLevelEnum.FREE }, } as any); upsertSubscriptionStub = sinon.stub(UpsertSubscription.prototype, 'execute').resolves({ - id: 'subscription_id', + licensed: { id: 'licensed_subscription_id' }, + metered: { id: 'metered_subscription_id' }, } as any); updateCustomerStub = sinon.stub(stripeStub.customers, 'update').resolves({}); }); @@ -93,7 +97,8 @@ describe('Stripe webhooks', () => { const handler = new SetupIntentSucceededHandler( stripeStub as any, { execute: verifyCustomerStub } as any, - { execute: upsertSubscriptionStub } as any + { execute: upsertSubscriptionStub } as any, + analyticsServiceStub as any ); return handler; From bf04198664424a6fb2e47f96c16c924ec186d516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Fri, 12 Apr 2024 16:01:07 +0200 Subject: [PATCH 40/48] Merge pull request #5401 from novuhq/feature/onb-exp-v2-modal feat: add modal on get started page for onboarding experiment v2 --- .../web/src/constants/experimentsConstants.ts | 1 + .../auth/components/QuestionnaireForm.tsx | 3 +- .../components/OnboardingExperimentModal.tsx | 125 ++++++++++++++++++ .../pages/quick-start/steps/GetStarted.tsx | 15 ++- .../components/TriggerSnippetTabs.tsx | 29 ++-- .../templates/workflow/WorkflowEditor.tsx | 2 +- 6 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/constants/experimentsConstants.ts create mode 100644 apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx diff --git a/apps/web/src/constants/experimentsConstants.ts b/apps/web/src/constants/experimentsConstants.ts new file mode 100644 index 00000000000..2720076d019 --- /dev/null +++ b/apps/web/src/constants/experimentsConstants.ts @@ -0,0 +1 @@ +export const OnboardingExperimentV2ModalKey = 'nv_onboarding_modal'; diff --git a/apps/web/src/pages/auth/components/QuestionnaireForm.tsx b/apps/web/src/pages/auth/components/QuestionnaireForm.tsx index 1b9f2404cb7..d698ba91821 100644 --- a/apps/web/src/pages/auth/components/QuestionnaireForm.tsx +++ b/apps/web/src/pages/auth/components/QuestionnaireForm.tsx @@ -26,6 +26,7 @@ import { useVercelIntegration, useVercelParams } from '../../../hooks'; import { ROUTES } from '../../../constants/routes.enum'; import { DynamicCheckBox } from './dynamic-checkbox/DynamicCheckBox'; import styled from '@emotion/styled/macro'; +import { OnboardingExperimentV2ModalKey } from '../../../constants/experimentsConstants'; export function QuestionnaireForm() { const [loading, setLoading] = useState(); @@ -66,7 +67,7 @@ export function QuestionnaireForm() { const createDto: ICreateOrganizationDto = { ...rest, name: organizationName }; const organization = await createOrganizationMutation(createDto); const organizationResponseToken = await api.post(`/v1/auth/organizations/${organization._id}/switch`, {}); - + localStorage.setItem(OnboardingExperimentV2ModalKey, 'true'); setToken(organizationResponseToken); } diff --git a/apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx b/apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx new file mode 100644 index 00000000000..4aaf6e36e00 --- /dev/null +++ b/apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { Modal, useMantineTheme, Grid } from '@mantine/core'; + +import styled from '@emotion/styled'; +import { colors, shadows, Title, Button } from '@novu/design-system'; +import { useAuthContext, useSegment } from '@novu/shared-web'; +import { useCreateOnboardingExperimentWorkflow } from '../../../api/hooks/notification-templates/useCreateOnboardingExperimentWorkflow'; +import { OnboardingExperimentV2ModalKey } from '../../../constants/experimentsConstants'; +import { OnBoardingAnalyticsEnum } from '../consts'; + +export function OnboardingExperimentModal() { + const [opened, setOpened] = useState(true); + const theme = useMantineTheme(); + const segment = useSegment(); + const { currentOrganization } = useAuthContext(); + const { + createOnboardingExperimentWorkflow, + isLoading: IsCreateOnboardingExpWorkflowLoading, + isDisabled: isIsCreateOnboardingExpWorkflowDisabled, + } = useCreateOnboardingExperimentWorkflow(); + const handleOnClose = () => { + setOpened(true); + }; + + return ( + What would you like to do first?} + sx={{ backdropFilter: 'blur(10px)' }} + shadow={theme.colorScheme === 'dark' ? shadows.dark : shadows.medium} + radius="md" + size="lg" + onClose={handleOnClose} + centered + withCloseButton={false} + > + + + + Send test notification + Learn how to setup a workflow and send your first email notification. + { + segment.track(OnBoardingAnalyticsEnum.ONBOARDING_EXPERIMENT_TEST_NOTIFICATION, { + action: 'Modal - Send test notification', + experiment_id: '2024-w15-onb', + _organization: currentOrganization?._id, + }); + localStorage.removeItem(OnboardingExperimentV2ModalKey); + createOnboardingExperimentWorkflow(); + }} + > + Send test notification now + + + + + + Look around + Start exploring the Novu app on your own terms + { + segment.track(OnBoardingAnalyticsEnum.ONBOARDING_EXPERIMENT_TEST_NOTIFICATION, { + action: 'Modal - Get started', + experiment_id: '2024-w15-onb', + _organization: currentOrganization?._id, + }); + localStorage.removeItem(OnboardingExperimentV2ModalKey); + setOpened(false); + }} + > + Get started + + + + + + ); +} + +const ChannelCard = styled.div` + display: flex; + justify-content: space-between; + flex-direction: column; + max-width: 230px; +`; + +const TitleRow = styled.div` + display: flex; + align-items: center; + font-size: 20px; + line-height: 32px; + margin-bottom: 8px; +`; + +const Description = styled.div` + flex: auto; + font-size: 16px; + line-height: 20px; + margin-bottom: 20px; + color: ${colors.B60}; + height: 60px; +`; + +const StyledButton = styled(Button)` + width: fit-content; + outline: none; +`; diff --git a/apps/web/src/pages/quick-start/steps/GetStarted.tsx b/apps/web/src/pages/quick-start/steps/GetStarted.tsx index 74618334d5c..4a01f78f9b0 100644 --- a/apps/web/src/pages/quick-start/steps/GetStarted.tsx +++ b/apps/web/src/pages/quick-start/steps/GetStarted.tsx @@ -9,6 +9,9 @@ import { ChannelsConfiguration } from '../components/ChannelsConfiguration'; import { GetStartedLayout } from '../components/layout/GetStartedLayout'; import { NavButton } from '../components/NavButton'; import { getStartedSteps, OnBoardingAnalyticsEnum } from '../consts'; +import { OnboardingExperimentModal } from '../components/OnboardingExperimentModal'; +import { useAuthContext } from '@novu/shared-web'; +import { OnboardingExperimentV2ModalKey } from '../../../constants/experimentsConstants'; const ChannelsConfigurationHolder = styled.div` display: flex; @@ -25,16 +28,25 @@ const ChannelsConfigurationHolder = styled.div` export function GetStarted() { const segment = useSegment(); + const { currentOrganization } = useAuthContext(); const [clickedChannel, setClickedChannel] = useState<{ open: boolean; channelType?: ChannelTypeEnum; }>({ open: false }); + const isOnboardingModalEnabled = localStorage.getItem(OnboardingExperimentV2ModalKey) === 'true'; + const onIntegrationModalClose = () => setClickedChannel({ open: false }); useEffect(() => { segment.track(OnBoardingAnalyticsEnum.CONFIGURE_PROVIDER_VISIT); - }, [segment]); + if (isOnboardingModalEnabled) { + segment.track('Welcome modal open - [Onboarding]', { + experiment_id: '2024-w15-onb', + _organization: currentOrganization?._id, + }); + } + }, [currentOrganization?._id, isOnboardingModalEnabled, segment]); function handleOnClick() { segment.track(OnBoardingAnalyticsEnum.CONFIGURE_PROVIDER_NAVIGATION_NEXT_PAGE_CLICK); @@ -60,6 +72,7 @@ export function GetStarted() { /> + {isOnboardingModalEnabled && } ); } diff --git a/apps/web/src/pages/templates/components/TriggerSnippetTabs.tsx b/apps/web/src/pages/templates/components/TriggerSnippetTabs.tsx index 19c7e846d5a..e7d31ec13f7 100644 --- a/apps/web/src/pages/templates/components/TriggerSnippetTabs.tsx +++ b/apps/web/src/pages/templates/components/TriggerSnippetTabs.tsx @@ -66,8 +66,7 @@ novu.trigger('${identifier}', ${JSON.stringify( 2 ) .replace(/"([^"]+)":/g, '$1:') - .replace(/"/g, "'") - .replaceAll('\n', '\n ')}); + .replace(/"/g, "'")}); `; return ( @@ -85,19 +84,19 @@ export const getCurlTriggerSnippet = ( snippet?: Record ) => { const curlSnippet = `curl --location --request POST '${API_ROOT}/v1/events/trigger' \\ - --header 'Authorization: ApiKey ' \\ - --header 'Content-Type: application/json' \\ - --data-raw '${JSON.stringify( - { - name: identifier, - to, - payload, - overrides, - ...snippet, - }, - null, - 2 - ).replaceAll('\n', '\n ')}' +--header 'Authorization: ApiKey ' \\ +--header 'Content-Type: application/json' \\ +--data-raw '${JSON.stringify( + { + name: identifier, + to, + payload, + overrides, + ...snippet, + }, + null, + 2 + )}' `; return ( diff --git a/apps/web/src/pages/templates/workflow/WorkflowEditor.tsx b/apps/web/src/pages/templates/workflow/WorkflowEditor.tsx index fcd04da28a3..61b3ac9c551 100644 --- a/apps/web/src/pages/templates/workflow/WorkflowEditor.tsx +++ b/apps/web/src/pages/templates/workflow/WorkflowEditor.tsx @@ -293,7 +293,7 @@ const WorkflowEditor = () => { }} data-test-id="get-snippet-btn" > - {tagsIncludesOnboarding ? 'Test Notification Now' : 'Get Snippet'} + Trigger Notification From 15e7917f8919fbe335c36921e5a261a40f93239a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 12 Apr 2024 17:09:19 +0200 Subject: [PATCH 41/48] chore(web): fixed description in the onboarding experiment modal --- .../quick-start/components/OnboardingExperimentModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx b/apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx index 4aaf6e36e00..e02139bcea6 100644 --- a/apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx +++ b/apps/web/src/pages/quick-start/components/OnboardingExperimentModal.tsx @@ -49,8 +49,8 @@ export function OnboardingExperimentModal() { - Send test notification - Learn how to setup a workflow and send your first email notification. + Send test notification + Learn how to set up a workflow and send your first email notification. Date: Fri, 12 Apr 2024 17:27:01 +0100 Subject: [PATCH 42/48] test(api): Add customer.subscription.created tests --- .source | 2 +- .../src/app/testing/billing/webhook.e2e-ee.ts | 271 +++++++++++++++++- 2 files changed, 265 insertions(+), 8 deletions(-) diff --git a/.source b/.source index d97044cf381..dc7d549783b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit d97044cf38148cd560831e7106d5e2f61d20d60a +Subproject commit dc7d549783b0742ecadbb603419e1140962abf2f diff --git a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts index 33fbf2c11b1..084207b3ab8 100644 --- a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts @@ -49,6 +49,29 @@ describe('Stripe webhooks', () => { customers: { update: () => {}, }, + subscriptions: { + retrieve: () => + Promise.resolve({ + items: { + data: [ + { + id: 'subscription_id', + items: { data: [{ id: 'item_id_usage_notifications' }, { id: 'item_id_flat' }] }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }, + }, + }, + }, + ], + }, + }), + }, }; const eeBilling = require('@novu/ee-billing'); @@ -56,7 +79,8 @@ describe('Stripe webhooks', () => { throw new Error('ee-billing does not exist'); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { SetupIntentSucceededHandler, UpsertSubscription, VerifyCustomer } = eeBilling; + const { SetupIntentSucceededHandler, CustomerSubscriptionCreatedHandler, UpsertSubscription, VerifyCustomer } = + eeBilling; describe('setup_intent.succeeded', () => { let updateCustomerStub: sinon.SinonStub; @@ -148,14 +172,247 @@ describe('Stripe webhooks', () => { }); describe('customer.subscription.created', () => { - it('Should handle customer.subscription.created event with known organization', async () => { - // @TODO: Implement test - expect(true).to.equal(true); + let verifyCustomerStub: sinon.SinonStub; + const organizationRepositoryStub = { + update: sinon.stub().resolves({ matched: 1, modified: 1 }), + }; + const analyticsServiceStub = { + track: sinon.stub(), + }; + + beforeEach(() => { + verifyCustomerStub = sinon.stub(VerifyCustomer.prototype, 'execute').resolves({ + customer: { + id: 'customer_id', + deleted: false, + metadata: { + organizationId: 'organization_id', + }, + }, + organization: { _id: 'organization_id', apiServiceLevel: ApiServiceLevelEnum.FREE }, + } as any); }); - it('Should exit early with unknown organization', async () => { - // @TODO: Implement test - expect(true).to.equal(true); + afterEach(() => { + organizationRepositoryStub.update.reset(); + }); + + const createHandler = () => { + const handler = new CustomerSubscriptionCreatedHandler( + stripeStub as any, + { execute: verifyCustomerStub } as any, + organizationRepositoryStub, + analyticsServiceStub as any + ); + + return handler; + }; + + it('should handle event with known organization', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + const handler = createHandler(); + await handler.handle(event); + + expect( + organizationRepositoryStub.update.calledWith( + { _id: 'organization_id' }, + { apiServiceLevel: ApiServiceLevelEnum.BUSINESS } + ) + ).to.be.true; + }); + + it('should exit early with unknown organization', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + verifyCustomerStub.resolves({ + organization: null, + customer: { id: 'customer_id', metadata: { organizationId: 'org_id' } }, + }); + + const handler = createHandler(); + await handler.handle(event); + + expect(organizationRepositoryStub.update.called).to.be.false; + }); + + it('should handle event with known organization and licensed subscription', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }, + }, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + const handler = createHandler(); + await handler.handle(event); + + expect( + organizationRepositoryStub.update.calledWith( + { _id: 'organization_id' }, + { apiServiceLevel: ApiServiceLevelEnum.BUSINESS } + ) + ).to.be.true; + }); + + it('should exit early with known organization and metered subscription', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + price: { + recurring: { + usage_type: 'metered', + }, + product: { + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }, + }, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + const handler = createHandler(); + await handler.handle(event); + + expect( + organizationRepositoryStub.update.calledWith( + { _id: 'organization_id' }, + { apiServiceLevel: ApiServiceLevelEnum.BUSINESS } + ) + ).to.be.true; + }); + + it('should exit early with known organization and invalid apiServiceLevel', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: 'invalid', + }, + }, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + stripeStub.subscriptions.retrieve = () => + Promise.resolve({ + items: { + data: [ + { + id: 'subscription_id', + items: { data: [{ id: 'item_id_usage_notifications' }, { id: 'item_id_flat' }] }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: 'invalid' as any, + }, + }, + }, + }, + ], + }, + }); + + const handler = createHandler(); + await handler.handle(event); + + expect(organizationRepositoryStub.update.called).to.be.false; }); }); }); From 8c5991dcfb03a9361da40c7688674ca25ce38993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Mon, 15 Apr 2024 13:08:37 +0200 Subject: [PATCH 43/48] feat: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index dc7d549783b..bca162176d5 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit dc7d549783b0742ecadbb603419e1140962abf2f +Subproject commit bca162176d50b988e5a67658f18b92b4f2775185 From 5b0da563829a512902ec3c8b684a5ec92c36d653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Fri, 5 Apr 2024 23:18:13 +0200 Subject: [PATCH 44/48] Merge pull request #5371 from novuhq/nv-3634-fix-nc-scroll fix(notification-center,widget): infinite scroll issue --- apps/widget/cypress/e2e/notifications-list.spec.ts | 5 +++-- apps/widget/cypress/plugins/index.ts | 4 ++++ .../notification-center/components/NotificationsList.tsx | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/widget/cypress/e2e/notifications-list.spec.ts b/apps/widget/cypress/e2e/notifications-list.spec.ts index 37150e8e00a..e7a24666b19 100644 --- a/apps/widget/cypress/e2e/notifications-list.spec.ts +++ b/apps/widget/cypress/e2e/notifications-list.spec.ts @@ -171,11 +171,12 @@ describe('Notifications List', function () { cy.getByTestId('unseen-count-label').should('contain', '99+'); }); - it('pagination check', async function () { + it('pagination check', function () { cy.wait('@getNotificationsFirstPage'); cy.task('createNotifications', { organizationId: this.session.organization._id, enumerate: true, + ordered: true, identifier: this.session.templates[0].triggers[0].identifier, token: this.session.token, subscriberId: this.session.subscriber.subscriberId, @@ -206,5 +207,5 @@ describe('Notifications List', function () { }); function scrollToBottom() { - cy.getByTestId('notifications-scroll-area').get('.infinite-scroll-component').scrollTo('bottom'); + cy.getByTestId('notifications-scroll-area').scrollTo('bottom', { ensureScrollable: true }); } diff --git a/apps/widget/cypress/plugins/index.ts b/apps/widget/cypress/plugins/index.ts index eeb8297abbd..8ca66929785 100644 --- a/apps/widget/cypress/plugins/index.ts +++ b/apps/widget/cypress/plugins/index.ts @@ -33,6 +33,7 @@ module.exports = (on, config) => { count = 1, organizationId, enumerate = false, + ordered = false, }) { const triggerIdentifier = identifier; const service = new NotificationsService(token); @@ -44,6 +45,9 @@ module.exports = (on, config) => { await service.triggerEvent(triggerIdentifier, subscriberId, { firstName: `John${num}`, }); + if (ordered) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } } if (organizationId) { diff --git a/packages/notification-center/src/components/notification-center/components/NotificationsList.tsx b/packages/notification-center/src/components/notification-center/components/NotificationsList.tsx index c84389cb632..bf6b5c08fdd 100644 --- a/packages/notification-center/src/components/notification-center/components/NotificationsList.tsx +++ b/packages/notification-center/src/components/notification-center/components/NotificationsList.tsx @@ -47,4 +47,5 @@ export function NotificationsList({ const notificationsListCss = css` height: 400px; + overflow-y: auto; `; From 3486a868cf9006aabbf19039fb07406e51d10489 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Tue, 16 Apr 2024 12:24:33 +0300 Subject: [PATCH 45/48] fix(web): Remove strict lastname validation Customers can provide any single word as their full name. We don't benefit from being too strict about it during sign-up as the email address is what matters. --- .../src/pages/auth/components/SignUpForm.tsx | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/apps/web/src/pages/auth/components/SignUpForm.tsx b/apps/web/src/pages/auth/components/SignUpForm.tsx index 5ae9274d156..ca81b0e2b56 100644 --- a/apps/web/src/pages/auth/components/SignUpForm.tsx +++ b/apps/web/src/pages/auth/components/SignUpForm.tsx @@ -3,7 +3,6 @@ import { Link, useNavigate } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { Center } from '@mantine/core'; -import { showNotification } from '@mantine/notifications'; import { passwordConstraints, UTM_CAMPAIGN_QUERY_PARAM } from '@novu/shared'; import type { IResponseError } from '@novu/shared'; import { PasswordInput, Button, colors, Input, Text, Checkbox } from '@novu/design-system'; @@ -13,7 +12,6 @@ import { api } from '../../../api/api.client'; import { applyToken, useVercelParams } from '../../../hooks'; import { useAcceptInvite } from './useAcceptInvite'; import { PasswordRequirementPopover } from './PasswordRequirementPopover'; -import { buildGithubLink, buildVercelGithubLink } from './gitHubUtils'; import { ROUTES } from '../../../constants/routes.enum'; import { OAuth } from './OAuth'; @@ -36,9 +34,6 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) { const { isFromVercel, code, next, configurationId } = useVercelParams(); const vercelQueryParams = `code=${code}&next=${next}&configurationId=${configurationId}`; const loginLink = isFromVercel ? `/auth/login?${vercelQueryParams}` : ROUTES.AUTH_LOGIN; - const githubLink = isFromVercel - ? buildVercelGithubLink({ code, next, configurationId }) - : buildGithubLink({ invitationToken }); const { isLoading, mutateAsync, isError, error } = useMutation< { token: string }, @@ -52,21 +47,14 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) { >((data) => api.post('/v1/auth/register', data)); const onSubmit = async (data) => { + const [firstName, lastName] = data?.fullName.trim().split(' '); const itemData = { - firstName: data.fullName.split(' ')[0], - lastName: data.fullName.split(' ')[1], + firstName, + lastName, email: data.email, password: data.password, }; - if (!itemData.lastName) { - showNotification({ - message: 'Please write your full name including last name', - color: 'red', - }); - - return; - } const response = await mutateAsync(itemData); /** @@ -80,10 +68,9 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) { submitToken(token, invitationToken); return true; - } else { - setToken(token); } + setToken(token); navigate(isFromVercel ? `/auth/application?${vercelQueryParams}` : ROUTES.AUTH_APPLICATION); return true; From 9a6a136817774d7c04c2b150982bd41fbd5d9fb1 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:08:42 +0100 Subject: [PATCH 46/48] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index bca162176d5..f4c7ef42b87 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit bca162176d50b988e5a67658f18b92b4f2775185 +Subproject commit f4c7ef42b873d5c3a67d561f36258bb313414a91 From a12688a91ae6cb16be091dc2b1352dc1b84db1a5 Mon Sep 17 00:00:00 2001 From: Joel Anton Date: Tue, 16 Apr 2024 12:46:07 -0700 Subject: [PATCH 47/48] test: Add visibility for CORS processing --- apps/api/src/config/cors.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/api/src/config/cors.ts b/apps/api/src/config/cors.ts index 0ff6af7cea6..434bd422c11 100644 --- a/apps/api/src/config/cors.ts +++ b/apps/api/src/config/cors.ts @@ -1,4 +1,4 @@ -import { INestApplication, Request } from '@nestjs/common'; +import { INestApplication, Logger } from '@nestjs/common'; import { HttpRequestHeaderKeysEnum } from '../app/shared/framework/types'; export const corsOptionsDelegate: Parameters[0] = function (req: Request, callback) { @@ -25,7 +25,14 @@ export const corsOptionsDelegate: Parameters[0] process.env.NODE_ENV === 'dev' && host.includes(process.env.PR_PREVIEW_ROOT_URL); + Logger.verbose(`Should allow deploy preview? ${shouldDisableCorsForPreviewUrls ? 'Yes' : 'No'}.`, { + curEnv: process.env.NODE_ENV, + previewUrlRoot: process.env.PR_PREVIEW_ROOT_URL, + host, + }); + if (shouldDisableCorsForPreviewUrls) { + Logger.verbose(`Allowing deploy previews.`); corsOptions.origin.push('*'); } } From 0ac43702bb1870335b5b3d2b12eb0106ebb0be88 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Wed, 17 Apr 2024 12:05:20 +0100 Subject: [PATCH 48/48] fix(worker): Provide correct execution context to Echo endpoints (#5416) --- .../send-message/digest/digest.usecase.ts | 4 +++- .../send-message/send-message.usecase.ts | 17 +++++++------ .../usecases/add-job/add-delay-job.usecase.ts | 9 ++++++- .../src/usecases/add-job/add-job.usecase.ts | 24 ++++++++++++++----- .../add-job/merge-or-create-digest.usecase.ts | 8 +++++-- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/worker/src/app/workflow/usecases/send-message/digest/digest.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/digest/digest.usecase.ts index 342a055c0b3..28e1827ca9d 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/digest/digest.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/digest/digest.usecase.ts @@ -71,11 +71,13 @@ export class Digest extends SendMessageType { }) ); + const jobsToUpdate = [...nextJobs.map((job) => job._id), command.job._id]; + await this.jobRepository.update( { _environmentId: command.environmentId, _id: { - $in: nextJobs.map((job) => job._id), + $in: jobsToUpdate, }, }, { diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts index 5cfe288974a..febe515fcca 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts @@ -88,13 +88,16 @@ export class SendMessage { const stepType = command.step?.template?.type; - const chimeraResponse = await this.chimeraConnector.execute< - SendMessageCommand & { variables: IFilterVariables }, - ExecuteOutput | null - >({ - ...command, - variables: shouldRun.variables, - }); + let chimeraResponse: ExecuteOutput | null = null; + if (!['digest', 'delay'].includes(stepType as any)) { + chimeraResponse = await this.chimeraConnector.execute< + SendMessageCommand & { variables: IFilterVariables }, + ExecuteOutput | null + >({ + ...command, + variables: shouldRun.variables, + }); + } if (!command.payload?.$on_boarding_trigger) { const usedFilters = shouldRun?.conditions.reduce(ConditionsFilter.sumFilters, { diff --git a/packages/application-generic/src/usecases/add-job/add-delay-job.usecase.ts b/packages/application-generic/src/usecases/add-job/add-delay-job.usecase.ts index 8462de9586c..b6efe9d3e31 100644 --- a/packages/application-generic/src/usecases/add-job/add-delay-job.usecase.ts +++ b/packages/application-generic/src/usecases/add-job/add-delay-job.usecase.ts @@ -2,6 +2,7 @@ import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { JobRepository, JobStatusEnum } from '@novu/dal'; import { + DelayTypeEnum, ExecutionDetailsSourceEnum, ExecutionDetailsStatusEnum, StepTypeEnum, @@ -46,7 +47,13 @@ export class AddDelayJob { stepMetadata: data.step.metadata, payload: data.payload, overrides: data.overrides, - chimeraResponse: command.chimeraResponse?.outputs, + // TODO: Remove fallback after other delay types are implemented. + chimeraResponse: command.chimeraResponse?.outputs + ? { + type: DelayTypeEnum.REGULAR, + ...command.chimeraResponse?.outputs, + } + : undefined, }); await this.jobRepository.updateStatus( diff --git a/packages/application-generic/src/usecases/add-job/add-job.usecase.ts b/packages/application-generic/src/usecases/add-job/add-job.usecase.ts index 482bf453622..a433019f9dc 100644 --- a/packages/application-generic/src/usecases/add-job/add-job.usecase.ts +++ b/packages/application-generic/src/usecases/add-job/add-job.usecase.ts @@ -5,6 +5,7 @@ import { ExecutionDetailsStatusEnum, StepTypeEnum, DigestCreationResultEnum, + DigestTypeEnum, } from '@novu/shared'; import { AddDelayJob } from './add-delay-job.usecase'; @@ -35,6 +36,7 @@ import { IUseCaseInterfaceInline, requireInject, } from '../../utils/require-inject'; +import { IFilterVariables } from '../../utils/filter-processing-details'; export enum BackoffStrategiesEnum { WEBHOOK_FILTER_BACKOFF = 'webhookFilterBackoff', @@ -85,7 +87,7 @@ export class AddJob { ); let filtered = false; - + let filterVariables: IFilterVariables | undefined; if ( [StepTypeEnum.DELAY, StepTypeEnum.DIGEST].includes( job.type as StepTypeEnum @@ -102,6 +104,7 @@ export class AddJob { }) ); + filterVariables = shouldRun.variables; filtered = !shouldRun.passed; } @@ -109,9 +112,12 @@ export class AddJob { let digestCreationResult: DigestCreationResultEnum | undefined; if (job.type === StepTypeEnum.DIGEST) { const chimeraResponse = await this.chimeraConnector.execute< - AddJobCommand, + AddJobCommand & { variables: IFilterVariables }, ExecuteOutput - >(command); + >({ + ...command, + variables: filterVariables, + }); validateDigest(job); @@ -119,7 +125,10 @@ export class AddJob { stepMetadata: job.digest, payload: job.payload, overrides: job.overrides, - chimeraResponse: chimeraResponse?.outputs, + // TODO: Remove fallback after other digest types are implemented. + chimeraResponse: chimeraResponse + ? { type: DigestTypeEnum.REGULAR, ...chimeraResponse.outputs } + : undefined, }); Logger.debug(`Digest step amount is: ${digestAmount}`, LOG_CONTEXT); @@ -164,9 +173,12 @@ export class AddJob { if (job.type === StepTypeEnum.DELAY) { const chimeraResponse = await this.chimeraConnector.execute< - AddJobCommand, + AddJobCommand & { variables: IFilterVariables }, ExecuteOutput - >(command); + >({ + ...command, + variables: filterVariables, + }); command.chimeraResponse = chimeraResponse; delayAmount = await this.addDelayJob.execute(command); diff --git a/packages/application-generic/src/usecases/add-job/merge-or-create-digest.usecase.ts b/packages/application-generic/src/usecases/add-job/merge-or-create-digest.usecase.ts index a0fabba8b0c..9a8600c4775 100644 --- a/packages/application-generic/src/usecases/add-job/merge-or-create-digest.usecase.ts +++ b/packages/application-generic/src/usecases/add-job/merge-or-create-digest.usecase.ts @@ -50,7 +50,8 @@ export class MergeOrCreateDigest { ): Promise { const { job } = command; - const digestMeta = job.digest as IDigestBaseMetadata | undefined; + const digestMeta = + command.chimeraData ?? (job.digest as IDigestBaseMetadata | undefined); const digestKey = command.chimeraData?.digestKey ?? digestMeta?.digestKey; const digestValue = getNestedValue(job.payload, digestKey); @@ -73,7 +74,10 @@ export class MergeOrCreateDigest { case DigestCreationResultEnum.SKIPPED: return await this.processSkippedDigest(job, command.filtered); case DigestCreationResultEnum.CREATED: - return await this.processCreatedDigest(digestMeta, job); + return await this.processCreatedDigest( + digestMeta as IDigestBaseMetadata, + job + ); default: throw new ApiException('Something went wrong with digest creation'); }