diff --git a/.cspell.json b/.cspell.json index e3b2e8a7dec..1ecad3d3bcc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -8,6 +8,7 @@ "adresses", "africas", "africastalking", + "Aland", "alanturing", "alexjoverm", "alist", @@ -68,6 +69,7 @@ "buildscript", "buildx", "bullmq", + "Burkina", "bzrignore", "cacheable", "cafebabe", @@ -92,6 +94,7 @@ "Clicksend", "clonedeep", "clsx", + "cmdk", "cnamek", "cnames", "codecov", @@ -188,6 +191,7 @@ "exponentiate", "externaldb", "externalredis", + "Faso", "Fdfdf", "fieldname", "fieldtype", @@ -274,6 +278,7 @@ "kannel", "kebabcase", "keybase", + "Keymap", "keyrings", "keysize", "kitterma", @@ -287,6 +292,7 @@ "lastindex", "Lato", "Lentczner", + "lezer" "libarary", "libauth", "libspf", @@ -342,14 +348,15 @@ "mlen", "mobishastra", "Mobishastra", + "Mobishatra", "moby", "Modiin", "modlen", - "Mobishatra", "mongod", "mongosh", "monokai", "monorepository", + "motionone", "mpeltonen", "mpim", "MPIMs", @@ -521,6 +528,7 @@ "RETRYABLE", "revlookup", "revlookupall", + "Rica", "righthand", "rimraf", "ringcentral", @@ -567,6 +575,8 @@ "softfail", "softwareupdate", "sonarjs", + "sonner", + "Sonner", "sortlist", "sourcemaps", "spamassassin", @@ -603,6 +613,7 @@ "supernet", "supertest", "suported", + "Syncable", "tabnannied", "tailwindcss", "tanstack", @@ -685,16 +696,9 @@ "whatsappbusiness", "xcodebuild", "xkeysib", + "xyflow", "zulip", "zwnj", - "motionone", - "xyflow", - "Sonner", - "sonner", - "cmdk", - "Keymap", - "Syncable", - "lezer" ], "flagWords": [], "patterns": [ diff --git a/.github/workflows/dev-deploy-webhook.yml b/.github/workflows/dev-deploy-webhook.yml index ef0eb725c2b..3da907f9fd2 100644 --- a/.github/workflows/dev-deploy-webhook.yml +++ b/.github/workflows/dev-deploy-webhook.yml @@ -22,6 +22,7 @@ env: jobs: test_webhook: uses: ./.github/workflows/reusable-webhook-e2e.yml + secrets: inherit publish_docker_image_webhook: if: "!contains(github.event.head_commit.message, 'ci skip')" diff --git a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts index e92e1ccdc98..c70661f45e6 100644 --- a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts +++ b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts @@ -1427,6 +1427,8 @@ describe('Novu-Hosted Bridge Trigger', () => { it('should execute a Novu-managed workflow', async () => { const createWorkflowDto: CreateWorkflowDto = { + tags: [], + active: true, name: 'Test Workflow', description: 'Test Workflow', __source: WorkflowCreationSourceEnum.DASHBOARD, diff --git a/apps/api/src/app/subscribers/e2e/remove-subscriber.e2e.ts b/apps/api/src/app/subscribers/e2e/remove-subscriber.e2e.ts index 2cb01759938..ccd2095d05e 100644 --- a/apps/api/src/app/subscribers/e2e/remove-subscriber.e2e.ts +++ b/apps/api/src/app/subscribers/e2e/remove-subscriber.e2e.ts @@ -45,18 +45,8 @@ describe('Delete Subscriber - /subscribers/:subscriberId (DELETE)', function () }, }); - const isDeleted = !(await subscriberRepository.findBySubscriberId(session.environment._id, '123')); - - expect(isDeleted).to.equal(true); - - const deletedSubscriber = ( - await subscriberRepository.findDeleted({ - _environmentId: session.environment._id, - subscriberId: '123', - }) - )?.[0]; - - expect(deletedSubscriber.deleted).to.equal(true); + const subscriber = await subscriberRepository.findBySubscriberId(session.environment._id, '123'); + expect(subscriber).to.be.null; }); it('should dispose subscriber relations to topic once he was removed', async () => { diff --git a/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.spec.ts b/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.spec.ts index 47413afc9f8..b8f8985bdb9 100644 --- a/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.spec.ts +++ b/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.spec.ts @@ -1,11 +1,9 @@ -import { Test } from '@nestjs/testing'; -import { SubscribersService, UserSession } from '@novu/testing'; -import { NotFoundException } from '@nestjs/common'; import { expect } from 'chai'; - +import { NotFoundException } from '@nestjs/common'; +import { SubscribersService, UserSession } from '@novu/testing'; +import { Test } from '@nestjs/testing'; import { RemoveSubscriber } from './remove-subscriber.usecase'; import { RemoveSubscriberCommand } from './remove-subscriber.command'; - import { SharedModule } from '../../../shared/shared.module'; import { SubscribersModule } from '../../subscribers.module'; @@ -41,8 +39,6 @@ describe('Remove Subscriber', function () { }); it('should throw a not found exception if subscriber to remove does not exist', async () => { - const subscriberService = new SubscribersService(session.organization._id, session.environment._id); - try { await useCase.execute( RemoveSubscriberCommand.create({ @@ -51,10 +47,9 @@ describe('Remove Subscriber', function () { organizationId: session.organization._id, }) ); - throw new Error('Should not reach here'); + expect(true, 'Should never reach this statement').to.be.false; } catch (e) { expect(e).to.be.instanceOf(NotFoundException); - expect(e.message).to.eql("Subscriber 'invalid-subscriber-id' was not found"); } }); }); diff --git a/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.usecase.ts b/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.usecase.ts index 426fd1ec752..c570bdcd191 100644 --- a/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.usecase.ts @@ -1,53 +1,88 @@ -import { Injectable } from '@nestjs/common'; -import { SubscriberRepository, DalException, TopicSubscribersRepository } from '@novu/dal'; -import { buildSubscriberKey, InvalidateCacheService } from '@novu/application-generic'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + SubscriberRepository, + TopicSubscribersRepository, + SubscriberPreferenceRepository, + PreferencesRepository, +} from '@novu/dal'; +import { + buildSubscriberKey, + buildFeedKey, + buildMessageCountKey, + InvalidateCacheService, +} from '@novu/application-generic'; import { RemoveSubscriberCommand } from './remove-subscriber.command'; -import { GetSubscriber } from '../get-subscriber'; -import { ApiException } from '../../../shared/exceptions/api.exception'; @Injectable() export class RemoveSubscriber { constructor( private invalidateCache: InvalidateCacheService, private subscriberRepository: SubscriberRepository, - private getSubscriber: GetSubscriber, - private topicSubscribersRepository: TopicSubscribersRepository + private topicSubscribersRepository: TopicSubscribersRepository, + private subscriberPreferenceRepository: SubscriberPreferenceRepository, + private preferenceRepository: PreferencesRepository ) {} - async execute(command: RemoveSubscriberCommand) { - try { - const { environmentId: _environmentId, organizationId, subscriberId } = command; - const subscriber = await this.getSubscriber.execute({ - environmentId: _environmentId, - organizationId, - subscriberId, - }); - - await this.invalidateCache.invalidateByKey({ + async execute({ environmentId: _environmentId, subscriberId }: RemoveSubscriberCommand) { + await Promise.all([ + this.invalidateCache.invalidateByKey({ key: buildSubscriberKey({ - subscriberId: command.subscriberId, - _environmentId: command.environmentId, + subscriberId, + _environmentId, }), - }); + }), + this.invalidateCache.invalidateQuery({ + key: buildFeedKey().invalidate({ + subscriberId, + _environmentId, + }), + }), + this.invalidateCache.invalidateQuery({ + key: buildMessageCountKey().invalidate({ + subscriberId, + _environmentId, + }), + }), + ]); + + const subscriberInternalIds = await this.subscriberRepository._model.distinct('_id', { + subscriberId, + _environmentId, + }); + if (subscriberInternalIds.length === 0) { + throw new NotFoundException({ message: 'Subscriber was not found', externalSubscriberId: subscriberId }); + } + + await this.subscriberRepository.withTransaction(async () => { + /* + * Note about parallelism in transactions + * + * Running operations in parallel is not supported during a transaction. + * The use of Promise.all, Promise.allSettled, Promise.race, etc. to parallelize operations + * inside a transaction is undefined behaviour and should be avoided. + * + * Refer to https://mongoosejs.com/docs/transactions.html#note-about-parallelism-in-transactions + */ await this.subscriberRepository.delete({ - _environmentId: subscriber._environmentId, - _organizationId: subscriber._organizationId, - subscriberId: subscriber.subscriberId, + subscriberId, + _environmentId, }); await this.topicSubscribersRepository.delete({ - _environmentId: subscriber._environmentId, - _organizationId: subscriber._organizationId, - externalSubscriberId: subscriber.subscriberId, + _environmentId, + externalSubscriberId: subscriberId, }); - } catch (e) { - if (e instanceof DalException) { - throw new ApiException(e.message); - } - throw e; - } + await this.subscriberPreferenceRepository.delete({ + _environmentId, + _subscriberId: { $in: subscriberInternalIds }, + }); + await this.preferenceRepository.delete({ + _environmentId, + _subscriberId: { $in: subscriberInternalIds }, + }); + }); return { acknowledged: true, diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts index 04dec02caa5..d9869453010 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -237,6 +237,7 @@ describe('Generate Preview', () => { } async function createWorkflowWithDigest() { const createWorkflowDto: CreateWorkflowDto = { + tags: [], __source: WorkflowCreationSourceEnum.EDITOR, name: 'John', workflowId: `john:${randomUUID()}`, diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts index 96392730e7f..0c27b50258b 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -161,10 +161,7 @@ export class UpsertWorkflowUseCase { ); } - private async upsertUserWorkflowPreferences( - workflow: NotificationTemplateEntity, - command: UpsertWorkflowCommand - ): Promise { + private async upsertUserWorkflowPreferences(workflow: NotificationTemplateEntity, command: UpsertWorkflowCommand) { let preferences: WorkflowPreferences | null; if (command.workflowDto.preferences?.user !== undefined) { preferences = command.workflowDto.preferences.user; @@ -172,7 +169,7 @@ export class UpsertWorkflowUseCase { preferences = DEFAULT_WORKFLOW_PREFERENCES; } - return await this.upsertPreferencesUsecase.upsertUserWorkflowPreferences( + await this.upsertPreferencesUsecase.upsertUserWorkflowPreferences( UpsertUserWorkflowPreferencesCommand.create({ environmentId: workflow._environmentId, organizationId: workflow._organizationId, @@ -183,11 +180,8 @@ export class UpsertWorkflowUseCase { ); } - private async upsertWorkflowPreferences( - workflow: NotificationTemplateEntity, - command: UpsertWorkflowCommand - ): Promise { - return await this.upsertPreferencesUsecase.upsertWorkflowPreferences( + private async upsertWorkflowPreferences(workflow: NotificationTemplateEntity, command: UpsertWorkflowCommand) { + await this.upsertPreferencesUsecase.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ environmentId: workflow._environmentId, organizationId: workflow._organizationId, diff --git a/apps/web/src/components/workflow/preview/in-app/v2/InboxPreviewContent.tsx b/apps/web/src/components/workflow/preview/in-app/v2/InboxPreviewContent.tsx index 87084311a6e..bbcc5ecc066 100644 --- a/apps/web/src/components/workflow/preview/in-app/v2/InboxPreviewContent.tsx +++ b/apps/web/src/components/workflow/preview/in-app/v2/InboxPreviewContent.tsx @@ -12,9 +12,9 @@ export const INBOX_TOKENS = { 'semantic/color/neutral/80': '#3D3D4D', 'semantic/color/neutral/90': '#292933', 'semantic/margins/buttons/S/S': '1rem', - 'Inbox/whiteLable/buttons/accent/normal': '#369EFF', - 'Inbox/whiteLable/secondaryButton': '#2E2E32', - 'Inbox/whiteLable/devider': '#2E2E32', + 'Inbox/whiteLabel/buttons/accent/normal': '#369EFF', + 'Inbox/whiteLabel/secondaryButton': '#2E2E32', + 'Inbox/whiteLabel/divider': '#2E2E32', 'Inbox/paddings/header/vertical': '1.25rem', 'Inbox/paddings/header/horizontal': '1.5rem', 'Inbox/paddings/message/horizontal': '1.5rem', @@ -91,7 +91,7 @@ export function InboxPreviewContent({ { + ) { return await this.preferencesRepository.delete({ _id: preferencesId, _environmentId: command.environmentId, diff --git a/libs/dal/src/repositories/base-repository.ts b/libs/dal/src/repositories/base-repository.ts index 37b27476dc2..76f734f55c2 100644 --- a/libs/dal/src/repositories/base-repository.ts +++ b/libs/dal/src/repositories/base-repository.ts @@ -6,7 +6,16 @@ import { DEFAULT_MESSAGE_IN_APP_RETENTION_DAYS, DEFAULT_NOTIFICATION_RETENTION_DAYS, } from '@novu/shared'; -import { FilterQuery, Model, ProjectionType, QueryOptions, QueryWithHelpers, Types, UpdateQuery } from 'mongoose'; +import { + ClientSession, + FilterQuery, + Model, + ProjectionType, + QueryOptions, + QueryWithHelpers, + Types, + UpdateQuery, +} from 'mongoose'; import { DalException } from '../shared'; export class BaseRepository { @@ -338,6 +347,10 @@ export class BaseRepository { protected mapEntities(data: any): T_MappedEntity[] { return plainToInstance(this.entity, JSON.parse(JSON.stringify(data))); } + + async withTransaction(fn: Parameters[0]) { + return (await this._model.db.startSession()).withTransaction(fn); + } } interface IOptions { diff --git a/libs/dal/src/repositories/preferences/preferences.repository.ts b/libs/dal/src/repositories/preferences/preferences.repository.ts index 96aa65f1ff5..e95fb5cfd8c 100644 --- a/libs/dal/src/repositories/preferences/preferences.repository.ts +++ b/libs/dal/src/repositories/preferences/preferences.repository.ts @@ -28,13 +28,6 @@ export class PreferencesRepository extends BaseRepository { const res: PreferencesEntity = await this.preferences.findDeleted(query); diff --git a/libs/dal/src/repositories/subscriber/subscriber.repository.ts b/libs/dal/src/repositories/subscriber/subscriber.repository.ts index 88db39aeaaa..bc60514b922 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.repository.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.repository.ts @@ -157,55 +157,19 @@ export class SubscriberRepository extends BaseRepository { return this.subscriber.estimatedDocumentCount(); } } + function mapToSubscriberObject(subscriberId: string) { return { subscriberId }; } + function regExpEscape(literalString: string): string { return literalString.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, '\\$&'); } + function isErrorWithWriteErrors(e: unknown): e is { writeErrors?: any; message?: string; result?: any } { return typeof e === 'object' && e !== null && 'writeErrors' in e; } diff --git a/packages/shared/src/dto/workflows/workflow-commons-fields.ts b/packages/shared/src/dto/workflows/workflow-commons-fields.ts index 3f30ae77bbc..9f410eaa94d 100644 --- a/packages/shared/src/dto/workflows/workflow-commons-fields.ts +++ b/packages/shared/src/dto/workflows/workflow-commons-fields.ts @@ -59,10 +59,10 @@ export type StepDto = { }; export type WorkflowCommonsFields = { - tags: string[]; - active?: boolean; name: string; description?: string; + tags?: string[]; + active?: boolean; }; export type PreferencesResponseDto = {