diff --git a/apps/api/src/app/shared/dtos/message-template.ts b/apps/api/src/app/shared/dtos/message-template.ts index 20141527ae7..bd32aaf2496 100644 --- a/apps/api/src/app/shared/dtos/message-template.ts +++ b/apps/api/src/app/shared/dtos/message-template.ts @@ -60,5 +60,5 @@ export class MessageTemplate { }; @IsOptional() - _creatorId: string; + _creatorId?: string; } diff --git a/apps/api/src/app/shared/dtos/notification-step.ts b/apps/api/src/app/shared/dtos/notification-step.ts index 379980ddbca..c789cc3f51d 100644 --- a/apps/api/src/app/shared/dtos/notification-step.ts +++ b/apps/api/src/app/shared/dtos/notification-step.ts @@ -14,6 +14,7 @@ import { MonthlyTypeEnum, OrdinalEnum, OrdinalValueEnum, + StepVariantDto, } from '@novu/shared'; import { IsBoolean, ValidateNested } from 'class-validator'; @@ -104,7 +105,7 @@ class DelayScheduledMetadata implements IDelayScheduledMetadata { } @ApiExtraModels(DigestRegularMetadata, DigestTimedMetadata, DelayRegularMetadata, DelayScheduledMetadata) -export class NotificationStep { +export class NotificationStepVariant implements StepVariantDto { @ApiPropertyOptional() _id?: string; @@ -156,3 +157,12 @@ export class NotificationStep { url: string; }; } + +@ApiExtraModels(DigestRegularMetadata, DigestTimedMetadata, DelayRegularMetadata, DelayScheduledMetadata) +export class NotificationStep extends NotificationStepVariant { + @ApiPropertyOptional({ + type: NotificationStepVariant, + }) + @ValidateNested() + variants?: NotificationStepVariant[]; +} diff --git a/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts b/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts index 0f404d73e90..874e6668e82 100644 --- a/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts +++ b/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts @@ -62,7 +62,7 @@ describe('Create Workflow - /workflows (POST)', async () => { it('should create email template', async function () { const defaultMessageIsActive = true; - const testTemplate: Partial = { + const templateRequestPayload: Partial = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], @@ -91,36 +91,57 @@ describe('Create Workflow - /workflows (POST)', async () => { ], }, ], + variants: [ + { + template: { + name: 'Better Message Template', + subject: 'Better subject', + preheader: 'Better pre header', + content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample of Better text block' }], + type: StepTypeEnum.EMAIL, + }, + active: defaultMessageIsActive, + }, + ], }, ], }; - const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); + const { body } = await session.testAgent.post(`/v1/workflows`).send(templateRequestPayload); expect(body.data).to.be.ok; - const template: INotificationTemplate = body.data; + const templateRequestResult: INotificationTemplate = body.data; - expect(template._notificationGroupId).to.equal(testTemplate.notificationGroupId); - const message = template.steps[0]; - const filters = message?.filters ? message?.filters[0] : null; + expect(templateRequestResult._notificationGroupId).to.equal(templateRequestPayload.notificationGroupId); + const message = templateRequestResult.steps[0]; - const messageTest = testTemplate?.steps ? testTemplate?.steps[0] : null; - const filtersTest = messageTest?.filters ? messageTest.filters[0] : null; + const messageRequest = templateRequestPayload?.steps ? templateRequestPayload?.steps[0] : null; + const filtersTest = messageRequest?.filters ? messageRequest.filters[0] : null; const children: IFieldFilterPart = filtersTest?.children[0] as IFieldFilterPart; - expect(message?.template?.name).to.equal(`${messageTest?.template?.name}`); + expect(message?.template?.name).to.equal(`${messageRequest?.template?.name}`); expect(message?.template?.active).to.equal(defaultMessageIsActive); - expect(message?.template?.subject).to.equal(`${messageTest?.template?.subject}`); - expect(message?.template?.preheader).to.equal(`${messageTest?.template?.preheader}`); + expect(message?.template?.subject).to.equal(`${messageRequest?.template?.subject}`); + expect(message?.template?.preheader).to.equal(`${messageRequest?.template?.preheader}`); + + const filters = message?.filters ? message?.filters[0] : null; expect(filters?.type).to.equal(filtersTest?.type); expect(filters?.children.length).to.equal(filtersTest?.children?.length); + expect(children.value).to.equal(children.value); expect(children.operator).to.equal(children.operator); - expect(template.tags[0]).to.equal('test-tag'); + expect(templateRequestResult.tags[0]).to.equal('test-tag'); + + const variantRequest = messageRequest?.variants ? messageRequest?.variants[0] : null; + const variantResult = templateRequestResult.steps[0]?.variants ? templateRequestResult.steps[0]?.variants[0] : null; + expect(variantResult?.template?.name).to.equal(variantRequest?.template?.name); + expect(variantResult?.template?.active).to.equal(variantRequest?.active); + expect(variantResult?.template?.subject).to.equal(variantRequest?.template?.subject); + expect(variantResult?.template?.preheader).to.equal(variantRequest?.template?.preheader); - if (Array.isArray(message?.template?.content) && Array.isArray(messageTest?.template?.content)) { - expect(message?.template?.content[0].type).to.equal(messageTest?.template?.content[0].type); + if (Array.isArray(message?.template?.content) && Array.isArray(messageRequest?.template?.content)) { + expect(message?.template?.content[0].type).to.equal(messageRequest?.template?.content[0].type); } else { throw new Error('content must be an array'); } @@ -131,7 +152,10 @@ describe('Create Workflow - /workflows (POST)', async () => { }); await session.testAgent.post(`/v1/changes/${change?._id}/apply`); - change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: template._id }); + change = await changeRepository.findOne({ + _environmentId: session.environment._id, + _entityId: templateRequestResult._id, + }); await session.testAgent.post(`/v1/changes/${change?._id}/apply`); const prodEnv = await getProductionEnvironment(); @@ -140,17 +164,17 @@ describe('Create Workflow - /workflows (POST)', async () => { const prodVersionNotification = await notificationTemplateRepository.findOne({ _environmentId: prodEnv._id, - _parentId: template._id, + _parentId: templateRequestResult._id, }); - expect(prodVersionNotification?.tags[0]).to.equal(template.tags[0]); - expect(prodVersionNotification?.steps.length).to.equal(template.steps.length); - expect(prodVersionNotification?.triggers[0].type).to.equal(template.triggers[0].type); - expect(prodVersionNotification?.triggers[0].identifier).to.equal(template.triggers[0].identifier); - expect(prodVersionNotification?.active).to.equal(template.active); - expect(prodVersionNotification?.draft).to.equal(template.draft); - expect(prodVersionNotification?.name).to.equal(template.name); - expect(prodVersionNotification?.description).to.equal(template.description); + expect(prodVersionNotification?.tags[0]).to.equal(templateRequestResult.tags[0]); + expect(prodVersionNotification?.steps.length).to.equal(templateRequestResult.steps.length); + expect(prodVersionNotification?.triggers[0].type).to.equal(templateRequestResult.triggers[0].type); + expect(prodVersionNotification?.triggers[0].identifier).to.equal(templateRequestResult.triggers[0].identifier); + expect(prodVersionNotification?.active).to.equal(templateRequestResult.active); + expect(prodVersionNotification?.draft).to.equal(templateRequestResult.draft); + expect(prodVersionNotification?.name).to.equal(templateRequestResult.name); + expect(prodVersionNotification?.description).to.equal(templateRequestResult.description); const prodVersionMessage = await messageTemplateRepository.findOne({ _environmentId: prodEnv._id, @@ -162,6 +186,17 @@ describe('Create Workflow - /workflows (POST)', async () => { expect(message?.template?.type).to.equal(prodVersionMessage?.type); expect(message?.template?.content).to.deep.equal(prodVersionMessage?.content); expect(message?.template?.active).to.equal(prodVersionMessage?.active); + + const prodVersionVariant = await messageTemplateRepository.findOne({ + _environmentId: prodEnv._id, + _parentId: variantResult._templateId, + }); + + expect(variantResult?.template?.name).to.equal(prodVersionVariant?.name); + expect(variantResult?.template?.subject).to.equal(prodVersionVariant?.subject); + expect(variantResult?.template?.type).to.equal(prodVersionVariant?.type); + expect(variantResult?.template?.content).to.deep.equal(prodVersionVariant?.content); + expect(variantResult?.template?.active).to.equal(prodVersionVariant?.active); }); it('should create a valid notification', async () => { diff --git a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts index 507d220c15a..33f41c2bf74 100644 --- a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts +++ b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.command.ts @@ -87,7 +87,7 @@ export class ChannelCTACommand { action?: IMessageAction[]; } -export class NotificationStep { +export class NotificationStepVariant { @IsString() @IsOptional() _templateId?: string; @@ -128,6 +128,13 @@ export class NotificationStep { metadata?: IWorkflowStepMetadata; } +export class NotificationStep extends NotificationStepVariant { + @IsOptional() + @IsArray() + @ValidateNested() + variants?: NotificationStepVariant[]; +} + export class MessageFilter { isNegated: boolean; diff --git a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts index 9a3ac6e45e3..0a03447c467 100644 --- a/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts +++ b/apps/api/src/app/workflows/usecases/create-notification-template/create-notification-template.usecase.ts @@ -9,10 +9,16 @@ import { FeedRepository, NotificationGroupRepository, } from '@novu/dal'; -import { ChangeEntityTypeEnum, INotificationTemplateStep, INotificationTrigger, TriggerTypeEnum } from '@novu/shared'; +import { + ChangeEntityTypeEnum, + INotificationTemplateStep, + INotificationTrigger, + IStepVariant, + TriggerTypeEnum, +} from '@novu/shared'; import { AnalyticsService } from '@novu/application-generic'; -import { CreateNotificationTemplateCommand } from './create-notification-template.command'; +import { CreateNotificationTemplateCommand, NotificationStep } from './create-notification-template.command'; import { ContentService } from '../../../shared/helpers/content.service'; import { CreateMessageTemplate, CreateMessageTemplateCommand } from '../../../message-template/usecases'; import { CreateChange, CreateChangeCommand } from '../../../change/usecases'; @@ -37,15 +43,30 @@ export class CreateNotificationTemplate { async execute(usecaseCommand: CreateNotificationTemplateCommand) { const blueprintCommand = await this.processBlueprint(usecaseCommand); const command = blueprintCommand ?? usecaseCommand; - - const contentService = new ContentService(); - const { variables, reservedVariables } = contentService.extractMessageVariables(command.steps); - const subscriberVariables = contentService.extractSubscriberMessageVariables(command.steps); - const triggerIdentifier = `${slugify(command.name, { lower: true, strict: true, })}`; + const parentChangeId: string = NotificationTemplateRepository.createObjectId(); + + const templateSteps = await this.storeTemplateSteps(command, parentChangeId); + + const trigger = await this.createNotificationTrigger(command, triggerIdentifier); + + const storedWorkflow = await this.storeWorkflow(command, templateSteps, trigger, triggerIdentifier); + + await this.createWorkflowChange(command, storedWorkflow, parentChangeId); + + return storedWorkflow; + } + + private async createNotificationTrigger( + command: CreateNotificationTemplateCommand, + triggerIdentifier: string + ): Promise { + const contentService = new ContentService(); + const { variables, reservedVariables } = contentService.extractMessageVariables(command.steps); + const subscriberVariables = contentService.extractSubscriberMessageVariables(command.steps); const templateCheckIdentifier = await this.notificationTemplateRepository.findByTriggerIdentifier( command.environmentId, @@ -79,23 +100,88 @@ export class CreateNotificationTemplate { }), }; - const parentChangeId: string = NotificationTemplateRepository.createObjectId(); - const templateSteps: INotificationTemplateStep[] = []; + return trigger; + } + + private sendTemplateCreationEvent(command: CreateNotificationTemplateCommand, triggerIdentifier: string) { + if (command.name !== 'On-boarding notification' && !command.__source?.startsWith('onboarding_')) { + this.analyticsService.track('Create Notification Template - [Platform]', command.userId, { + _organization: command.organizationId, + steps: command.steps?.length, + channels: command.steps?.map((i) => i.template?.type), + __source: command.__source, + triggerIdentifier, + }); + } + } + + private async createWorkflowChange(command: CreateNotificationTemplateCommand, item, parentChangeId: string) { + await this.createChange.execute( + CreateChangeCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + userId: command.userId, + type: ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE, + item, + changeId: parentChangeId, + }) + ); + } + + private async storeWorkflow( + command: CreateNotificationTemplateCommand, + templateSteps: INotificationTemplateStep[], + trigger: INotificationTrigger, + triggerIdentifier: string + ) { + const tmp = { + _organizationId: command.organizationId, + _creatorId: command.userId, + _environmentId: command.environmentId, + name: command.name, + active: command.active, + draft: command.draft, + critical: command.critical, + preferenceSettings: command.preferenceSettings, + tags: command.tags, + description: command.description, + steps: templateSteps, + triggers: [trigger], + _notificationGroupId: command.notificationGroupId, + blueprintId: command.blueprintId, + ...(command.data ? { data: command.data } : {}), + }; + + const savedTemplate = await this.notificationTemplateRepository.create(tmp); + + const item = await this.notificationTemplateRepository.findById(savedTemplate._id, command.environmentId); + if (!item) throw new NotFoundException(`Notification template ${savedTemplate._id} is not found`); + + this.sendTemplateCreationEvent(command, triggerIdentifier); + + return item; + } + + private async storeTemplateSteps( + command: CreateNotificationTemplateCommand, + parentChangeId: string + ): Promise { let parentStepId: string | null = null; + const templateSteps: INotificationTemplateStep[] = []; for (const message of command.steps) { if (!message.template) throw new ApiException(`Unexpected error: message template is missing`); const template = await this.createMessageTemplate.execute( CreateMessageTemplateCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + userId: command.userId, type: message.template.type, name: message.template.name, content: message.template.content, variables: message.template.variables, contentType: message.template.contentType, - organizationId: command.organizationId, - environmentId: command.environmentId, - userId: command.userId, cta: message.template.cta, subject: message.template.subject, title: message.template.title, @@ -103,73 +189,96 @@ export class CreateNotificationTemplate { layoutId: message.template.layoutId, preheader: message.template.preheader, senderName: message.template.senderName, - parentChangeId, actor: message.template.actor, + parentChangeId, }) ); + const storedVariants = await this.storeVariantSteps(message, command, parentChangeId, parentStepId); + const stepId = template._id; - templateSteps.push({ + const templateStep: Partial = { _id: stepId, _templateId: template._id, filters: message.filters, _parentId: parentStepId, - metadata: message.metadata, active: message.active, shouldStopOnFail: message.shouldStopOnFail, replyCallback: message.replyCallback, uuid: message.uuid, name: message.name, - }); + metadata: message.metadata, + }; + + if (storedVariants.length) { + templateStep.variants = storedVariants; + } + + templateSteps.push(templateStep); if (stepId) { parentStepId = stepId; } } - const savedTemplate = await this.notificationTemplateRepository.create({ - _organizationId: command.organizationId, - _creatorId: command.userId, - _environmentId: command.environmentId, - name: command.name, - active: command.active, - draft: command.draft, - critical: command.critical, - preferenceSettings: command.preferenceSettings, - tags: command.tags, - description: command.description, - steps: templateSteps, - triggers: [trigger], - _notificationGroupId: command.notificationGroupId, - blueprintId: command.blueprintId, - ...(command.data ? { data: command.data } : {}), - }); + return templateSteps; + } - const item = await this.notificationTemplateRepository.findById(savedTemplate._id, command.environmentId); - if (!item) throw new NotFoundException(`Notification template ${savedTemplate._id} is not found`); + private async storeVariantSteps( + message: NotificationStep, + command: CreateNotificationTemplateCommand, + parentChangeId: string, + parentStepId: string | null + ): Promise { + if (!message.variants?.length) return []; - await this.createChange.execute( - CreateChangeCommand.create({ - organizationId: command.organizationId, - environmentId: command.environmentId, - userId: command.userId, - type: ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE, - item, - changeId: parentChangeId, - }) - ); + const variantsList: IStepVariant[] = []; + let parentVariantId: string | null = null; - if (command.name !== 'On-boarding notification' && !command.__source?.startsWith('onboarding_')) { - this.analyticsService.track('Create Notification Template - [Platform]', command.userId, { - _organization: command.organizationId, - steps: command.steps?.length, - channels: command.steps?.map((i) => i.template?.type), - __source: command.__source, - triggerIdentifier, + for (const variant of message.variants) { + if (!variant.template) throw new ApiException(`Unexpected error: variants message template is missing`); + + const variantTemplate = await this.createMessageTemplate.execute( + CreateMessageTemplateCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + userId: command.userId, + type: variant.template.type, + name: variant.template.name, + content: variant.template.content, + variables: variant.template.variables, + contentType: variant.template.contentType, + cta: variant.template.cta, + subject: variant.template.subject, + title: variant.template.title, + feedId: variant.template.feedId, + layoutId: variant.template.layoutId, + preheader: variant.template.preheader, + senderName: variant.template.senderName, + actor: variant.template.actor, + parentChangeId, + }) + ); + + variantsList.push({ + _id: variantTemplate._id, + _templateId: variantTemplate._id, + filters: variant.filters, + _parentId: parentStepId, + active: variant.active, + shouldStopOnFail: variant.shouldStopOnFail, + replyCallback: variant.replyCallback, + uuid: variant.uuid, + name: variant.name, + metadata: variant.metadata, }); + + if (variantTemplate._id) { + parentVariantId = variantTemplate._id; + } } - return item; + return variantsList; } private async processBlueprint(command: CreateNotificationTemplateCommand) { diff --git a/apps/web/src/pages/templates/components/templateToFormMappers.ts b/apps/web/src/pages/templates/components/templateToFormMappers.ts index d57b1ea64bd..401763f19f0 100644 --- a/apps/web/src/pages/templates/components/templateToFormMappers.ts +++ b/apps/web/src/pages/templates/components/templateToFormMappers.ts @@ -208,7 +208,7 @@ export const mapNotificationTemplateToForm = (template: INotificationTemplate): export const mapFormToCreateNotificationTemplate = (form: IForm): ICreateNotificationTemplateDto => { const steps = form.steps.map((formStep: IFormStep) => { const { digestMetadata, delayMetadata, template, ...rest } = formStep; - const step: NotificationStepDto = { ...rest }; + const step: Omit = { ...rest }; if (template.type === StepTypeEnum.EMAIL && template.contentType === 'customHtml') { template.content = template.htmlContent as string; diff --git a/libs/dal/src/repositories/notification-template/notification-template.repository.ts b/libs/dal/src/repositories/notification-template/notification-template.repository.ts index 09c94f8a09d..edb1115eee8 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.repository.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.repository.ts @@ -40,7 +40,9 @@ export class NotificationTemplateRepository extends BaseRepository< _environmentId: environmentId, }; - const item = await this.MongooseModel.findOne(requestQuery).populate('steps.template'); + const item = await this.MongooseModel.findOne(requestQuery) + .populate('steps.template') + .populate('steps.variants.template'); return this.mapEntity(item); } diff --git a/libs/dal/src/repositories/notification-template/notification-template.schema.ts b/libs/dal/src/repositories/notification-template/notification-template.schema.ts index 6b09f6630bc..d4b535d54e6 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.schema.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.schema.ts @@ -301,9 +301,19 @@ notificationTemplateSchema.virtual('steps.template', { justOne: true, }); +notificationTemplateSchema.virtual('steps.variants.template', { + ref: 'MessageTemplate', + localField: 'steps.variants._templateId', + foreignField: '_id', + justOne: true, +}); + notificationTemplateSchema.path('steps').schema.set('toJSON', { virtuals: true }); notificationTemplateSchema.path('steps').schema.set('toObject', { virtuals: true }); +notificationTemplateSchema.path('steps.variants').schema.set('toJSON', { virtuals: true }); +notificationTemplateSchema.path('steps.variants').schema.set('toObject', { virtuals: true }); + notificationTemplateSchema.virtual('notificationGroup', { ref: 'NotificationGroup', localField: '_notificationGroupId', diff --git a/libs/shared/src/dto/workflows/workflow.dto.ts b/libs/shared/src/dto/workflows/workflow.dto.ts index 0a4cf230404..293cb245279 100644 --- a/libs/shared/src/dto/workflows/workflow.dto.ts +++ b/libs/shared/src/dto/workflows/workflow.dto.ts @@ -2,7 +2,7 @@ import { IWorkflowStepMetadata } from '../../entities/step'; import { BuilderFieldType, BuilderGroupValues, FilterParts } from '../../types'; import { MessageTemplateDto } from '../message-template'; -export class NotificationStepDto { +export class StepVariantDto { id?: string; _id?: string; name?: string; @@ -23,3 +23,7 @@ export class NotificationStepDto { }; metadata?: IWorkflowStepMetadata; } + +export class NotificationStepDto extends StepVariantDto { + variants?: StepVariantDto[]; +} diff --git a/libs/shared/src/entities/notification-template/notification-template.interface.ts b/libs/shared/src/entities/notification-template/notification-template.interface.ts index 9777de28233..aee59cd5d4d 100644 --- a/libs/shared/src/entities/notification-template/notification-template.interface.ts +++ b/libs/shared/src/entities/notification-template/notification-template.interface.ts @@ -11,7 +11,7 @@ export interface INotificationTemplate { _parentId?: string; _environmentId: string; tags: string[]; - draft: boolean; + draft?: boolean; active: boolean; critical: boolean; preferenceSettings: IPreferenceChannels;