diff --git a/.source b/.source index 1eaf99c1369..dfa9f193b6a 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 1eaf99c1369b3d2fe6eeaa062462ff6027435992 +Subproject commit dfa9f193b6a312a84cc562f68080a526d401bc21 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 14df9002691..49873547f75 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -37,11 +37,26 @@ "endsPattern": "Started application in NODE_ENV" } }, + }, + { + "type": "npm", + "script": "start:test", + "isBackground": true, + "label": "WORKER TEST", + "path": "/apps/worker", + "problemMatcher": { + "base": "$tsc-watch", + "owner": "typescript", + "background": { + "activeOnStart": true, + "beginsPattern": "Successfully compiled", + "endsPattern": "Started application in NODE_ENV" + } + }, "icon": { "id": "server", "color": "terminal.ansiGreen" - }, - "dependsOn": ["SHARED", "API", "APPLICATION GENERIC", "DAL", "EE - TRANSLATION", "EE - BILLING"] + } }, { "type": "npm", diff --git a/apps/api/package.json b/apps/api/package.json index 97aaf593c05..11933cc3b73 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -74,6 +74,7 @@ "helmet": "^6.0.1", "i18next": "^23.7.6", "ioredis": "5.3.2", + "json-logic-js": "^2.0.5", "json-schema-to-ts": "^3.0.0", "jsonwebtoken": "9.0.0", "liquidjs": "^10.14.0", @@ -119,6 +120,7 @@ "@types/passport-jwt": "^3.0.3", "@types/sinon": "^9.0.0", "@types/supertest": "^2.0.8", + "@types/json-logic-js": "^2.0.8", "async": "^3.2.0", "chai": "^4.2.0", "mocha": "^10.2.0", diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts index 59b53303a4b..dcf3f1e3eec 100644 --- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts @@ -3,7 +3,9 @@ import { workflow } from '@novu/framework/express'; import { ActionStep, ChannelStep, JsonSchema, Step, StepOptions, StepOutput, Workflow } from '@novu/framework/internal'; import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { StepTypeEnum } from '@novu/shared'; -import { Instrument, InstrumentUsecase } from '@novu/application-generic'; +import { Instrument, InstrumentUsecase, PinoLogger } from '@novu/application-generic'; +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; +import _ from 'lodash'; import { ConstructFrameworkWorkflowCommand } from './construct-framework-workflow.command'; import { ChatOutputRendererUsecase, @@ -15,10 +17,14 @@ import { } from '../output-renderers'; import { DelayOutputRendererUsecase } from '../output-renderers/delay-output-renderer.usecase'; import { DigestOutputRendererUsecase } from '../output-renderers/digest-output-renderer.usecase'; +import { evaluateRules } from '../../../shared/services/query-parser/query-parser.service'; + +const LOG_CONTEXT = 'ConstructFrameworkWorkflow'; @Injectable() export class ConstructFrameworkWorkflow { constructor( + private logger: PinoLogger, private workflowsRepository: NotificationTemplateRepository, private inAppOutputRendererUseCase: InAppOutputRendererUsecase, private emailOutputRendererUseCase: RenderEmailOutputUsecase, @@ -103,7 +109,7 @@ export class ConstructFrameworkWorkflow { return this.inAppOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, // Step options - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.EMAIL: return step.email( @@ -111,7 +117,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.emailOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.SMS: return step.inApp( @@ -119,7 +125,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.smsOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.CHAT: return step.inApp( @@ -127,7 +133,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.chatOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.PUSH: return step.inApp( @@ -135,7 +141,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.pushOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.DIGEST: return step.digest( @@ -143,7 +149,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.digestOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructActionStepOptions(staticStep) + this.constructActionStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.DELAY: return step.delay( @@ -151,7 +157,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.delayOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructActionStepOptions(staticStep) + this.constructActionStepOptions(staticStep, fullPayloadForRender) ); default: throw new InternalServerErrorException(`Step type ${stepType} is not supported`); @@ -159,9 +165,12 @@ export class ConstructFrameworkWorkflow { } @Instrument() - private constructChannelStepOptions(staticStep: NotificationStepEntity): Required[2]> { + private constructChannelStepOptions( + staticStep: NotificationStepEntity, + fullPayloadForRender: FullPayloadForRender + ): Required[2]> { return { - ...this.constructCommonStepOptions(staticStep), + ...this.constructCommonStepOptions(staticStep, fullPayloadForRender), // TODO: resolve this from the Step options disableOutputSanitization: false, // TODO: add providers @@ -170,22 +179,24 @@ export class ConstructFrameworkWorkflow { } @Instrument() - private constructActionStepOptions(staticStep: NotificationStepEntity): Required[2]> { + private constructActionStepOptions( + staticStep: NotificationStepEntity, + fullPayloadForRender: FullPayloadForRender + ): Required[2]> { return { - ...this.constructCommonStepOptions(staticStep), + ...this.constructCommonStepOptions(staticStep, fullPayloadForRender), }; } @Instrument() - private constructCommonStepOptions(staticStep: NotificationStepEntity): Required { + private constructCommonStepOptions( + staticStep: NotificationStepEntity, + fullPayloadForRender: FullPayloadForRender + ): Required { return { // TODO: fix the `JSONSchemaDto` type to enforce a non-primitive schema type. controlSchema: staticStep.template!.controls!.schema as JsonSchema, - /* - * TODO: add conditions - * Used to construct conditions defined with https://react-querybuilder.js.org/ or similar - */ - skip: (controlValues) => false, + skip: (controlValues: Record) => this.processSkipOption(controlValues, fullPayloadForRender), }; } @@ -199,7 +210,24 @@ export class ConstructFrameworkWorkflow { return foundWorkflow; } + + private processSkipOption(controlValues: { [x: string]: unknown }, variables: FullPayloadForRender) { + const skipRules = controlValues.skip as RulesLogic; + + if (_.isEmpty(skipRules)) { + return false; + } + + const { result, error } = evaluateRules(skipRules, variables); + + if (error) { + this.logger.error({ err: error }, 'Failed to evaluate skip rule', LOG_CONTEXT); + } + + return result; + } } + const PERMISSIVE_EMPTY_SCHEMA = { type: 'object', properties: {}, diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts index e3bfa56781e..81ec6db1480 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts @@ -2,9 +2,10 @@ import { EmailRenderOutput, TipTapNode } from '@novu/shared'; import { Injectable } from '@nestjs/common'; import { render as mailyRender } from '@maily-to/render'; import { Instrument, InstrumentUsecase } from '@novu/application-generic'; +import isEmpty from 'lodash/isEmpty'; import { FullPayloadForRender, RenderCommand } from './render-command'; import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; -import { EmailStepControlZodSchema } from '../../../workflows-v2/shared'; +import { emailStepControlZodSchema } from '../../../workflows-v2/shared'; export class RenderEmailOutputCommand extends RenderCommand {} @@ -14,8 +15,13 @@ export class RenderEmailOutputUsecase { @InstrumentUsecase() async execute(renderCommand: RenderEmailOutputCommand): Promise { - const { emailEditor, subject } = EmailStepControlZodSchema.parse(renderCommand.controlValues); - const expandedSchema = this.transformForAndShowLogic(emailEditor, renderCommand.fullPayloadForRender); + const { body, subject } = emailStepControlZodSchema.parse(renderCommand.controlValues); + + if (isEmpty(body)) { + return { subject, body: '' }; + } + + const expandedSchema = this.transformForAndShowLogic(body, renderCommand.fullPayloadForRender); const htmlRendered = await this.renderEmail(expandedSchema); return { subject, body: htmlRendered }; diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts index a65c6ca9561..3e8fbdc35cf 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -33,6 +33,10 @@ import { StepTypeEnum, SystemAvatarIconEnum, TemplateVariableTypeEnum, + CreateWorkflowDto, + WorkflowCreationSourceEnum, + WorkflowResponseDto, + ExecutionDetailsStatusEnum, } from '@novu/shared'; import { EmailEventStatusEnum } from '@novu/stateless'; import { DetailEnum } from '@novu/application-generic'; @@ -64,20 +68,20 @@ describe(`Trigger event - /v1/events/trigger (POST)`, function () { const tenantRepository = new TenantRepository(); let novuClient: Novu; - describe(`Trigger Event - /v1/events/trigger (POST)`, function () { - beforeEach(async () => { - session = new UserSession(); - await session.initialize(); - template = await session.createTemplate(); - subscriberService = new SubscribersService(session.organization._id, session.environment._id); - subscriber = await subscriberService.createSubscriber(); - workflowOverrideService = new WorkflowOverrideService({ - organizationId: session.organization._id, - environmentId: session.environment._id, - }); - novuClient = initNovuClassSdk(session); + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + template = await session.createTemplate(); + subscriberService = new SubscribersService(session.organization._id, session.environment._id); + subscriber = await subscriberService.createSubscriber(); + workflowOverrideService = new WorkflowOverrideService({ + organizationId: session.organization._id, + environmentId: session.environment._id, }); + novuClient = initNovuClassSdk(session); + }); + describe(`Trigger Event - /v1/events/trigger (POST)`, function () { it('should filter delay step', async function () { const firstStepUuid = uuid(); template = await session.createTemplate({ @@ -2198,7 +2202,7 @@ describe(`Trigger event - /v1/events/trigger (POST)`, function () { ], }); - // const axiosPostStub = sinon.stub(axios, 'post').throws(new Error('Users remote error')); + // const axiosPostStub = sinon.stub(axios, 'post').throws(new Error('Users remote error'))); await novuClient.trigger({ name: template.triggers[0].identifier, @@ -3223,10 +3227,215 @@ describe(`Trigger event - /v1/events/trigger (POST)`, function () { tenant, actor, }; - console.log('request111', JSON.stringify(request, null, 2)); return (await novuClient.trigger(request)).result; } + + describe('Trigger Event v2 workflow - /v1/events/trigger (POST)', function () { + afterEach(async () => { + await messageRepository.deleteMany({ + _environmentId: session.environment._id, + }); + }); + + it('should skip step based on skip', async function () { + const workflowBody: CreateWorkflowDto = { + name: 'Test Skip Workflow', + workflowId: 'test-skip-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'Message Name', + controlValues: { + body: 'Hello {{subscriber.lastName}}, Welcome!', + skip: { + '==': [{ var: 'payload.shouldSkip' }, true], + }, + }, + }, + ], + }; + + const response = await session.testAgent.post('/v2/workflows').send(workflowBody); + expect(response.status).to.equal(201); + const workflow: WorkflowResponseDto = response.body.data; + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + shouldSkip: true, + }, + }); + await session.awaitRunningJobs(workflow._id); + const skippedMessages = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(skippedMessages.length).to.equal(0); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + shouldSkip: false, + }, + }); + await session.awaitRunningJobs(workflow._id); + const notSkippedMessages = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(notSkippedMessages.length).to.equal(1); + }); + }); + + it('should handle complex skip logic with subscriber data', async function () { + const workflowBody: CreateWorkflowDto = { + name: 'Test Complex Skip Logic', + workflowId: 'test-complex-skip-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'Message Name', + controlValues: { + body: 'Hello {{subscriber.lastName}}, Welcome!', + skip: { + and: [ + { + or: [ + { '==': [{ var: 'subscriber.firstName' }, 'John'] }, + { '==': [{ var: 'subscriber.data.role' }, 'admin'] }, + ], + }, + { + and: [ + { '>=': [{ var: 'payload.userScore' }, 100] }, + { '==': [{ var: 'subscriber.lastName' }, 'Doe'] }, + ], + }, + ], + }, + }, + }, + ], + }; + + const response = await session.testAgent.post('/v2/workflows').send(workflowBody); + expect(response.status).to.equal(201); + const workflow: WorkflowResponseDto = response.body.data; + + // Should skip - matches all conditions + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Doe', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 150, + }, + }); + await session.awaitRunningJobs(workflow._id); + const skippedMessages = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(skippedMessages.length).to.equal(0); + + // Should not skip - doesn't match lastName condition + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Smith', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 150, + }, + }); + await session.awaitRunningJobs(workflow._id); + const notSkippedMessages1 = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(notSkippedMessages1.length).to.equal(1); + + // Should not skip - doesn't match score condition + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Doe', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 50, + }, + }); + await session.awaitRunningJobs(workflow._id); + const notSkippedMessages2 = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(notSkippedMessages2.length).to.equal(1); + }); + + it('should exit execution if skip condition execution throws an error', async function () { + const workflowBody: CreateWorkflowDto = { + name: 'Test Complex Skip Logic', + workflowId: 'test-complex-skip-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'Message Name', + controlValues: { + body: 'Hello {{subscriber.lastName}}, Welcome!', + skip: { invalidOp: [1, 2] }, // INVALID OPERATOR + }, + }, + ], + }; + + const response = await session.testAgent.post('/v2/workflows').send(workflowBody); + expect(response.status).to.equal(201); + const workflow: WorkflowResponseDto = response.body.data; + + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Doe', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 150, + }, + }); + await session.awaitRunningJobs(workflow._id); + const executionDetails = await executionDetailsRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + channel: ChannelTypeEnum.IN_APP, + status: ExecutionDetailsStatusEnum.FAILED, + }); + + expect(executionDetails?.raw).to.contain('Failed to evaluate rule'); + expect(executionDetails?.raw).to.contain('Unrecognized operation invalidOp'); + }); }); async function createTemplate(session, channelType) { diff --git a/apps/api/src/app/shared/services/query-parser/query-parser.service.spec.ts b/apps/api/src/app/shared/services/query-parser/query-parser.service.spec.ts new file mode 100644 index 00000000000..4c58f03920b --- /dev/null +++ b/apps/api/src/app/shared/services/query-parser/query-parser.service.spec.ts @@ -0,0 +1,384 @@ +import { RulesLogic, AdditionalOperation } from 'json-logic-js'; +import { expect } from 'chai'; + +import { evaluateRules } from './query-parser.service'; + +describe('QueryParserService', () => { + describe('Smoke Tests', () => { + it('should evaluate a simple equality rule', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 42] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should evaluate a complex nested rule', () => { + const rule: RulesLogic = { + and: [ + { '=': [{ var: 'value' }, 42] }, + { beginsWith: [{ var: 'text' }, 'hello'] }, + { notBetween: [{ var: 'number' }, [1, 5]] }, + ], + }; + const data = { value: 42, text: 'hello world', number: 10 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + describe('Error Handling', () => { + it('should handle invalid data types gracefully', () => { + const rule: RulesLogic = { beginsWith: [{ var: 'text' }, 123] }; + const data = { text: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should throw error when safe mode is disabled', () => { + const rule: RulesLogic = { invalid: 'operator' }; + const data = { text: 'hello' }; + expect(() => evaluateRules(rule, data, false)).to.throw('Failed to evaluate rule'); + }); + + it('should return false and error when safe mode is enabled', () => { + const rule: RulesLogic = { invalid: 'operator' }; + const data = { text: 'hello' }; + const { result, error } = evaluateRules(rule, data, true); + expect(error).to.not.be.undefined; + expect(result).to.be.false; + }); + }); + }); + + describe('Custom Operators', () => { + describe('= operator', () => { + it('should return true when values are equal', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 42] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when strings are equal', () => { + const rule: RulesLogic = { '=': [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when comparing number and string (type coercion)', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, '42'] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when values are not equal', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 42] }; + const data = { value: 43 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when types are different and values cannot be coerced', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 'not a number'] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('beginsWith operator', () => { + it('should return true when string begins with given value', () => { + const rule: RulesLogic = { beginsWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string does not begin with given value', () => { + const rule: RulesLogic = { beginsWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('endsWith operator', () => { + it('should return true when string ends with given value', () => { + const rule: RulesLogic = { endsWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string does not end with given value', () => { + const rule: RulesLogic = { endsWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('contains operator', () => { + it('should return true when string contains given value', () => { + const rule: RulesLogic = { contains: [{ var: 'text' }, 'llo wo'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string does not contain given value', () => { + const rule: RulesLogic = { contains: [{ var: 'text' }, 'xyz'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('doesNotContain operator', () => { + it('should return true when string does not contain given value', () => { + const rule: RulesLogic = { doesNotContain: [{ var: 'text' }, 'xyz'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string contains given value', () => { + const rule: RulesLogic = { doesNotContain: [{ var: 'text' }, 'llo'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('doesNotBeginWith operator', () => { + it('should return true when string does not begin with given value', () => { + const rule: RulesLogic = { doesNotBeginWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string begins with given value', () => { + const rule: RulesLogic = { doesNotBeginWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('doesNotEndWith operator', () => { + it('should return true when string does not end with given value', () => { + const rule: RulesLogic = { doesNotEndWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string ends with given value', () => { + const rule: RulesLogic = { doesNotEndWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('null operator', () => { + it('should return true when value is null', () => { + const rule: RulesLogic = { null: [{ var: 'value' }] }; + const data = { value: null }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when value is not null', () => { + const rule: RulesLogic = { null: [{ var: 'value' }] }; + const data = { value: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('notNull operator', () => { + it('should return true when value is not null', () => { + const rule: RulesLogic = { notNull: [{ var: 'value' }] }; + const data = { value: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when value is null', () => { + const rule: RulesLogic = { notNull: [{ var: 'value' }] }; + const data = { value: null }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('notIn operator', () => { + it('should return true when value is not in array', () => { + const rule: RulesLogic = { notIn: [{ var: 'value' }, ['a', 'b', 'c']] }; + const data = { value: 'd' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when value is in array', () => { + const rule: RulesLogic = { notIn: [{ var: 'value' }, ['a', 'b', 'c']] }; + const data = { value: 'b' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when ruleValue is not an array', () => { + const rule: RulesLogic = { notIn: [{ var: 'value' }, 'not an array'] }; + const data = { value: 'b' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('between operator', () => { + it('should return true when number is between min and max', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when number equals min', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 5 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when number equals max', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 10 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when number is less than min', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 4 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when number is greater than max', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 11 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when value is not a number', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 'not a number' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when range is not valid', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('notBetween operator', () => { + it('should return true when number is less than min', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 4 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when number is greater than max', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 11 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when number is between min and max', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when number equals min', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 5 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when number equals max', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 10 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when value is not a number', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 'not a number' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when range is not valid', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + }); +}); diff --git a/apps/api/src/app/shared/services/query-parser/query-parser.service.ts b/apps/api/src/app/shared/services/query-parser/query-parser.service.ts new file mode 100644 index 00000000000..35ac695bb2d --- /dev/null +++ b/apps/api/src/app/shared/services/query-parser/query-parser.service.ts @@ -0,0 +1,146 @@ +import jsonLogic, { AdditionalOperation, RulesLogic } from 'json-logic-js'; + +type RangeValidation = + | { + isValid: true; + min: number; + max: number; + } + | { + isValid: false; + }; + +type StringValidation = + | { + isValid: true; + input: string; + value: string; + } + | { + isValid: false; + }; + +function validateStringInput(dataInput: unknown, ruleValue: unknown): StringValidation { + if (typeof dataInput !== 'string' || typeof ruleValue !== 'string') { + return { isValid: false }; + } + + return { isValid: true, input: dataInput, value: ruleValue }; +} + +function validateRangeInput(dataInput: unknown, ruleValue: unknown): RangeValidation { + if (!Array.isArray(ruleValue) || ruleValue.length !== 2) { + return { isValid: false }; + } + + if (typeof dataInput !== 'number') { + return { isValid: false }; + } + + const [min, max] = ruleValue; + const valid = typeof min === 'number' && typeof max === 'number'; + + return { isValid: valid, min, max }; +} + +function createStringOperator(evaluator: (input: string, value: string) => boolean) { + return (dataInput: unknown, ruleValue: unknown): boolean => { + const validation = validateStringInput(dataInput, ruleValue); + if (!validation.isValid) return false; + + return evaluator(validation.input, validation.value); + }; +} + +const initializeCustomOperators = (): void => { + jsonLogic.add_operation('=', (dataInput: unknown, ruleValue: unknown): boolean => { + const result = jsonLogic.apply({ '==': [dataInput, ruleValue] }, {}); + + return typeof result === 'boolean' ? result : false; + }); + + jsonLogic.add_operation( + 'beginsWith', + createStringOperator((input, value) => input.startsWith(value)) + ); + + jsonLogic.add_operation( + 'endsWith', + createStringOperator((input, value) => input.endsWith(value)) + ); + + jsonLogic.add_operation( + 'contains', + createStringOperator((input, value) => input.includes(value)) + ); + + jsonLogic.add_operation( + 'doesNotContain', + createStringOperator((input, value) => !input.includes(value)) + ); + + jsonLogic.add_operation( + 'doesNotBeginWith', + createStringOperator((input, value) => !input.startsWith(value)) + ); + + jsonLogic.add_operation( + 'doesNotEndWith', + createStringOperator((input, value) => !input.endsWith(value)) + ); + + jsonLogic.add_operation('null', (dataInput: unknown): boolean => dataInput === null); + + jsonLogic.add_operation('notNull', (dataInput: unknown): boolean => dataInput !== null); + + jsonLogic.add_operation( + 'notIn', + (dataInput: unknown, ruleValue: unknown[]): boolean => Array.isArray(ruleValue) && !ruleValue.includes(dataInput) + ); + + jsonLogic.add_operation('between', (dataInput, ruleValue) => { + const validation = validateRangeInput(dataInput, ruleValue); + + if (!validation.isValid) { + return false; + } + + return dataInput >= validation.min && dataInput <= validation.max; + }); + + jsonLogic.add_operation('notBetween', (dataInput, ruleValue) => { + const validation = validateRangeInput(dataInput, ruleValue); + + if (!validation.isValid) { + return false; + } + + return dataInput < validation.min || dataInput > validation.max; + }); +}; + +initializeCustomOperators(); + +export function evaluateRules( + rule: RulesLogic, + data: unknown, + safe = false +): { result: boolean; error: string | undefined } { + try { + return { result: jsonLogic.apply(rule, data), error: undefined }; + } catch (error) { + if (safe) { + return { result: false, error }; + } + + throw new Error(`Failed to evaluate rule: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +export function isValidRule(rule: RulesLogic): boolean { + try { + return jsonLogic.is_logic(rule); + } catch { + return false; + } +} diff --git a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts index 236c38d7ddb..0ecf30de17c 100644 --- a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts @@ -59,7 +59,7 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview', () => preview: { subject: 'Welcome {{subscriber.firstName}}', // cspell:disable-next-line - body: 'Hello {{subscriber.firstName}} {{subscriber.lastName}}, Welcome to {{PAYLOAD.ORGANIZATIONNAME | UPCASE}}!', + body: 'Hello {{subscriber.firstName}} {{subscriber.lastName}}, Welcome to {{PAYLOAD.ORGANIZATIONNAME}}!', }, type: 'in_app', }, @@ -69,7 +69,7 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview', () => lastName: '{{subscriber.lastName}}', }, payload: { - organizationName: '{{payload.organizationName | upcase}}', + organizationName: '{{payload.organizationName}}', }, }, }, 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 fd6024fd280..fd480ac9855 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -8,7 +8,6 @@ import { createWorkflowClient, CreateWorkflowDto, CronExpressionEnum, - EmailStepControlSchemaDto, GeneratePreviewRequestDto, GeneratePreviewResponseDto, HttpError, @@ -20,6 +19,7 @@ import { import { buildCreateWorkflowDto } from './workflow.controller.e2e'; import { forSnippet, fullCodeSnippet } from './maily-test-data'; import { InAppControlType } from './shared'; +import { EmailStepControlType } from './shared/schemas/email-control.schema'; const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; @@ -328,11 +328,9 @@ describe('Generate Preview', () => { if (previewResponseDto.result!.type !== 'sms') { throw new Error('Expected sms'); } - expect(previewResponseDto.result!.preview.body).to.contain('{{PAYLOAD.VARIABLENAME | UPCASE}}'); + expect(previewResponseDto.result!.preview.body).to.contain('{{PAYLOAD.VARIABLENAME}}'); expect(previewResponseDto.previewPayloadExample).to.exist; - expect(previewResponseDto?.previewPayloadExample?.payload?.variableName).to.equal( - '{{payload.variableName | upcase}}' - ); + expect(previewResponseDto?.previewPayloadExample?.payload?.variableName).to.equal('{{payload.variableName}}'); }); it('Should not fail if inApp is providing partial URL in redirect', async () => { @@ -413,26 +411,7 @@ describe('Generate Preview', () => { ); if (generatePreviewResponseDto.result?.type === ChannelTypeEnum.IN_APP) { - expect(generatePreviewResponseDto.result.preview.body).to.equal( - { - subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, - body: `Hello, World! {{payload.placeholder.body}}`, - avatar: 'https://www.example.com/avatar.png', - primaryAction: { - label: '{{payload.secondaryUrl}}', - redirect: { - target: RedirectTargetEnum.BLANK, - }, - }, - secondaryAction: null, - redirect: { - target: RedirectTargetEnum.BLANK, - url: ' ', - }, - }.body - ); - expect(generatePreviewResponseDto.result.preview.primaryAction?.redirect?.url).to.be.ok; - expect(generatePreviewResponseDto.result.preview.primaryAction?.redirect?.url).to.contain('https'); + expect(generatePreviewResponseDto.result.preview.body).to.equal('Hello, World! {{payload.placeholder.body}}'); } }); }); @@ -441,7 +420,8 @@ describe('Generate Preview', () => { const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }]; channelTypes.forEach(({ type, description }) => { - it(`[${type}] should assign default values to missing elements`, async () => { + // TODO: We need to get back to the drawing board on this one to make the preview action of the framework more forgiving + it(`[${type}] catches the 400 error returned by the Bridge Preview action`, async () => { const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(workflowsClient, type); const requestDto = buildDtoWithMissingControlValues(type, stepId); @@ -453,11 +433,7 @@ describe('Generate Preview', () => { description ); - if (previewResponseDto.result!.type !== ChannelTypeEnum.IN_APP) { - throw new Error('Expected email'); - } - expect(previewResponseDto.result!.preview.body).to.exist; - expect(previewResponseDto.result!.preview.body).to.equal('PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING'); + expect(previewResponseDto.result).to.eql({ preview: {} }); }); }); }); @@ -538,16 +514,16 @@ function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId?: string): Generat }; } -function buildEmailControlValuesPayload(stepId?: string): EmailStepControlSchemaDto { +function buildEmailControlValuesPayload(stepId?: string): EmailStepControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, - emailEditor: JSON.stringify(fullCodeSnippet(stepId)), + body: JSON.stringify(fullCodeSnippet(stepId)), }; } -function buildSimpleForEmail(): EmailStepControlSchemaDto { +function buildSimpleForEmail(): EmailStepControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, - emailEditor: JSON.stringify(forSnippet), + body: JSON.stringify(forSnippet), }; } function buildInAppControlValues() { @@ -642,8 +618,6 @@ async function assertHttpError( dto: GeneratePreviewRequestDto ) { if (novuRestResult.error) { - console.log(JSON.stringify(JSON.parse(novuRestResult.error.responseText), null, 2)); - return new Error( `${description}: Failed to generate preview: ${novuRestResult.error.message}payload: ${JSON.stringify(dto, null, 2)} ` ); diff --git a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts index a90e225ad89..4855b30e463 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const ChatStepControlZodSchema = z .object({ + skip: skipControl.schema, body: z.string(), }) .strict(); @@ -18,6 +20,7 @@ export const chatStepUiSchema: UiSchema = { body: { component: UiComponentEnum.CHAT_BODY, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts index 855e35cbb83..cd3976caa1f 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts @@ -8,9 +8,11 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const DelayTimeControlZodSchema = z .object({ + skip: skipControl.schema, type: z.enum(['regular']).default('regular'), amount: z.union([z.number().min(1), z.string()]), unit: z.nativeEnum(TimeUnitEnum), @@ -24,6 +26,7 @@ export type DelayTimeControlType = z.infer; export const delayUiSchema: UiSchema = { group: UiSchemaGroupEnum.DELAY, properties: { + skip: skipControl.uiSchema.properties.skip, amount: { component: UiComponentEnum.DELAY_AMOUNT, placeholder: null, diff --git a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts index a9784660f0b..b235ca49d9f 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts @@ -8,9 +8,11 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; const DigestRegularControlZodSchema = z .object({ + skip: skipControl.schema, amount: z.union([z.number().min(1), z.string()]), unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), digestKey: z.string().optional(), @@ -74,5 +76,6 @@ export const digestUiSchema: UiSchema = { component: UiComponentEnum.DIGEST_CRON, placeholder: null, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 8b4648d300d..897c4f88429 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -1,30 +1,38 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; - import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import { skipControl } from './skip-control.schema'; +import { TipTapSchema } from '../../../environments-v1/usecases/output-renderers'; -export const EmailStepControlZodSchema = z +export const emailStepControlZodSchema = z .object({ - emailEditor: z.string(), - subject: z.string(), + skip: skipControl.schema, + /* + * todo: we need to validate the email editor (body) by type and not string, + * updating it to TipTapSchema will break the existing upsert issues generation + */ + body: z.string().optional().default(''), + subject: z.string().optional().default(''), }) - .strict() - .required({ - emailEditor: true, - subject: true, - }); + .strict(); -export const emailStepControlSchema = zodToJsonSchema(EmailStepControlZodSchema) as JSONSchemaDto; +export const emailStepControlSchema = zodToJsonSchema(emailStepControlZodSchema) as JSONSchemaDto; +export type EmailStepControlType = z.infer; -export type EmailStepControlType = z.infer; export const emailStepUiSchema: UiSchema = { group: UiSchemaGroupEnum.EMAIL, properties: { - emailEditor: { - component: UiComponentEnum.MAILY, + body: { + component: UiComponentEnum.BLOCK_EDITOR, }, subject: { component: UiComponentEnum.TEXT_INLINE_LABEL, }, + skip: skipControl.uiSchema.properties.skip, }, }; + +export const emailStepControl = { + uiSchema: emailStepUiSchema, + schema: emailStepControlSchema, +}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts index 90f713db176..fdd9da2533a 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; const redirectZodSchema = z .object({ @@ -22,6 +23,7 @@ const actionZodSchema = z export const InAppControlZodSchema = z .object({ + skip: skipControl.schema, subject: z.string().optional(), body: z.string(), avatar: z.string().optional(), @@ -76,5 +78,6 @@ export const inAppUiSchema: UiSchema = { component: UiComponentEnum.URL_TEXT_BOX, placeholder: redirectPlaceholder, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts index 7622bb3e428..c10e9f165d7 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const PushStepControlZodSchema = z .object({ + skip: skipControl.schema, subject: z.string(), body: z.string(), }) @@ -22,6 +24,7 @@ export const pushStepUiSchema: UiSchema = { body: { component: UiComponentEnum.PUSH_BODY, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts new file mode 100644 index 00000000000..286bc032e3f --- /dev/null +++ b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts @@ -0,0 +1,18 @@ +import { UiSchemaGroupEnum, UiSchema, UiComponentEnum } from '@novu/shared'; +import { z } from 'zod'; + +export const skipZodSchema = z.object({}).catchall(z.unknown()).optional(); + +export const skipStepUiSchema = { + group: UiSchemaGroupEnum.SKIP, + properties: { + skip: { + component: UiComponentEnum.QUERY_EDITOR, + }, + }, +} satisfies UiSchema; + +export const skipControl = { + uiSchema: skipStepUiSchema, + schema: skipZodSchema, +}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts index 58ad049f599..cefee696214 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const SmsStepControlZodSchema = z .object({ + skip: skipControl.schema, body: z.string(), }) .strict(); @@ -18,6 +20,7 @@ export const smsStepUiSchema: UiSchema = { body: { component: UiComponentEnum.SMS_BODY, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts index 0d737a2c2f0..663d6b10642 100644 --- a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts +++ b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts @@ -1,6 +1,6 @@ -import { ActionStepEnum, ChannelStepEnum, channelStepSchemas } from '@novu/framework/internal'; +import { ActionStepEnum, ChannelStepEnum } from '@novu/framework/internal'; import { ControlSchemas, JSONSchemaDto } from '@novu/shared'; -import { emailStepControlSchema, emailStepUiSchema, inAppControlSchema, inAppUiSchema } from './schemas'; +import { emailStepControl, inAppControlSchema, inAppUiSchema } from './schemas'; import { DelayTimeControlSchema, delayUiSchema } from './schemas/delay-control.schema'; import { DigestOutputJsonSchema, digestUiSchema } from './schemas/digest-control.schema'; import { smsStepControl } from './schemas/sms-control.schema'; @@ -20,8 +20,8 @@ export const stepTypeToControlSchema: Record; +}; + +type ProcessedControlResult = { + controlValues: Record; + variablesExample: Record | null; +}; + @Injectable() export class GeneratePreviewUsecase { constructor( - private legacyPreviewStepUseCase: PreviewStep, + private previewStepUsecase: PreviewStep, private buildStepDataUsecase: BuildStepDataUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private readonly logger: PinoLogger, - private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase, - private buildPayloadSchema: BuildPayloadSchema + private buildPayloadSchema: BuildPayloadSchema, + private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase ) {} @InstrumentUsecase() async execute(command: GeneratePreviewCommand): Promise { try { - const { previewPayload: commandVariablesExample, controlValues: commandControlValues } = - command.generatePreviewRequestDto; - const stepData = await this.getStepData(command); - const controlValues = commandControlValues || stepData.controls.values || {}; - const workflow = await this.findWorkflow(command); - const payloadSchema = await this.buildPayloadSchema.execute( - BuildPayloadSchemaCommand.create({ - environmentId: command.user.environmentId, - organizationId: command.user.organizationId, - userId: command.user._id, - workflowId: command.workflowIdOrInternalId, - controlValues, - }) - ); + const { + stepData, + controlValues: initialControlValues, + variableSchema, + workflow, + } = await this.initializePreviewContext(command); - const variableSchema = this.buildVariablesSchema(stepData.variables, payloadSchema); - const preparedAndValidatedContent = await this.prepareAndValidateContentUsecase.execute({ - user: command.user, - previewPayloadFromDto: commandVariablesExample, - controlValues, - controlDataSchema: stepData.controls.dataSchema || {}, + const sanitizedValidatedControls = sanitizePreviewControlValues(initialControlValues, stepData.type); + + if (!sanitizedValidatedControls) { + throw new Error( + // eslint-disable-next-line max-len + 'Control values normalization failed: The normalizeControlValues function requires maintenance to sanitize the provided type or data structure correctly' + ); + } + + const destructuredControlValues = this.destructureControlValues(sanitizedValidatedControls); + + const { variablesExample: tiptapVariablesExample, controlValues: tiptapControlValues } = + await this.handleTipTapControl( + destructuredControlValues.tiptapControlValues, + command, + stepData, + variableSchema + ); + const { variablesExample: simpleVariablesExample, controlValues: simpleControlValues } = this.handleSimpleControl( + destructuredControlValues.simpleControlValues, variableSchema, - }); - const variablesExample = this.buildVariablesExample( workflow, - preparedAndValidatedContent.finalPayload, - commandVariablesExample + command.generatePreviewRequestDto.previewPayload ); + const previewData = { + variablesExample: _.merge({}, tiptapVariablesExample || {}, simpleVariablesExample || {}), + controlValues: { ...tiptapControlValues, ...simpleControlValues }, + }; const executeOutput = await this.executePreviewUsecase( command, stepData, - variablesExample, - preparedAndValidatedContent.finalControlValues + previewData.variablesExample, + previewData.controlValues ); return { @@ -84,7 +111,7 @@ export class GeneratePreviewUsecase { preview: executeOutput.outputs as any, type: stepData.type as unknown as ChannelTypeEnum, }, - previewPayloadExample: variablesExample, + previewPayloadExample: previewData.variablesExample, }; } catch (error) { this.logger.error( @@ -96,7 +123,6 @@ export class GeneratePreviewUsecase { `Unexpected error while generating preview`, LOG_CONTEXT ); - if (process.env.SENTRY_DSN) { captureException(error); } @@ -111,39 +137,149 @@ export class GeneratePreviewUsecase { } } - /** - * Merges the payload schema into the variables schema to enable proper validation - * and sanitization of control values in the prepareAndValidateContentUsecase. - */ - @Instrument() - private buildVariablesSchema(variables: Record, payloadSchema: JSONSchemaDto) { - if (Object.keys(payloadSchema).length === 0) { - return variables; + private async safeAttemptToParseEmailSchema( + tiptapControl: string, + command: GeneratePreviewCommand, + controlValues: Record, + controlSchema: Record, + variableSchema: Record + ): Promise | null> { + if (typeof tiptapControl !== 'string') { + return null; } - return _.merge(variables, { properties: { payload: payloadSchema } }); + try { + const preparedAndValidatedContent = await this.prepareAndValidateContentUsecase.execute({ + user: command.user, + previewPayloadFromDto: command.generatePreviewRequestDto.previewPayload, + controlValues, + controlDataSchema: controlSchema || {}, + variableSchema, + }); + + return preparedAndValidatedContent.finalPayload as Record; + } catch (e) { + return null; + } } - @Instrument() - private buildVariablesExample( + private async handleTipTapControl( + tiptapControlValue: { + emailEditor?: string | null; + body?: string | null; + } | null, + command: GeneratePreviewCommand, + stepData: StepDataDto, + variableSchema: Record + ): Promise { + if (!tiptapControlValue || (!tiptapControlValue?.emailEditor && !tiptapControlValue?.body)) { + return { + variablesExample: null, + controlValues: tiptapControlValue as Record, + }; + } + + const emailVariables = await this.safeAttemptToParseEmailSchema( + tiptapControlValue?.emailEditor || tiptapControlValue?.body || '', + command, + tiptapControlValue, + stepData.controls.dataSchema || {}, + variableSchema + ); + + return { + variablesExample: emailVariables, + controlValues: tiptapControlValue, + }; + } + + private handleSimpleControl( + controlValues: Record, + variableSchema: Record, workflow: WorkflowInternalResponseDto, - finalPayload?: PreviewPayload, - commandVariablesExample?: PreviewPayload | undefined - ) { - if (workflow.origin !== WorkflowOriginEnum.EXTERNAL) { - return finalPayload; + commandVariablesExample: PreviewPayload | undefined + ): ProcessedControlResult { + const variables = this.processControlValueVariables(controlValues, variableSchema); + const processedControlValues = this.fixControlValueInvalidVariables(controlValues, variables.invalid); + const extractedTemplateVariables = variables.valid.map((variable) => variable.name); + const payloadVariableExample = + workflow.origin === WorkflowOriginEnum.EXTERNAL + ? createMockObjectFromSchema({ + type: 'object', + properties: { payload: workflow.payloadSchema }, + }) + : {}; + + if (extractedTemplateVariables.length === 0) { + return { + variablesExample: payloadVariableExample, + controlValues: processedControlValues, + }; } - const examplePayloadSchema = createMockObjectFromSchema({ - type: 'object', - properties: { payload: workflow.payloadSchema }, + const variablesExample: Record = pathsToObject(extractedTemplateVariables, { + valuePrefix: '{{', + valueSuffix: '}}', }); - if (!examplePayloadSchema || Object.keys(examplePayloadSchema).length === 0) { - return finalPayload; + const variablesExampleForPreview = _.merge(variablesExample, payloadVariableExample, commandVariablesExample || {}); + + return { + variablesExample: variablesExampleForPreview, + controlValues: processedControlValues, + }; + } + + private async initializePreviewContext(command: GeneratePreviewCommand) { + const stepData = await this.getStepData(command); + const controlValues = command.generatePreviewRequestDto.controlValues || stepData.controls.values || {}; + const workflow = await this.findWorkflow(command); + const variableSchema = await this.buildVariablesSchema(stepData.variables, command, controlValues); + + return { stepData, controlValues, variableSchema, workflow }; + } + + private processControlValueVariables( + controlValues: Record, + variableSchema: Record + ): { + valid: Variable[]; + invalid: Variable[]; + } { + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(controlValues)); + + const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( + variableSchema, + validVariables + ); + + return { + valid: validSchemaVariables, + invalid: [...invalidVariables, ...invalidSchemaVariables], + }; + } + + @Instrument() + private async buildVariablesSchema( + variables: Record, + command: GeneratePreviewCommand, + controlValues: Record + ) { + const payloadSchema = await this.buildPayloadSchema.execute( + BuildPayloadSchemaCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + userId: command.user._id, + workflowId: command.workflowIdOrInternalId, + controlValues, + }) + ); + + if (Object.keys(payloadSchema).length === 0) { + return variables; } - return _.merge(finalPayload as Record, examplePayloadSchema, commandVariablesExample || {}); + return _.merge(variables, { properties: { payload: payloadSchema } }); } @Instrument() @@ -180,7 +316,7 @@ export class GeneratePreviewUsecase { ) { const state = buildState(hydratedPayload.steps); try { - return await this.legacyPreviewStepUseCase.execute( + return await this.previewStepUsecase.execute( PreviewStepCommand.create({ payload: hydratedPayload.payload || {}, subscriber: hydratedPayload.subscriber, @@ -202,6 +338,55 @@ export class GeneratePreviewUsecase { } } } + + private destructureControlValues(controlValues: Record): DestructuredControlValues { + try { + const localControlValue = _.cloneDeep(controlValues); + let tiptapControlString: string | null = null; + + if (isTipTapNode(localControlValue.emailEditor)) { + tiptapControlString = localControlValue.emailEditor; + delete localControlValue.emailEditor; + + return { tiptapControlValues: { emailEditor: tiptapControlString }, simpleControlValues: localControlValue }; + } + + if (isTipTapNode(localControlValue.body)) { + tiptapControlString = localControlValue.body; + delete localControlValue.body; + + return { tiptapControlValues: { body: tiptapControlString }, simpleControlValues: localControlValue }; + } + + return { tiptapControlValues: null, simpleControlValues: localControlValue }; + } catch (error) { + this.logger.error({ error }, 'Failed to extract TipTap control', LOG_CONTEXT); + + return { tiptapControlValues: null, simpleControlValues: controlValues }; + } + } + + private fixControlValueInvalidVariables( + controlValues: Record, + invalidVariables: Variable[] + ): Record { + try { + let controlValuesString = JSON.stringify(controlValues); + + for (const invalidVariable of invalidVariables) { + if (!controlValuesString.includes(invalidVariable.template)) { + continue; + } + + const EMPTY_STRING = ''; + controlValuesString = replaceAll(controlValuesString, invalidVariable.template, EMPTY_STRING); + } + + return JSON.parse(controlValuesString) as Record; + } catch (error) { + return controlValues; + } + } } function buildState(steps: Record | undefined): FrameworkPreviousStepsOutputState[] { @@ -241,3 +426,154 @@ class FrameworkError { message: string; name: string; } + +/** + * Validates liquid template variables against a schema, the result is an object with valid and invalid variables + * @example + * const variables = [ + * { name: 'subscriber.firstName' }, + * { name: 'subscriber.orderId' } + * ]; + * const schema = { + * properties: { + * subscriber: { + * properties: { + * firstName: { type: 'string' } + * } + * } + * } + * }; + * const invalid = [{ name: 'unknown.variable' }]; + * + * validateVariablesAgainstSchema(variables, schema, invalid); + * // Returns: + * // { + * // validVariables: [{ name: 'subscriber.firstName' }], + * // invalidVariables: [{ name: 'unknown.variable' }, { name: 'subscriber.orderId' }] + * // } + */ +function identifyUnknownVariables( + variableSchema: Record, + validVariables: Variable[] +): TemplateParseResult { + const validVariablesCopy: Variable[] = _.cloneDeep(validVariables); + + const result = validVariablesCopy.reduce( + (acc, variable: Variable) => { + const parts = variable.name.split('.'); + let isValid = true; + let currentPath = 'properties'; + + for (const part of parts) { + currentPath += `.${part}`; + const valueSearch = _.get(variableSchema, currentPath); + + currentPath += '.properties'; + const propertiesSearch = _.get(variableSchema, currentPath); + + if (valueSearch === undefined && propertiesSearch === undefined) { + isValid = false; + break; + } + } + + if (isValid) { + acc.validVariables.push(variable); + } else { + acc.invalidVariables.push({ + name: variable.template, + context: variable.context, + message: 'Variable is not supported', + template: variable.template, + }); + } + + return acc; + }, + { + validVariables: [] as Variable[], + invalidVariables: [] as Variable[], + } as TemplateParseResult + ); + + return result; +} + +/** + * Fixes invalid Liquid template variables for preview by replacing them with error messages. + * + * @example + * // Input controlValues: + * { "message": "Hello {{invalid.var}}" } + * + * // Output: + * { "message": "Hello [[Invalid Variable: invalid.var]]" } + */ +function replaceAll(text: string, searchValue: string, replaceValue: string): string { + return _.replace(text, new RegExp(_.escapeRegExp(searchValue), 'g'), replaceValue); +} + +/** + * + * @param value minimal tiptap object from the client is + * { + * "type": "doc", + * "content": [ + * { + * "type": "paragraph", + * "attrs": { + * "textAlign": "left" + * }, + * "content": [ + * { + * "type": "text", + * "text": " " + * } + * ] + * } + *] + *} + */ +export function isTipTapNode(value: unknown): value is string { + let localValue = value; + if (typeof localValue === 'string') { + try { + localValue = JSON.parse(localValue); + } catch { + return false; + } + } + + if (!localValue || typeof localValue !== 'object') return false; + + const doc = localValue as TipTapNode; + + // TODO check if validate type === doc is enough + if (doc.type !== 'doc' || !Array.isArray(doc.content)) return false; + + return true; + + /* + * TODO check we need to validate the content + * return doc.content.every((node) => isValidTipTapContent(node)); + */ +} + +function isValidTipTapContent(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + const content = node as TipTapNode; + if (typeof content.type !== 'string') return false; + if (content.attrs !== undefined && (typeof content.attrs !== 'object' || content.attrs === null)) { + return false; + } + if (content.text !== undefined && typeof content.text !== 'string') { + return false; + } + if (content.content !== undefined) { + if (!Array.isArray(content.content)) return false; + + return content.content.every((child) => isValidTipTapContent(child)); + } + + return true; +} diff --git a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts index e754b027026..591045b9007 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts @@ -17,5 +17,5 @@ export class PatchStepCommand extends EnvironmentWithUserObjectCommand { @IsOptional() @IsObject() - controlValues?: Record; + controlValues?: Record | null; } diff --git a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts index 26b2126c0d5..02d8924a1a1 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts @@ -1,8 +1,10 @@ /* eslint-disable no-param-reassign */ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; import { StepDataDto, UserSessionData } from '@novu/shared'; import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { + DeleteControlValuesCommand, + DeleteControlValuesUseCase, GetWorkflowByIdsUseCase, UpsertControlValuesCommand, UpsertControlValuesUseCase, @@ -22,7 +24,9 @@ export class PatchStepUsecase { private buildStepDataUsecase: BuildStepDataUsecase, private notificationTemplateRepository: NotificationTemplateRepository, private upsertControlValuesUseCase: UpsertControlValuesUseCase, - private postProcessWorkflowUpdate: PostProcessWorkflowUpdate + private postProcessWorkflowUpdate: PostProcessWorkflowUpdate, + @Inject(forwardRef(() => DeleteControlValuesUseCase)) + private deleteControlValuesUseCase: DeleteControlValuesUseCase ) {} async execute(command: PatchStepCommand): Promise { @@ -43,7 +47,19 @@ export class PatchStepUsecase { } if (command.controlValues !== undefined) { - await this.updateControlValues(persistedItems, command); + if (command.controlValues === null) { + await this.deleteControlValuesUseCase.execute( + DeleteControlValuesCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + stepId: persistedItems.currentStep._id as string, + workflowId: persistedItems.workflow._id, + userId: command.user._id, + }) + ); + } else { + await this.updateControlValues(persistedItems, command); + } } } diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts index 94ce1125c5f..8b55e29e18b 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts @@ -12,7 +12,7 @@ export class UpsertStepDataCommand { type: StepTypeEnum; @IsOptional() - controlValues?: Record; + controlValues?: Record | null; @IsOptional() @IsString() 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 a34b5f91014..adf24b7c0a7 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 @@ -299,7 +299,7 @@ export class UpsertWorkflowUseCase { ): Promise { for (const step of workflow.steps) { const controlValues = this.findControlValueInRequest(step, command.workflowDto.steps); - if (!controlValues) { + if (controlValues === undefined) { continue; } await this.patchStepDataUsecase.execute({ @@ -316,7 +316,7 @@ export class UpsertWorkflowUseCase { private findControlValueInRequest( step: NotificationStepEntity, steps: (StepCreateDto | StepUpdateDto)[] | StepCreateDto[] - ): Record | undefined { + ): Record | undefined | null { return steps.find((stepRequest) => { if (this.isStepUpdateDto(stepRequest)) { return stepRequest._id === step._templateId; diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts index c794cca8190..ce717cd3481 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts @@ -2,12 +2,15 @@ import { expect } from 'chai'; import { extractLiquidTemplateVariables } from './liquid-parser'; describe('parseLiquidVariables', () => { - it('should extract simple variable names', () => { + it('should not extract variable without namespace', () => { const template = '{{name}} {{age}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['name', 'age']); + expect(validVariablesNames).to.have.members([]); + expect(invalidVariables).to.have.lengthOf(2); + expect(invalidVariables[0].name).to.equal('{{name}}'); + expect(invalidVariables[1].name).to.equal('{{age}}'); }); it('should extract nested object paths', () => { @@ -19,73 +22,87 @@ describe('parseLiquidVariables', () => { }); it('should handle multiple occurrences of the same variable', () => { - const template = '{{user.name}} {{user.name}} {{user.name}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const template = '{{user.name}} {{user.name}} {{user.name}} {{invalid..foo}} {{invalid..foo}}'; + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); expect(validVariablesNames).to.have.members(['user.name']); + expect(invalidVariables).to.have.lengthOf(1); + expect(invalidVariables[0].name).to.equal('{{invalid..foo}}'); }); it('should handle mixed content with HTML and variables', () => { const template = '
Hello {{user.name}}
{{status}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['user.name', 'status']); + expect(validVariablesNames).to.have.members(['user.name']); + expect(invalidVariables).to.have.lengthOf(1); + expect(invalidVariables[0].name).to.equal('{{status}}'); }); it('should handle whitespace in template syntax', () => { - const template = '{{ user.name }} {{ status }}'; + const template = '{{ user.name }}'; const { validVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['user.name', 'status']); + expect(validVariablesNames).to.have.members(['user.name']); }); it('should handle empty template string', () => { const template = ''; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(0); }); it('should handle template with no variables', () => { const template = 'Hello World!'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(0); }); it('should handle special characters in variable names', () => { - const template = '{{special_var_1}} {{data-point}}'; + const template = '{{subscriber.special_var_1}} {{subscriber.data-point}}'; const { validVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['special_var_1', 'data-point']); + expect(validVariablesNames).to.have.members(['subscriber.special_var_1', 'subscriber.data-point']); + }); + + it('should handle whitespace in between template syntax', () => { + const template = '{{ user. name }}'; + const { validVariables } = extractLiquidTemplateVariables(template); + + expect(validVariables).to.have.lengthOf(1); + expect(validVariables[0].name).to.equal('user.name'); }); describe('Error handling', () => { it('should handle invalid liquid syntax gracefully', () => { - const { validVariables: variables, invalidVariables: errors } = extractLiquidTemplateVariables( + const { validVariables, invalidVariables } = extractLiquidTemplateVariables( '{{invalid..syntax}} {{invalid2..syntax}}' ); - expect(variables).to.have.lengthOf(0); - expect(errors).to.have.lengthOf(2); - expect(errors[0].message).to.contain('expected "|" before filter'); - expect(errors[0].name).to.equal('{{invalid..syntax}}'); - expect(errors[1].name).to.equal('{{invalid2..syntax}}'); + expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(2); + expect(invalidVariables[0].message).to.contain('expected "|" before filter'); + expect(invalidVariables[0].name).to.equal('{{invalid..syntax}}'); + expect(invalidVariables[1].name).to.equal('{{invalid2..syntax}}'); }); it('should handle invalid liquid syntax gracefully, return valid variables', () => { - const { validVariables, invalidVariables: errors } = extractLiquidTemplateVariables( + const { validVariables, invalidVariables } = extractLiquidTemplateVariables( '{{subscriber.name}} {{invalid..syntax}}' ); const validVariablesNames = validVariables.map((variable) => variable.name); expect(validVariablesNames).to.have.members(['subscriber.name']); - expect(errors[0].message).to.contain('expected "|" before filter'); - expect(errors[0].name).to.equal('{{invalid..syntax}}'); + expect(invalidVariables[0].message).to.contain('expected "|" before filter'); + expect(invalidVariables[0].name).to.equal('{{invalid..syntax}}'); }); it('should handle undefined input gracefully', () => { diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts index cec68e1c16b..8cba93edd84 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts @@ -12,6 +12,7 @@ export type Variable = { context?: string; message?: string; name: string; + template: string; }; export type TemplateParseResult = { @@ -75,62 +76,103 @@ export function extractLiquidTemplateVariables(template: string): TemplateParseR } function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { - const validVariables = new Set(); + const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; + const processedVariables = new Set(); + + function addVariable(variable: Variable, isValid: boolean) { + if (!processedVariables.has(variable.name)) { + processedVariables.add(variable.name); + (isValid ? validVariables : invalidVariables).push(variable); + } + } for (const rawOutput of rawOutputs) { try { - const parsedVars = parseByLiquid(rawOutput); - parsedVars.forEach((variable) => validVariables.add(variable)); + const result = parseByLiquid(rawOutput); + result.validVariables.forEach((variable) => addVariable(variable, true)); + result.invalidVariables.forEach((variable) => addVariable(variable, false)); } catch (error: unknown) { if (isLiquidErrors(error)) { - invalidVariables.push( - ...error.errors.map((e: RenderError) => ({ - context: e.context, - message: e.message, - name: rawOutput, - })) - ); + error.errors.forEach((e: RenderError) => { + addVariable( + { + name: rawOutput, + message: e.message, + context: e.context, + template: rawOutput, + }, + false + ); + }); } } } - return { - validVariables: [...validVariables].map((name) => ({ name })), - invalidVariables, - }; + return { validVariables, invalidVariables }; } -function parseByLiquid(expression: string): Set { - const variables = new Set(); +function parseByLiquid(rawOutput: string): TemplateParseResult { + const validVariables: Variable[] = []; + const invalidVariables: Variable[] = []; const engine = new Liquid(LIQUID_CONFIG); - const parsed = engine.parse(expression) as unknown as Template[]; + const parsed = engine.parse(rawOutput) as unknown as Template[]; parsed.forEach((template: Template) => { if (isOutputToken(template)) { - const props = extractValidProps(template); - if (props.length > 0) { - variables.add(props.join('.')); + const result = extractProps(template); + + if (result.valid && result.props.length > 0) { + validVariables.push({ name: result.props.join('.'), template: rawOutput }); + } + + if (!result.valid) { + invalidVariables.push({ name: template?.token?.input, message: result.error, template: rawOutput }); } } }); - return variables; + return { validVariables, invalidVariables }; } function isOutputToken(template: Template): boolean { return template.token?.constructor.name === 'OutputToken'; } -function extractValidProps(template: any): string[] { +function extractProps(template: any): { valid: boolean; props: string[]; error?: string } { const initial = template.value?.initial; - if (!initial?.postfix?.[0]?.props) return []; + if (!initial?.postfix?.[0]?.props) return { valid: true, props: [] }; + + /** + * If initial.postfix length is greater than 1, it means the variable contains spaces + * which is not supported in Novu's variable syntax. + * + * Example: + * Valid: {{user.firstName}} + * Invalid: {{user.first name}} - postfix length would be 2 due to space + */ + if (initial.postfix.length > 1) { + return { valid: false, props: [], error: 'Novu does not support variables with spaces' }; + } const validProps: string[] = []; + for (const prop of initial.postfix[0].props) { if (prop.constructor.name !== 'IdentifierToken') break; validProps.push(prop.content); } - return validProps; + /** + * If validProps length is 1, it means the variable has no namespace which is not + * supported in Novu's variable syntax. Variables must be namespaced. + * + * Example: + * Valid: {{user.firstName}} - Has namespace 'user' + * Invalid: {{firstName}} - No namespace + */ + if (validProps.length === 1) { + return { valid: false, props: [], error: 'Novu variables must include a namespace (e.g. user.firstName)' }; + } + + return { valid: true, props: validProps }; } diff --git a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts index 74d42989d7a..2c55a9dc5d0 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -227,13 +227,13 @@ describe('Workflow Controller E2E API Testing', () => { describe('Workflow Step content Issues', () => { it('should show control value required when missing', async () => { - const { issues, status } = await createWorkflowAndReturnStepIssues({ steps: [{ ...buildEmailStep() }] }, 0); + const { issues, status } = await createWorkflowAndReturnStepIssues({ steps: [{ ...buildInAppStep() }] }, 0); expect(status, JSON.stringify(issues)).to.equal(WorkflowStatusEnum.ERROR); expect(issues).to.be.ok; if (issues.controls) { - expect(issues.controls?.emailEditor).to.be.ok; - if (issues.controls?.emailEditor) { - expect(issues.controls?.emailEditor[0].issueType).to.be.equal(StepContentIssueEnum.MISSING_VALUE); + expect(issues.controls?.body).to.be.ok; + if (issues.controls?.body) { + expect(issues.controls?.body[0].issueType).to.be.equal(StepContentIssueEnum.MISSING_VALUE); } } }); diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index 4bd4396ea5f..9b9b95530a0 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -9,6 +9,7 @@ import { UpsertControlValuesUseCase, UpsertPreferences, TierRestrictionsValidateUsecase, + DeleteControlValuesUseCase, } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; @@ -77,6 +78,7 @@ const DAL_REPOSITORIES = [CommunityOrganizationRepository]; PatchWorkflowUsecase, TierRestrictionsValidateUsecase, BuildPayloadSchema, + DeleteControlValuesUseCase, ], }) export class WorkflowModule implements NestModule { diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 8ae5550652f..65bdce5c519 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -74,6 +74,7 @@ "js-cookie": "^3.0.5", "launchdarkly-react-client-sdk": "^3.3.2", "lodash.debounce": "^4.0.8", + "lodash.isequal": "^4.5.0", "lodash.merge": "^4.6.2", "lucide-react": "^0.439.0", "mixpanel-browser": "^2.52.0", @@ -102,6 +103,7 @@ "@sentry/vite-plugin": "^2.22.6", "@tiptap/core": "^2.10.3", "@types/lodash.debounce": "^4.0.9", + "@types/lodash.isequal": "^4.5.8", "@types/lodash.merge": "^4.6.6", "@types/mixpanel-browser": "^2.49.0", "@types/node": "^22.7.0", diff --git a/apps/dashboard/src/components/billing/features.tsx b/apps/dashboard/src/components/billing/features.tsx index 6baa84667fd..610391e412d 100644 --- a/apps/dashboard/src/components/billing/features.tsx +++ b/apps/dashboard/src/components/billing/features.tsx @@ -1,5 +1,5 @@ -import { Check } from 'lucide-react'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { Check } from 'lucide-react'; import { cn } from '../../utils/ui'; enum SupportedPlansEnum { @@ -159,7 +159,7 @@ const features: Feature[] = [ label: 'Team members', values: { [SupportedPlansEnum.FREE]: { value: '3' }, - [SupportedPlansEnum.BUSINESS]: { value: '10' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Unlimited' }, [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, }, }, diff --git a/apps/dashboard/src/components/billing/highlights-row.tsx b/apps/dashboard/src/components/billing/highlights-row.tsx index 331673493a2..a2678384e3f 100644 --- a/apps/dashboard/src/components/billing/highlights-row.tsx +++ b/apps/dashboard/src/components/billing/highlights-row.tsx @@ -19,7 +19,7 @@ const highlights: PlanHighlights = { ], [ApiServiceLevelEnum.BUSINESS]: [ { text: 'Up to 250,000 events per month' }, - { text: '50 teammates' }, + { text: 'Unlimited teammates' }, { text: '90 days Activity Feed retention' }, ], [ApiServiceLevelEnum.ENTERPRISE]: [ diff --git a/apps/dashboard/src/components/billing/plans-row.tsx b/apps/dashboard/src/components/billing/plans-row.tsx index d5b2d1a397d..70255b6e2d2 100644 --- a/apps/dashboard/src/components/billing/plans-row.tsx +++ b/apps/dashboard/src/components/billing/plans-row.tsx @@ -1,8 +1,8 @@ import { Badge } from '@/components/primitives/badge'; import { Card } from '@/components/primitives/card'; import { Check } from 'lucide-react'; -import { PlanActionButton } from './plan-action-button'; import { ContactSalesButton } from './contact-sales-button'; +import { PlanActionButton } from './plan-action-button'; interface PlansRowProps { selectedBillingInterval: 'month' | 'year'; @@ -87,7 +87,7 @@ export function PlansRow({ selectedBillingInterval, currentPlan, trial }: PlansR
  • - Up to 10 team members + Unlimited team members
  • diff --git a/apps/dashboard/src/components/primitives/input.tsx b/apps/dashboard/src/components/primitives/input.tsx index dcad8fad7b8..ddde386725f 100644 --- a/apps/dashboard/src/components/primitives/input.tsx +++ b/apps/dashboard/src/components/primitives/input.tsx @@ -39,7 +39,12 @@ const inputFieldVariants = cva( 'has-[input:read-only]:text-foreground-700', 'has-[input:read-only]:bg-neutral-alpha-100', 'has-[input:read-only]:opacity-70', - 'has-[input:read-only]:border-neutral-alpha-200' + 'has-[input:read-only]:border-neutral-alpha-200', + 'has-[.cm-content[aria-readonly=true]]:cursor-not-allowed', + 'has-[.cm-content[aria-readonly=true]]:text-foreground-700', + 'has-[.cm-content[aria-readonly=true]]:bg-neutral-alpha-100', + 'has-[.cm-content[aria-readonly=true]]:opacity-70', + 'has-[.cm-content[aria-readonly=true]]:border-neutral-alpha-200' ), { variants: { diff --git a/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx b/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx index 20d164029c5..1474e8b9d00 100644 --- a/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx +++ b/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx @@ -78,6 +78,7 @@ export const AddStepMenu = ({ }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const areNewStepsEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_DELAY_DIGEST_EMAIL_ENABLED); + const arePushChatSMSEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_SMS_CHAT_PUSH_ENABLED); const handleMenuItemClick = (stepType: StepTypeEnum) => { onMenuItemClick(stepType); @@ -123,7 +124,13 @@ export const AddStepMenu = ({ > In-App - Push + handleMenuItemClick(StepTypeEnum.PUSH)} + > + Push + Chat SMS diff --git a/apps/dashboard/src/components/workflow-editor/nodes.tsx b/apps/dashboard/src/components/workflow-editor/nodes.tsx index 319fa79615f..32816bcd7c6 100644 --- a/apps/dashboard/src/components/workflow-editor/nodes.tsx +++ b/apps/dashboard/src/components/workflow-editor/nodes.tsx @@ -121,7 +121,7 @@ export const InAppNode = (props: NodeProps) => { {data.name || 'In-App Step'} - Sends In-app notification to your subscribers + Sends In-App notification to your subscribers {data.error && {data.error}} diff --git a/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx b/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx new file mode 100644 index 00000000000..4d22ff77e47 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx @@ -0,0 +1,55 @@ +import { EditorView } from '@uiw/react-codemirror'; +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Editor } from '@/components/primitives/editor'; +import { FormControl, FormField, FormItem, FormMessage } from '@/components/primitives/form/form'; +import { InputField } from '@/components/primitives/input'; +import { completions } from '@/utils/liquid-autocomplete'; +import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; +import { capitalize } from '@/utils/string'; +import { autocompletion } from '@codemirror/autocomplete'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; + +const bodyKey = 'body'; + +const basicSetup = { + defaultKeymap: true, +}; + +export const BaseBody = () => { + const { control } = useFormContext(); + const { step } = useWorkflow(); + const variables = useMemo(() => (step ? parseStepVariablesToLiquidVariables(step.variables) : []), [step]); + const extensions = useMemo( + () => [autocompletion({ override: [completions(variables)] }), EditorView.lineWrapping], + [variables] + ); + + return ( + ( + + + + + + + {`This supports markdown and variables, type { for more.`} + + )} + /> + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx b/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx new file mode 100644 index 00000000000..3f72d19a9ff --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { EditorView } from '@uiw/react-codemirror'; +import { useFormContext } from 'react-hook-form'; + +import { Editor } from '@/components/primitives/editor'; +import { FormControl, FormField, FormItem, FormMessage } from '@/components/primitives/form/form'; +import { InputField } from '@/components/primitives/input'; +import { completions } from '@/utils/liquid-autocomplete'; +import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; +import { capitalize } from '@/utils/string'; +import { autocompletion } from '@codemirror/autocomplete'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; + +const subjectKey = 'subject'; + +export const BaseSubject = () => { + const { control } = useFormContext(); + const { step } = useWorkflow(); + const variables = useMemo(() => (step ? parseStepVariablesToLiquidVariables(step.variables) : []), [step]); + const extensions = useMemo( + () => [autocompletion({ override: [completions(variables)] }), EditorView.lineWrapping], + [variables] + ); + + return ( + ( + + + + + + + + + )} + /> + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/common/common-custom-control-values.tsx b/apps/dashboard/src/components/workflow-editor/steps/common/common-custom-control-values.tsx new file mode 100644 index 00000000000..8e736b8d304 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/common/common-custom-control-values.tsx @@ -0,0 +1,39 @@ +import { Button } from '@/components/primitives/button'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; +import { RiCloseLine, RiEdit2Line } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; +import { CustomStepControls } from '../controls/custom-step-controls'; + +export const CommonCustomControlValues = () => { + const { step, workflow } = useWorkflow(); + const { dataSchema } = step?.controls ?? {}; + const navigate = useNavigate(); + + if (!dataSchema || !workflow) { + return null; + } + + return ( + <> +
    +
    + + Configure Template +
    + +
    + + + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx index 18d0303efad..27e630e55f2 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx @@ -10,6 +10,8 @@ import { Maily } from '@/components/workflow-editor/steps/email/maily'; import { EmailSubject } from '@/components/workflow-editor/steps/email/email-subject'; import { DigestKey } from '@/components/workflow-editor/steps/digest/digest-key'; import { DigestWindow } from '@/components/workflow-editor/steps/digest/digest-window'; +import { BaseBody } from './base/base-body'; +import { BaseSubject } from './base/base-subject'; export const getComponentByType = ({ component }: { component?: UiComponentEnum }) => { switch (component) { @@ -33,7 +35,7 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum case UiComponentEnum.DELAY_TYPE: { return ; } - case UiComponentEnum.MAILY: { + case UiComponentEnum.BLOCK_EDITOR: { return ; } case UiComponentEnum.TEXT_INLINE_LABEL: { @@ -47,6 +49,13 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum case UiComponentEnum.DIGEST_CRON: { return ; } + case UiComponentEnum.PUSH_BODY: { + return ; + } + case UiComponentEnum.PUSH_SUBJECT: { + return ; + } + default: { return null; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx index 812794ffbb7..c66bfa63ab9 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx @@ -9,7 +9,7 @@ import { WorkflowOriginEnum, WorkflowResponseDto, } from '@novu/shared'; -import { motion } from 'motion/react'; +import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useCallback, useMemo, useState, HTMLAttributes, ReactNode } from 'react'; import { useForm } from 'react-hook-form'; import { RiArrowLeftSLine, RiArrowRightSLine, RiCloseFill, RiDeleteBin2Line, RiPencilRuler2Fill } from 'react-icons/ri'; @@ -114,6 +114,8 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { const isTemplateConfigurableStep = isSupportedStep && TEMPLATE_CONFIGURABLE_STEP_TYPES.includes(step.type); const isInlineConfigurableStep = isSupportedStep && INLINE_CONFIGURABLE_STEP_TYPES.includes(step.type); + const hasCustomControls = Object.keys(step.controls.dataSchema ?? {}).length > 0 && !step.controls.uiSchema; + const isInlineConfigurableStepWithCustomControls = isInlineConfigurableStep && hasCustomControls; const onDeleteStep = () => { update({ ...workflow, steps: workflow.steps.filter((s) => s._id !== step._id) }); @@ -191,134 +193,132 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { return ( <> - - - - - - Configure Step - - - - + + + + + + + Configure Step + + + + + + - +
    + + + + ( + + Name + + + + + + + + )} + /> + ( + + Identifier + + + + + + + + + )} + /> + + + + {isInlineConfigurableStep && !hasCustomControls && } + +
    + -
    - - + {(isTemplateConfigurableStep || isInlineConfigurableStepWithCustomControls) && ( + <> - ( - - Name - - - - - - - - )} - /> - ( - - Identifier - - - - - - - - - )} - /> + + + - {isInlineConfigurableStep && } - -
    - - - {isTemplateConfigurableStep && ( - <> - - - - - - - - {firstError ? ( - <> - - - - ) : ( - Preview && ( + {firstError ? ( <> - - - + - ) - )} - - )} + ) : ( + Preview && ( + <> + + + + + + ) + )} + + )} - {!isSupportedStep && ( - <> + {!isSupportedStep && ( - - )} + )} - {!isReadOnly && ( - <> + {!isReadOnly && ( { Delete step - - )} -
    + )} +
    + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx index 157b572a024..bcdc813ae36 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import merge from 'lodash.merge'; +import isEqual from 'lodash.isequal'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { @@ -18,15 +19,17 @@ import { Form } from '@/components/primitives/form/form'; import { useFormAutosave } from '@/hooks/use-form-autosave'; import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; import { EmailTabs } from '@/components/workflow-editor/steps/email/email-tabs'; +import { CommonCustomControlValues } from './common/common-custom-control-values'; +import { PushTabs } from '@/components/workflow-editor/steps/push/push-tabs'; const STEP_TYPE_TO_TEMPLATE_FORM: Record React.JSX.Element | null> = { [StepTypeEnum.EMAIL]: EmailTabs, [StepTypeEnum.CHAT]: OtherStepTabs, [StepTypeEnum.IN_APP]: InAppTabs, [StepTypeEnum.SMS]: OtherStepTabs, - [StepTypeEnum.PUSH]: OtherStepTabs, - [StepTypeEnum.DIGEST]: () => null, - [StepTypeEnum.DELAY]: () => null, + [StepTypeEnum.PUSH]: PushTabs, + [StepTypeEnum.DIGEST]: CommonCustomControlValues, + [StepTypeEnum.DELAY]: CommonCustomControlValues, [StepTypeEnum.TRIGGER]: () => null, [StepTypeEnum.CUSTOM]: () => null, }; @@ -67,9 +70,12 @@ export const ConfigureStepTemplateForm = (props: ConfigureStepTemplateFormProps) previousData: defaultValues, form, save: (data) => { + const defaultValues = buildDefaultValuesOfDataSchema(step.controls.dataSchema ?? {}); + const isDefaultValues = isEqual(data, defaultValues); + const updateData = isDefaultValues ? null : data; // transform form fields to step update dto const updateStepData: Partial = { - controlValues: data, + controlValues: updateData, }; update(updateStepInWorkflow(workflow, step.stepId, updateStepData)); }, diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx index 1b09af1751e..d54f54d74c9 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx @@ -1,52 +1,187 @@ -import { ComponentProps, useState } from 'react'; +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { RiBookMarkedLine, RiInputField, RiQuestionLine } from 'react-icons/ri'; +import { motion } from 'motion/react'; +import { Link } from 'react-router-dom'; import { RJSFSchema } from '@rjsf/utils'; -import { RiArrowDownSLine, RiArrowUpSLine, RiInputField } from 'react-icons/ri'; import { type ControlsMetadata } from '@novu/shared'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible'; -import { JsonForm } from './json-form'; +import { Separator } from '@/components/primitives/separator'; +import { Switch } from '@/components/primitives/switch'; import { WorkflowOriginEnum } from '@/utils/enums'; import { cn } from '@/utils/ui'; +import { JsonForm } from './json-form'; +import { useSaveForm } from '@/components/workflow-editor/steps/save-form-context'; +import { useWorkflow } from '../../workflow-provider'; +import { buildDefaultValuesOfDataSchema } from '@/utils/schema'; +import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { ConfirmationModal } from '@/components/confirmation-modal'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; -type CustomStepControlsProps = ComponentProps & { +type CustomStepControlsProps = { dataSchema: ControlsMetadata['dataSchema']; origin: WorkflowOriginEnum; + className?: string; }; + +const CONTROLS_DOCS_LINK = 'https://docs.novu.co/concepts/controls'; + export const CustomStepControls = (props: CustomStepControlsProps) => { - const { className, dataSchema, origin, ...rest } = props; - const [isEditorOpen, setIsEditorOpen] = useState(true); + const { className, dataSchema, origin } = props; + const [isRestoreDefaultModalOpen, setIsRestoreDefaultModalOpen] = useState(false); + const { step } = useWorkflow(); + const [isOverridden, setIsOverridden] = useState(() => Object.keys(step?.controls.values ?? {}).length > 0); + const { reset } = useFormContext(); + const { saveForm } = useSaveForm(); - if (!dataSchema?.properties || origin !== WorkflowOriginEnum.EXTERNAL) { - return null; + if (origin !== WorkflowOriginEnum.EXTERNAL || Object.keys(dataSchema?.properties ?? {}).length === 0) { + return ( + + + + +
    + + Code-defined step controls +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    No controls defined yet

    + + Define step controls to render fields here. This lets your team collaborate and ensure changes + are validated in code. + +
    +
    +
    + + + View docs + +
    +
    +
    + + + + + ); } return ( - - -
    - - Custom step controls + + { + const defaultValues = buildDefaultValuesOfDataSchema(step?.controls.dataSchema ?? {}); + reset(defaultValues); + saveForm(true); + setIsRestoreDefaultModalOpen(false); + setIsOverridden(false); + }} + title="Proceeding will restore controls to defaults." + description="All edits will be discarded, and defaults will be restored from the code." + confirmButtonText="Proceed anyway" + /> +
    +
    + Override code defined defaults + + Code-defined defaults are read-only by default, you can override them using this toggle. +
    + { + if (!checked) { + setIsRestoreDefaultModalOpen(true); + return; + } + setIsOverridden(checked); + }} + /> +
    + - {isEditorOpen ? ( - - ) : ( - + + type="single" + defaultValue="controls" + collapsible + > + + +
    + + Code-defined step controls +
    +
    - -
    - + +
    + +
    +
    + + + + + ); +}; + +const OverrideMessage = ({ isOverridden }: { isOverridden: boolean }) => { + const fadeAnimation = { + initial: { opacity: 0, scale: 0.95 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.95 }, + transition: { duration: 0.1 }, + }; + + return ( + + {isOverridden ? ( +
    + + + Custom controls defined in the code have been overridden. Disable overrides to restore the original. +
    - - + ) : ( + + Learn more about code-defined controls. + + )} +
    ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx index dac959e0f83..3743adadeee 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx @@ -9,7 +9,7 @@ import { JSON_SCHEMA_FORM_ID_DELIMITER, UI_SCHEMA, WIDGETS } from './template-ut type JsonFormProps = Pick< FormProps, - 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName' | 'onError' + 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName' | 'onError' | 'disabled' > & { variables?: string[]; }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx index 0dbc3f6b407..a886c6852d7 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx @@ -47,7 +47,7 @@ export const DelayAmount = () => { fields={{ inputKey: `controlValues.${amountKey}`, selectKey: `controlValues.${unitKey}` }} options={unitOptions} defaultOption={defaultUnitOption} - onValueChange={saveForm} + onValueChange={() => saveForm()} min={minAmountValue} />
    diff --git a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx index e47801bcb56..94d29ff4e5e 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx @@ -2,7 +2,6 @@ import { UiSchemaGroupEnum } from '@novu/shared'; import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { SidebarContent } from '@/components/side-navigation/sidebar'; import { Separator } from '@/components/primitives/separator'; -import { CustomStepControls } from '@/components/workflow-editor/steps/controls/custom-step-controls'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; const amountKey = 'amount'; @@ -11,7 +10,7 @@ const typeKey = 'type'; export const DelayControlValues = () => { const { workflow, step } = useWorkflow(); - const { uiSchema, dataSchema } = step?.controls ?? {}; + const { uiSchema } = step?.controls ?? {}; if (!uiSchema || !workflow || uiSchema?.group !== UiSchemaGroupEnum.DELAY) { return null; @@ -27,7 +26,6 @@ export const DelayControlValues = () => { )} - ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx index 064c1863ac7..edfe866de89 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx @@ -1,5 +1,4 @@ import { UiSchemaGroupEnum } from '@novu/shared'; - import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { Separator } from '@/components/primitives/separator'; diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx index b1e79681515..c69f198e37c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx @@ -99,7 +99,7 @@ export const DigestWindow = () => { options={unitOptions} defaultOption={defaultUnitOption} className="w-min [&_input]:!w-[3ch] [&_input]:!min-w-[3ch]" - onValueChange={saveForm} + onValueChange={() => saveForm()} showError={false} min={minAmountValue} /> diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx index 72dbe9297af..963393ae4bb 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx @@ -6,7 +6,6 @@ import { usePreviewStep } from '@/hooks/use-preview-step'; import { EmailPreviewHeader } from '@/components/workflow-editor/steps/email/email-preview'; import { Separator } from '@/components/primitives/separator'; import { Skeleton } from '@/components/primitives/skeleton'; -import { ChannelTypeEnum } from '@novu/shared'; import { cn } from '@/utils/ui'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; @@ -16,7 +15,7 @@ const MiniEmailPreview = (props: MiniEmailPreviewProps) => { return (
    @@ -65,13 +64,13 @@ export function ConfigureEmailStepPreview(props: ConfigureEmailStepPreviewProps) ); } - if (previewData?.result?.type !== ChannelTypeEnum.EMAIL) { - return No preview available; + if (previewData.result.type === 'email') { + return ( + +
    {previewData.result.preview.subject}
    +
    + ); } - return ( - -
    {previewData.result.preview.subject}
    -
    - ); + return null; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx index 904496048ea..88f42ff12d4 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx @@ -2,15 +2,17 @@ import { Separator } from '@/components/primitives/separator'; import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { EmailPreviewHeader } from '@/components/workflow-editor/steps/email/email-preview'; import { EmailTabsSection } from '@/components/workflow-editor/steps/email/email-tabs-section'; -import { type UiSchema } from '@novu/shared'; - -const subjectKey = 'subject'; -const emailEditorKey = 'emailEditor'; +import { UiSchemaGroupEnum, type UiSchema } from '@novu/shared'; type EmailEditorProps = { uiSchema: UiSchema }; export const EmailEditor = (props: EmailEditorProps) => { const { uiSchema } = props; - const { [emailEditorKey]: emailEditor, [subjectKey]: subject } = uiSchema?.properties ?? {}; + + if (uiSchema.group !== UiSchemaGroupEnum.EMAIL) { + return null; + } + + const { body, subject } = uiSchema.properties ?? {}; return (
    @@ -23,7 +25,7 @@ export const EmailEditor = (props: EmailEditorProps) => { {/* extra padding on the left to account for the drag handle */} - {emailEditor && getComponentByType({ component: emailEditor.component })} + {getComponentByType({ component: body.component })}
    ); diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx index 0d77cfc58e4..e549af18818 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx @@ -1,76 +1,36 @@ -import { Cross2Icon } from '@radix-ui/react-icons'; +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; -import { useNavigate } from 'react-router-dom'; - -import { Notification5Fill } from '@/components/icons'; -import { Button } from '@/components/primitives/button'; -import { Separator } from '@/components/primitives/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; -import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; +import { WorkflowOriginEnum } from '@novu/shared'; import { EmailEditor } from '@/components/workflow-editor/steps/email/email-editor'; import { EmailEditorPreview } from '@/components/workflow-editor/steps/email/email-editor-preview'; import { CustomStepControls } from '../controls/custom-step-controls'; -import { EmailTabsSection } from '@/components/workflow-editor/steps/email/email-tabs-section'; -import { WorkflowOriginEnum } from '@novu/shared'; -import { useState } from 'react'; - -const tabsContentClassName = 'h-full w-full overflow-y-auto data-[state=inactive]:hidden'; +import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; +import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; export const EmailTabs = (props: StepEditorProps) => { const { workflow, step } = props; const { dataSchema, uiSchema } = step.controls; const form = useFormContext(); - const navigate = useNavigate(); const [tabsValue, setTabsValue] = useState('editor'); - return ( - -
    -
    - - Configure Template -
    - - - - Editor - - - - Preview - - + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; + const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + + const editorContent = ( + <> + {isNovuCloud && } + {isExternal && } + + ); + + const previewContent = ; - -
    - - - {workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema && } - {workflow.origin === WorkflowOriginEnum.EXTERNAL && ( - - - - )} - - - {tabsValue === 'preview' && ( - - )} - - -
    + return ( + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx index bf320de8f08..0e172707647 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx @@ -24,8 +24,6 @@ import type { Editor as TiptapEditor } from '@tiptap/core'; import { HTMLAttributes, useMemo, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -const bodyKey = 'emailEditor'; - type MailyProps = HTMLAttributes; export const Maily = (props: MailyProps) => { const { className, ...rest } = props; @@ -37,7 +35,7 @@ export const Maily = (props: MailyProps) => { return ( { return ( <> diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx index fa9189898ba..dacfe550a16 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx @@ -60,7 +60,7 @@ export const InAppEditorPreview = ({ workflow, step, formValues }: InAppEditorPr
    - In-app template editor + In-App template editor
    diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx index d9095a049d5..7c549ca8166 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx @@ -12,8 +12,8 @@ const redirectKey = 'redirect'; const primaryActionKey = 'primaryAction'; const secondaryActionKey = 'secondaryAction'; -export const InAppEditor = ({ uiSchema }: { uiSchema?: UiSchema }) => { - if (!uiSchema || uiSchema?.group !== UiSchemaGroupEnum.IN_APP) { +export const InAppEditor = ({ uiSchema }: { uiSchema: UiSchema }) => { + if (uiSchema.group !== UiSchemaGroupEnum.IN_APP) { return null; } @@ -31,7 +31,7 @@ export const InAppEditor = ({ uiSchema }: { uiSchema?: UiSchema }) => {
    - In-app template editor + In-App template editor
    {(avatar || subject) && ( diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx index 3b1e6ef329a..2ee9cf46e3f 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx @@ -1,69 +1,36 @@ -import { Cross2Icon } from '@radix-ui/react-icons'; +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; -import { useNavigate } from 'react-router-dom'; - -import { Notification5Fill } from '@/components/icons'; -import { Button } from '@/components/primitives/button'; -import { Separator } from '@/components/primitives/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { InAppEditor } from '@/components/workflow-editor/steps/in-app/in-app-editor'; import { InAppEditorPreview } from '@/components/workflow-editor/steps/in-app/in-app-editor-preview'; import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; -import { InAppTabsSection } from '@/components/workflow-editor/steps/in-app/in-app-tabs-section'; - -const tabsContentClassName = 'h-full w-full overflow-y-auto'; +import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; +import { WorkflowOriginEnum } from '@/utils/enums'; export const InAppTabs = (props: StepEditorProps) => { const { workflow, step } = props; const { dataSchema, uiSchema } = step.controls; const form = useFormContext(); - const navigate = useNavigate(); + const [tabsValue, setTabsValue] = useState('editor'); - return ( - -
    -
    - - Configure Template -
    - - - - Editor - - - - Preview - - + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; + const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; - -
    - - - - - - - - - - - -
    + const editorContent = ( + <> + {isNovuCloud && } + {isExternal && } + + ); + + const previewContent = ; + + return ( + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx index d76fd26408d..6aeb61af3d6 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx @@ -1,62 +1,26 @@ -/** - * This component is used as a placeholder for the other step configuration until the actual configuration is implemented. - */ -import { Cross2Icon } from '@radix-ui/react-icons'; -import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; -import { useNavigate } from 'react-router-dom'; - -import { Notification5Fill } from '@/components/icons'; -import { Button } from '@/components/primitives/button'; -import { Separator } from '@/components/primitives/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; -import { CustomStepControls } from '@/components/workflow-editor/steps/controls/custom-step-controls'; -import type { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; - -const tabsContentClassName = 'h-full w-full px-3 py-3.5 overflow-y-auto'; +import { useState } from 'react'; +import { CustomStepControls } from './controls/custom-step-controls'; +import { TemplateTabs } from './template-tabs'; +import type { StepEditorProps } from './configure-step-template-form'; export const OtherStepTabs = ({ workflow, step }: StepEditorProps) => { const { dataSchema } = step.controls; - const navigate = useNavigate(); + const [tabsValue, setTabsValue] = useState('editor'); - return ( - -
    -
    - - Configure Template -
    - - - - Editor - - - - Preview - - + const editorContent = ( +
    + +
    + ); + + const previewContent = null; - -
    - - -
    - -
    -
    - -
    + return ( + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx new file mode 100644 index 00000000000..5604eb2a62b --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx @@ -0,0 +1,28 @@ +import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; +import { PushTabsSection } from '@/components/workflow-editor/steps/push/push-tabs-section'; +import { type UiSchema } from '@novu/shared'; +import { RiCellphoneFill } from 'react-icons/ri'; + +const subjectKey = 'subject'; +const bodyKey = 'body'; + +type PushEditorProps = { uiSchema: UiSchema }; +export const PushEditor = (props: PushEditorProps) => { + const { uiSchema } = props; + const { [bodyKey]: body, [subjectKey]: subject } = uiSchema?.properties ?? {}; + + return ( +
    + +
    + + Push template editor +
    +
    + {getComponentByType({ component: subject.component })} + {getComponentByType({ component: body.component })} +
    +
    +
    + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs-section.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs-section.tsx new file mode 100644 index 00000000000..06a0f74837a --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs-section.tsx @@ -0,0 +1,8 @@ +import { cn } from '@/utils/ui'; +import { HTMLAttributes } from 'react'; + +type PushTabsSectionProps = HTMLAttributes; +export const PushTabsSection = (props: PushTabsSectionProps) => { + const { className, ...rest } = props; + return
    ; +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx new file mode 100644 index 00000000000..44bde28b18b --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react'; +import { WorkflowOriginEnum } from '@novu/shared'; +import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; +import { PushEditor } from '@/components/workflow-editor/steps/push/push-editor'; +import { CustomStepControls } from '../controls/custom-step-controls'; +import { TemplateTabs } from '../template-tabs'; + +export const PushTabs = (props: StepEditorProps) => { + const { workflow, step } = props; + const { dataSchema, uiSchema } = step.controls; + const [tabsValue, setTabsValue] = useState('editor'); + + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; + const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + + const editorContent = ( + <> + {isNovuCloud && } + {isExternal && } + + ); + + const previewContent = <>TODO; + + return ( + + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts b/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts index 909068ef67d..df9025bd11a 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts +++ b/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts @@ -1,7 +1,7 @@ import React from 'react'; type SaveFormContextValue = { - saveForm: () => Promise; + saveForm: (forceSubmit?: boolean) => Promise; }; export const SaveFormContext = React.createContext({} as SaveFormContextValue); diff --git a/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx b/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx index 73bc29cec45..ab0845004a9 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx @@ -34,7 +34,7 @@ const STEP_TYPE_TO_SKELETON_CONTENT: Record React.J <>
    - In-app template editor + In-App template editor
    diff --git a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx new file mode 100644 index 00000000000..3f3b31fdea3 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx @@ -0,0 +1,62 @@ +import { Cross2Icon } from '@radix-ui/react-icons'; +import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; + +import { Notification5Fill } from '@/components/icons'; +import { Button } from '@/components/primitives/button'; +import { Separator } from '@/components/primitives/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; + +interface TemplateTabsProps { + editorContent: React.ReactNode; + previewContent?: React.ReactNode; + tabsValue: string; + onTabChange: (tab: string) => void; +} + +export const TemplateTabs = ({ editorContent, previewContent, tabsValue, onTabChange }: TemplateTabsProps) => { + const navigate = useNavigate(); + + return ( + +
    +
    + + Configure Template +
    + + + + Editor + + + + Preview + + + + +
    + + + {editorContent} + + + {previewContent} + + +
    + ); +}; diff --git a/apps/dashboard/src/context/identity-provider.tsx b/apps/dashboard/src/context/identity-provider.tsx index 0e5cc9ae50e..9bdea043de0 100644 --- a/apps/dashboard/src/context/identity-provider.tsx +++ b/apps/dashboard/src/context/identity-provider.tsx @@ -26,6 +26,8 @@ export function IdentityProvider({ children }: { children: React.ReactNode }) { id: currentUser._id, organizationId: currentOrganization._id, organizationName: currentOrganization.name, + organizationTier: currentOrganization.apiServiceLevel, + organizationCreatedAt: currentOrganization.createdAt, }); if (ldClient) { @@ -35,6 +37,7 @@ export function IdentityProvider({ children }: { children: React.ReactNode }) { key: currentOrganization._id, name: currentOrganization.name, createdAt: currentOrganization.createdAt, + tier: currentOrganization.apiServiceLevel, }, user: { key: currentUser._id, diff --git a/apps/dashboard/src/hooks/use-form-autosave.ts b/apps/dashboard/src/hooks/use-form-autosave.ts index f8d892fc2e6..d261b5159b1 100644 --- a/apps/dashboard/src/hooks/use-form-autosave.ts +++ b/apps/dashboard/src/hooks/use-form-autosave.ts @@ -20,13 +20,16 @@ export function useFormAutosave, T extends Fie const formRef = useDataRef(propsForm); const onSave = useCallback( - async (data: T) => { + async (data: T, options?: { forceSubmit?: boolean }) => { + if (isReadOnly) { + return; + } // use the form reference instead of destructuring the props to avoid stale closures const form = formRef.current; const dirtyFields = form.formState.dirtyFields; // somehow the form isDirty flag is lost on first blur that why we fallback to dirtyFields const isDirty = form.formState.isDirty || Object.keys(dirtyFields).length > 0; - if (!isDirty || isReadOnly) { + if (!isDirty && !options?.forceSubmit) { return; } // manually trigger the validation of the form @@ -60,14 +63,14 @@ export function useFormAutosave, T extends Fie ); // flush the form updates right away - const saveForm = (): Promise => { + const saveForm = (forceSubmit: boolean = false): Promise => { return new Promise((resolve) => { // await for the state to be updated setTimeout(async () => { // use the form reference instead of destructuring the props to avoid stale closures const form = formRef.current; const values = form.getValues(); - await onSave(values); + await onSave(values, { forceSubmit }); resolve(); }, 0); diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 0df18281db4..941734e8455 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -30,6 +30,7 @@ import { ChannelPreferences } from './components/workflow-editor/channel-prefere import { FeatureFlagsProvider } from './context/feature-flags-provider'; import { ConfigureStep } from '@/components/workflow-editor/steps/configure-step'; import { ConfigureStepTemplate } from '@/components/workflow-editor/steps/configure-step-template'; +import { RedirectToLegacyStudioAuth } from './pages/redirect-to-legacy-studio-auth'; initializeSentry(); overrideZodErrorMap(); @@ -157,6 +158,10 @@ const router = createBrowserRouter([ path: ROUTES.SETTINGS_BILLING, element: , }, + { + path: ROUTES.LOCAL_STUDIO_AUTH, + element: , + }, { path: '*', element: , diff --git a/apps/dashboard/src/pages/redirect-to-legacy-studio-auth.tsx b/apps/dashboard/src/pages/redirect-to-legacy-studio-auth.tsx new file mode 100644 index 00000000000..5974562af7b --- /dev/null +++ b/apps/dashboard/src/pages/redirect-to-legacy-studio-auth.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { LEGACY_ROUTES } from '@/utils/routes'; + +export const RedirectToLegacyStudioAuth = () => { + useEffect(() => { + const currentSearch = window.location.search; + const redirectUrl = `${LEGACY_ROUTES.LOCAL_STUDIO_AUTH}${currentSearch}&studio_path_hint=/legacy/studio`; + window.location.href = redirectUrl; + }, []); + + return null; +}; diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 389a75bec21..a4341e938c0 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -8,6 +8,7 @@ export const ROUTES = { INBOX_EMBED: '/onboarding/inbox/embed', INBOX_EMBED_SUCCESS: '/onboarding/inbox/success', ROOT: '/', + LOCAL_STUDIO_AUTH: '/local-studio/auth', ENV: '/env', SETTINGS: '/settings', SETTINGS_ACCOUNT: '/settings/account', @@ -40,4 +41,5 @@ export const LEGACY_ROUTES = { SETTINGS: '/legacy/manage-account/user-profile', EDIT_WORKFLOW: '/legacy/workflows/edit/:workflowId', TEST_WORKFLOW: '/legacy/workflows/edit/:workflowId/test-workflow', + LOCAL_STUDIO_AUTH: '/legacy/local-studio/auth', }; diff --git a/apps/web/src/components/nav/RootNavMenu.tsx b/apps/web/src/components/nav/RootNavMenu.tsx index b87b8fb3463..e775d660b4c 100644 --- a/apps/web/src/components/nav/RootNavMenu.tsx +++ b/apps/web/src/components/nav/RootNavMenu.tsx @@ -48,6 +48,7 @@ export const RootNavMenu: React.FC = () => { const { updateOnboardingStatus, showOnboarding, isLoading: isLoadingOnboardingStatus } = useUserOnboardingStatus(); const { readonly: isEnvReadonly, environment } = useEnvironment(); const isV2Enabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_ENABLED); + const isNewDashboardEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_NEW_DASHBOARD_ENABLED); const [isLocalStudioModalOpen, toggleLocalStudioModalOpen] = useToggle(); const { navigateToLocalStudio } = useNavigateToLocalStudio({ fallbackFn: toggleLocalStudioModalOpen }); @@ -159,7 +160,7 @@ export const RootNavMenu: React.FC = () => { > - {isV2Enabled ? ( + {isNewDashboardEnabled ? ( <> {!IS_SELF_HOSTED && IS_EE_AUTH_ENABLED && } diff --git a/apps/web/src/ee/billing/components/Features.tsx b/apps/web/src/ee/billing/components/Features.tsx index d5c45b3f471..1acd4212608 100644 --- a/apps/web/src/ee/billing/components/Features.tsx +++ b/apps/web/src/ee/billing/components/Features.tsx @@ -194,7 +194,7 @@ const features: Feature[] = [ label: 'Team members', values: { [SupportedPlansEnum.FREE]: { value: '3' }, - [SupportedPlansEnum.BUSINESS]: { value: '10' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Unlimited' }, [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, }, }, diff --git a/apps/web/src/ee/billing/components/HighlightsRow.tsx b/apps/web/src/ee/billing/components/HighlightsRow.tsx index 156aafcb721..874873f6b56 100644 --- a/apps/web/src/ee/billing/components/HighlightsRow.tsx +++ b/apps/web/src/ee/billing/components/HighlightsRow.tsx @@ -32,7 +32,7 @@ const highlights: PlanHighlights = { ], [ApiServiceLevelEnum.BUSINESS]: [ { text: 'Up to 250,000 events per month' }, - { text: '50 teammates' }, + { text: 'Unlimited teammates' }, { text: '90 days Activity Feed retention' }, ], [ApiServiceLevelEnum.ENTERPRISE]: [ diff --git a/apps/web/src/ee/clerk/components/UserProfileButton.tsx b/apps/web/src/ee/clerk/components/UserProfileButton.tsx index 39423224c46..6f519e78b55 100644 --- a/apps/web/src/ee/clerk/components/UserProfileButton.tsx +++ b/apps/web/src/ee/clerk/components/UserProfileButton.tsx @@ -9,14 +9,13 @@ import { WEB_APP_URL } from '../../../config'; export function UserProfileButton() { const { optIn } = useNewDashboardOptIn(); const isNewDashboardEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_NEW_DASHBOARD_ENABLED); - const isV2Enabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_ENABLED); return ( - {isNewDashboardEnabled && isV2Enabled && ( + {isNewDashboardEnabled && ( scope.setUser(null)); @@ -46,6 +48,7 @@ export function useMonitoring() { key: currentOrganization._id, name: currentOrganization.name, createdAt: currentOrganization.createdAt, + tier: currentOrganization.apiServiceLevel, }); } else { ldClient.identify({ diff --git a/apps/web/src/utils/segment.ts b/apps/web/src/utils/segment.ts index 577cd0cba70..03e8f568ecb 100644 --- a/apps/web/src/utils/segment.ts +++ b/apps/web/src/utils/segment.ts @@ -56,7 +56,7 @@ export class SegmentService { this._segment?.identify(user?._id, { email: user.email, - name: user.firstName + ' ' + user.lastName, + name: `${user.firstName} ${user.lastName}`, firstName: user.firstName, lastName: user.lastName, avatar: user.profilePicture, diff --git a/docker/community/docker-compose.yml b/docker/community/docker-compose.yml index 979c8f1a749..747e56446af 100644 --- a/docker/community/docker-compose.yml +++ b/docker/community/docker-compose.yml @@ -46,7 +46,7 @@ services: start_period: 60s api: - image: 'ghcr.io/novuhq/novu/api:2.0.0' + image: 'ghcr.io/novuhq/novu/api:2.1.0' depends_on: mongodb: condition: service_healthy @@ -87,10 +87,10 @@ services: NEW_RELIC_LICENSE_KEY: ${NEW_RELIC_LICENSE_KEY} API_CONTEXT_PATH: ${API_CONTEXT_PATH} ports: - - '3000:3000' + - ${API_PORT}:${API_PORT} worker: - image: 'ghcr.io/novuhq/novu/worker:2.0.0' + image: 'ghcr.io/novuhq/novu/worker:2.1.0' depends_on: mongodb: condition: service_healthy @@ -127,7 +127,7 @@ services: MULTICAST_QUEUE_CHUNK_SIZE: ${MULTICAST_QUEUE_CHUNK_SIZE} ws: - image: 'ghcr.io/novuhq/novu/ws:2.0.0' + image: 'ghcr.io/novuhq/novu/ws:2.1.0' depends_on: mongodb: condition: service_healthy @@ -153,10 +153,10 @@ services: NEW_RELIC_APP_NAME: ${NEW_RELIC_APP_NAME} NEW_RELIC_LICENSE_KEY: ${NEW_RELIC_LICENSE_KEY} ports: - - '3002:3002' + - ${WS_PORT}:${WS_PORT} web: - image: 'ghcr.io/novuhq/novu/web:2.0.0' + image: 'ghcr.io/novuhq/novu/web:2.1.0' depends_on: - api - worker diff --git a/libs/application-generic/src/usecases/delete-control-values/delete-control-values.command.ts b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.command.ts new file mode 100644 index 00000000000..3ba499810f0 --- /dev/null +++ b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.command.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { EnvironmentWithUserCommand } from '../../commands'; + +export class DeleteControlValuesCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + readonly workflowId: string; + + @IsString() + @IsNotEmpty() + readonly stepId: string; +} diff --git a/libs/application-generic/src/usecases/delete-control-values/delete-control-values.usecase.ts b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.usecase.ts new file mode 100644 index 00000000000..2e73b7bd3e9 --- /dev/null +++ b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.usecase.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { ControlValuesRepository } from '@novu/dal'; +import { ControlValuesLevelEnum } from '@novu/shared'; +import { DeleteControlValuesCommand } from './delete-control-values.command'; +import { InstrumentUsecase } from '../../instrumentation'; + +@Injectable() +export class DeleteControlValuesUseCase { + constructor(private controlValuesRepository: ControlValuesRepository) {} + + @InstrumentUsecase() + public async execute(command: DeleteControlValuesCommand): Promise { + await this.controlValuesRepository.delete({ + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _workflowId: command.workflowId, + _stepId: command.stepId, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }); + } +} diff --git a/libs/application-generic/src/usecases/delete-control-values/index.ts b/libs/application-generic/src/usecases/delete-control-values/index.ts new file mode 100644 index 00000000000..57965d1a57c --- /dev/null +++ b/libs/application-generic/src/usecases/delete-control-values/index.ts @@ -0,0 +1,2 @@ +export * from './delete-control-values.usecase'; +export * from './delete-control-values.command'; diff --git a/libs/application-generic/src/usecases/index.ts b/libs/application-generic/src/usecases/index.ts index 93003165c0a..dcc91021c17 100644 --- a/libs/application-generic/src/usecases/index.ts +++ b/libs/application-generic/src/usecases/index.ts @@ -48,3 +48,4 @@ export * from './get-preferences'; export * from './delete-preferences'; export * from './get-decrypted-secret-key'; export * from './tier-restrictions-validate'; +export * from './delete-control-values'; diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts index 11f207996b1..a10c9bfa848 100644 --- a/libs/application-generic/src/utils/index.ts +++ b/libs/application-generic/src/utils/index.ts @@ -12,3 +12,4 @@ export * from './subscriber'; export * from './variants'; export * from './deepmerge'; export * from './generate-id'; +export * from './sanitize-preview-control-values'; diff --git a/libs/application-generic/src/utils/sanitize-preview-control-values.ts b/libs/application-generic/src/utils/sanitize-preview-control-values.ts new file mode 100644 index 00000000000..03d19c4e6fc --- /dev/null +++ b/libs/application-generic/src/utils/sanitize-preview-control-values.ts @@ -0,0 +1,222 @@ +const EMPTY_TIP_TAP_OBJECT = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: 'left' }, + content: [{ type: 'text', text: ' ' }], + }, + ], +}); +const WHITESPACE = ' '; + +type Redirect = { + url: string; + target: '_self' | '_blank' | '_parent' | '_top' | '_unfencedTop'; +}; + +type Action = { + label?: string; + redirect?: Redirect; +}; + +type LookBackWindow = { + amount: number; + unit: string; +}; + +function sanitizeRedirect(redirect: Redirect) { + if (!redirect.url || !redirect.target) { + return undefined; + } + + return { + url: redirect.url || 'https://example.com', + target: redirect.target || '_self', + }; +} + +function sanitizeAction(action: Action) { + if (!action?.label) { + return undefined; + } + + return { + label: action.label, + redirect: sanitizeRedirect(action.redirect), + }; +} + +function sanitizeInApp(controlValues: Record) { + if (!controlValues) return controlValues; + + const normalized: Record = { + subject: controlValues.subject || null, + body: + (controlValues.body as string)?.length === 0 + ? WHITESPACE + : controlValues.body, + avatar: controlValues.avatar || null, + primaryAction: null, + secondaryAction: null, + redirect: null, + data: controlValues.data || null, + }; + + if (controlValues.primaryAction) { + normalized.primaryAction = sanitizeAction( + controlValues.primaryAction as Action, + ); + } + + if (controlValues.secondaryAction) { + normalized.secondaryAction = sanitizeAction( + controlValues.secondaryAction as Action, + ); + } + + if (controlValues.redirect) { + normalized.redirect = sanitizeRedirect(controlValues.redirect as Redirect); + } + + if (typeof normalized === 'object' && normalized !== null) { + return Object.fromEntries( + Object.entries(normalized).filter(([_, value]) => value !== null), + ); + } + + return normalized; +} + +function sanitizeEmail(controlValues: Record) { + if (!controlValues) return controlValues; + + const emailControls: Record = {}; + + /* + * if (controlValues.body != null) { + * emailControls.body = controlValues.body || ''; + * } + */ + emailControls.subject = controlValues.subject || ''; + emailControls.body = controlValues.body || EMPTY_TIP_TAP_OBJECT; + emailControls.data = controlValues.data || null; + + return emailControls; +} + +function sanitizeSms(controlValues: Record) { + if (!controlValues) return controlValues; + + return { + body: controlValues.body || '', + data: controlValues.data || null, + }; +} + +function sanitizePush(controlValues: Record) { + if (!controlValues) return controlValues; + + const mappedValues = { + subject: controlValues.subject || '', + body: controlValues.body || '', + data: controlValues.data || null, + }; + + if (typeof mappedValues === 'object' && mappedValues !== null) { + return Object.fromEntries( + Object.entries(mappedValues).filter(([_, value]) => value !== null), + ); + } + + return mappedValues; +} + +function sanitizeChat(controlValues: Record) { + if (!controlValues) return controlValues; + + return { + body: controlValues.body || '', + data: controlValues.data || null, + }; +} + +function sanitizeDigest(controlValues: Record) { + if (!controlValues) return controlValues; + + const mappedValues = { + cron: controlValues.cron || '', + amount: controlValues.amount || 0, + unit: controlValues.unit || '', + digestKey: controlValues.digestKey || '', + data: controlValues.data || null, + lookBackWindow: controlValues.lookBackWindow + ? { + amount: (controlValues.lookBackWindow as LookBackWindow).amount || 0, + unit: (controlValues.lookBackWindow as LookBackWindow).unit || '', + } + : null, + }; + + if (typeof mappedValues === 'object' && mappedValues !== null) { + return Object.fromEntries( + Object.entries(mappedValues).filter(([_, value]) => value !== null), + ); + } + + return mappedValues; +} + +/** + * Sanitizes control values received from client-side forms into a clean minimal object. + * This function processes potentially invalid form data that may contain default/placeholder values + * and transforms it into a standardized format suitable for preview generation. + * + * @example + * // Input from form with default values: + * { + * subject: "Hello", + * body: null, + * unusedField: "test" + * } + * + * // Normalized output: + * { + * subject: "Hello", + * body: " " + * } + * + */ +export function sanitizePreviewControlValues( + controlValues: Record, + stepType: string, +): Record | null { + if (!controlValues) { + return null; + } + let normalizedValues: Record; + switch (stepType) { + case 'in_app': + normalizedValues = sanitizeInApp(controlValues); + break; + case 'email': + normalizedValues = sanitizeEmail(controlValues); + break; + case 'sms': + normalizedValues = sanitizeSms(controlValues); + break; + case 'push': + normalizedValues = sanitizePush(controlValues); + break; + case 'chat': + normalizedValues = sanitizeChat(controlValues); + break; + case 'digest': + normalizedValues = sanitizeDigest(controlValues); + break; + default: + normalizedValues = controlValues; + } + + return normalizedValues; +} diff --git a/libs/dal/src/repositories/change/change.schema.ts b/libs/dal/src/repositories/change/change.schema.ts index 915aaf96f1d..dd399cee97a 100644 --- a/libs/dal/src/repositories/change/change.schema.ts +++ b/libs/dal/src/repositories/change/change.schema.ts @@ -43,5 +43,17 @@ changeSchema.virtual('user', { justOne: true, }); +changeSchema.index({ + _environmentId: 1, +}); + +changeSchema.index({ + _creatorId: 1, +}); + +changeSchema.index({ + _entityId: 1, +}); + export const Change = (mongoose.models.Change as mongoose.Model) || mongoose.model('Change', changeSchema); diff --git a/libs/dal/src/repositories/environment/environment.schema.ts b/libs/dal/src/repositories/environment/environment.schema.ts index e9388aafa6b..8d12cd9103b 100644 --- a/libs/dal/src/repositories/environment/environment.schema.ts +++ b/libs/dal/src/repositories/environment/environment.schema.ts @@ -81,6 +81,22 @@ environmentSchema.index({ 'apiKeys.hash': 1, }); +environmentSchema.index( + { + identifier: 1, + }, + { unique: true } +); + +environmentSchema.index( + { + 'apiKeys.key': 1, + }, + { + unique: true, + } +); + export const Environment = (mongoose.models.Environment as mongoose.Model) || mongoose.model('Environment', environmentSchema); diff --git a/libs/dal/src/repositories/execution-details/execution-details.schema.ts b/libs/dal/src/repositories/execution-details/execution-details.schema.ts index b6c76e58de1..b01c5fc19e4 100644 --- a/libs/dal/src/repositories/execution-details/execution-details.schema.ts +++ b/libs/dal/src/repositories/execution-details/execution-details.schema.ts @@ -106,10 +106,6 @@ executionDetailsSchema.index({ _notificationId: 1, }); -executionDetailsSchema.index({ - _environmentId: 1, -}); - /* * This index was created to push entries to Online Archive */ diff --git a/libs/dal/src/repositories/feed/feed.schema.ts b/libs/dal/src/repositories/feed/feed.schema.ts index 7a50016b7c0..d8c93c62a24 100644 --- a/libs/dal/src/repositories/feed/feed.schema.ts +++ b/libs/dal/src/repositories/feed/feed.schema.ts @@ -26,6 +26,18 @@ const feedSchema = new Schema( schemaOptions ); +feedSchema.index({ + _organizationId: 1, +}); + +feedSchema.index({ + _environmentId: 1, +}); + +feedSchema.index({ + identifier: 1, +}); + feedSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const Feed = diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 851d823653a..2ab1d60e9bb 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -104,6 +104,10 @@ integrationSchema.index({ active: 1, }); +integrationSchema.index({ + _environmentId: 1, +}); + integrationSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const Integration = diff --git a/libs/dal/src/repositories/job/job.schema.ts b/libs/dal/src/repositories/job/job.schema.ts index b05c2ae996c..0bf13fbb44a 100644 --- a/libs/dal/src/repositories/job/job.schema.ts +++ b/libs/dal/src/repositories/job/job.schema.ts @@ -383,10 +383,6 @@ jobSchema.index({ _notificationId: 1, }); -jobSchema.index({ - _environmentId: 1, -}); - jobSchema.index( { _mergedDigestId: 1, diff --git a/libs/dal/src/repositories/layout/layout.schema.ts b/libs/dal/src/repositories/layout/layout.schema.ts index e49f9df7222..fe25645b11a 100644 --- a/libs/dal/src/repositories/layout/layout.schema.ts +++ b/libs/dal/src/repositories/layout/layout.schema.ts @@ -55,5 +55,9 @@ const layoutSchema = new Schema( layoutSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); +layoutSchema.index({ + _environmentId: 1, +}); + export const Layout = (mongoose.models.Layout as mongoose.Model) || mongoose.model('Layout', layoutSchema); diff --git a/libs/dal/src/repositories/member/member.schema.ts b/libs/dal/src/repositories/member/member.schema.ts index 3363d653fe1..9f7508661db 100644 --- a/libs/dal/src/repositories/member/member.schema.ts +++ b/libs/dal/src/repositories/member/member.schema.ts @@ -34,5 +34,21 @@ const memberSchema = new Schema( schemaOptions ); +memberSchema.index({ + _userId: 1, +}); + +memberSchema.index({ + 'invite.token': 1, +}); + +memberSchema.index({ + _organizationId: 1, +}); + +memberSchema.index({ + 'organizationId._userId._id': 1, +}); + export const Member = (mongoose.models.Member as mongoose.Model) || mongoose.model('Member', memberSchema); diff --git a/libs/dal/src/repositories/notification-group/notification-group.schema.ts b/libs/dal/src/repositories/notification-group/notification-group.schema.ts index 72460503896..915ab7fae86 100644 --- a/libs/dal/src/repositories/notification-group/notification-group.schema.ts +++ b/libs/dal/src/repositories/notification-group/notification-group.schema.ts @@ -24,6 +24,14 @@ const NotificationGroupSchema = new Schema( schemaOptions ); +NotificationGroupSchema.index({ + _organizationId: 1, +}); + +NotificationGroupSchema.index({ + _environmentId: 1, +}); + export const NotificationGroup = (mongoose.models.NotificationGroup as mongoose.Model) || mongoose.model('NotificationGroup', NotificationGroupSchema); 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 2c9a7454f3a..91bc9a74bf6 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.schema.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.schema.ts @@ -271,6 +271,11 @@ notificationTemplateSchema.index({ name: 1, }); +notificationTemplateSchema.index({ + _environmentId: 1, + 'triggers.identifier': 1, +}); + notificationTemplateSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const NotificationTemplate = diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index 8ad9bfc7387..68f0a4c87a8 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -175,7 +175,7 @@ subscriberSchema.index({ * * We can not add `deleted` field to the index the client wont be able to delete twice subscriber with the same subscriberId. */ -index( +subscriberSchema.index( { subscriberId: 1, _environmentId: 1, @@ -183,6 +183,16 @@ index( { unique: true } ); +subscriberSchema.index({ + _organizationId: 1, +}); + +subscriberSchema.index({ + _environmentId: 1, + _organizationId: 1, + deleted: 1, +}); + subscriberSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const Subscriber = diff --git a/libs/dal/src/repositories/tenant/tenant.schema.ts b/libs/dal/src/repositories/tenant/tenant.schema.ts index 56496ae18b9..952dcaeb4a4 100644 --- a/libs/dal/src/repositories/tenant/tenant.schema.ts +++ b/libs/dal/src/repositories/tenant/tenant.schema.ts @@ -43,23 +43,5 @@ tenantSchema.index({ createdAt: -1, }); -/* - * This index was initially created to optimize: - * - * Path: apps/api/src/app/tenant/usecases/create-tenant/create-tenant.usecase.ts - * Context: execute() - * Query: findOne({ - * _environmentId: command.environmentId, - * identifier: command.identifier, - * }); - */ -tenantSchema.index( - { - _environmentId: 1, - identifier: 1, - }, - { unique: true } -); - export const Tenant = (mongoose.models.Tenant as mongoose.Model) || mongoose.model('Tenant', tenantSchema); diff --git a/libs/dal/src/repositories/topic/topic-subscribers.schema.ts b/libs/dal/src/repositories/topic/topic-subscribers.schema.ts index 8d20dce1557..46786f65c7c 100644 --- a/libs/dal/src/repositories/topic/topic-subscribers.schema.ts +++ b/libs/dal/src/repositories/topic/topic-subscribers.schema.ts @@ -40,6 +40,26 @@ const topicSubscribersSchema = new Schema( schemaOptions ); +topicSubscribersSchema.index({ + _environmentId: 1, +}); + +topicSubscribersSchema.index({ + _organizationId: 1, +}); + +topicSubscribersSchema.index({ + _subscriberId: 1, +}); + +topicSubscribersSchema.index({ + _topicId: 1, +}); + +topicSubscribersSchema.index({ + topicKey: 1, +}); + export const TopicSubscribers = (mongoose.models.TopicSubscribers as mongoose.Model) || mongoose.model('TopicSubscribers', topicSubscribersSchema); diff --git a/libs/dal/src/repositories/topic/topic.schema.ts b/libs/dal/src/repositories/topic/topic.schema.ts index 72b41337fd2..6f00121cd6f 100644 --- a/libs/dal/src/repositories/topic/topic.schema.ts +++ b/libs/dal/src/repositories/topic/topic.schema.ts @@ -29,5 +29,13 @@ const topicSchema = new Schema( schemaOptions ); +topicSchema.index({ + _environmentId: 1, +}); + +topicSchema.index({ + _organizationId: 1, +}); + export const Topic = (mongoose.models.Topic as mongoose.Model) || mongoose.model('Topic', topicSchema); diff --git a/packages/shared/src/dto/workflows/control-schemas.ts b/packages/shared/src/dto/workflows/control-schemas.ts index 998fa944e7b..37a2041bd61 100644 --- a/packages/shared/src/dto/workflows/control-schemas.ts +++ b/packages/shared/src/dto/workflows/control-schemas.ts @@ -1,7 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { JSONSchemaDto } from './json-schema-dto'; - -export interface TipTapNode { +export type TipTapNode = { type?: string; attrs?: Record; content?: TipTapNode[]; @@ -12,22 +9,4 @@ export interface TipTapNode { }[]; text?: string; [key: string]: any; -} -export interface EmailStepControlSchemaDto { - emailEditor: string; - subject: string; -} - -export const EmailStepControlSchema: JSONSchemaDto = { - type: 'object', - properties: { - emailEditor: { - type: 'string', - }, - subject: { - type: 'string', - }, - }, - required: ['emailEditor', 'subject'], - additionalProperties: false, }; diff --git a/packages/shared/src/dto/workflows/preview-step-response.dto.ts b/packages/shared/src/dto/workflows/preview-step-response.dto.ts index bd25043fd1f..4b627a0474c 100644 --- a/packages/shared/src/dto/workflows/preview-step-response.dto.ts +++ b/packages/shared/src/dto/workflows/preview-step-response.dto.ts @@ -113,7 +113,7 @@ export class PreviewPayload { export class GeneratePreviewResponseDto { previewPayloadExample: PreviewPayload; - result?: + result: | { type: ChannelTypeEnum.EMAIL; preview: EmailRenderOutput; diff --git a/packages/shared/src/dto/workflows/step-content-issue.enum.ts b/packages/shared/src/dto/workflows/step-content-issue.enum.ts index 2de4cd39964..afc31add422 100644 --- a/packages/shared/src/dto/workflows/step-content-issue.enum.ts +++ b/packages/shared/src/dto/workflows/step-content-issue.enum.ts @@ -11,7 +11,3 @@ export enum StepIssueEnum { STEP_ID_EXISTS = 'STEP_ID_EXISTS', MISSING_REQUIRED_VALUE = 'MISSING_REQUIRED_VALUE', } - -export enum PreviewIssueEnum { - PREVIEW_ISSUE_REQUIRED_CONTROL_VALUE_IS_MISSING = 'PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING', -} diff --git a/packages/shared/src/dto/workflows/step.dto.ts b/packages/shared/src/dto/workflows/step.dto.ts index 156dda463e3..f3f7e2cdc00 100644 --- a/packages/shared/src/dto/workflows/step.dto.ts +++ b/packages/shared/src/dto/workflows/step.dto.ts @@ -21,12 +21,12 @@ export type StepUpdateDto = StepCreateDto & { }; export type StepCreateDto = StepDto & { - controlValues?: Record; + controlValues?: Record | null; }; export type PatchStepDataDto = { name?: string; - controlValues?: Record; + controlValues?: Record | null; }; export type StepDto = { @@ -62,10 +62,11 @@ export enum UiSchemaGroupEnum { SMS = 'SMS', CHAT = 'CHAT', PUSH = 'PUSH', + SKIP = 'SKIP', } export enum UiComponentEnum { - MAILY = 'MAILY', + BLOCK_EDITOR = 'BLOCK_EDITOR', TEXT_FULL_LINE = 'TEXT_FULL_LINE', TEXT_INLINE_LABEL = 'TEXT_INLINE_LABEL', IN_APP_BODY = 'IN_APP_BODY', @@ -84,6 +85,7 @@ export enum UiComponentEnum { CHAT_BODY = 'CHAT_BODY', PUSH_BODY = 'PUSH_BODY', PUSH_SUBJECT = 'PUSH_SUBJECT', + QUERY_EDITOR = 'QUERY_EDITOR', } export class UiSchemaProperty { diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 3cd3afcb8d7..437c7eed1f7 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -47,4 +47,5 @@ export enum FeatureFlagsKeysEnum { IS_NEW_DASHBOARD_GETTING_STARTED_ENABLED = 'IS_NEW_DASHBOARD_GETTING_STARTED_ENABLED', IS_ND_DELAY_DIGEST_EMAIL_ENABLED = 'IS_ND_DELAY_DIGEST_EMAIL_ENABLED', IS_NEW_DASHBOARD_ACTIVITY_FEED_ENABLED = 'IS_NEW_DASHBOARD_ACTIVITY_FEED_ENABLED', + IS_ND_SMS_CHAT_PUSH_ENABLED = 'IS_ND_SMS_CHAT_PUSH_ENABLED', } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192656f3944..9181613d4c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,6 +489,9 @@ importers: ioredis: specifier: 5.3.2 version: 5.3.2 + json-logic-js: + specifier: ^2.0.5 + version: 2.0.5 json-schema-to-ts: specifier: ^3.0.0 version: 3.1.0 @@ -614,6 +617,9 @@ importers: '@types/express': specifier: 4.17.17 version: 4.17.17 + '@types/json-logic-js': + specifier: ^2.0.8 + version: 2.0.8 '@types/mocha': specifier: ^10.0.8 version: 10.0.8 @@ -821,6 +827,9 @@ importers: lodash.debounce: specifier: ^4.0.8 version: 4.0.8 + lodash.isequal: + specifier: ^4.5.0 + version: 4.5.0 lodash.merge: specifier: ^4.6.2 version: 4.6.2 @@ -900,6 +909,9 @@ importers: '@types/lodash.debounce': specifier: ^4.0.9 version: 4.0.9 + '@types/lodash.isequal': + specifier: ^4.5.8 + version: 4.5.8 '@types/lodash.merge': specifier: ^4.6.6 version: 4.6.7 @@ -17275,6 +17287,9 @@ packages: '@types/jsdom@20.0.1': resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/json-logic-js@2.0.8': + resolution: {integrity: sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -17311,6 +17326,9 @@ packages: '@types/lodash.get@4.4.7': resolution: {integrity: sha512-af34Mj+KdDeuzsJBxc/XeTtOx0SZHZNLd+hdrn+PcKGQs0EG2TJTzQAOTCZTgDJCArahlCzLWSy8c2w59JRz7Q==} + '@types/lodash.isequal@4.5.8': + resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} + '@types/lodash.merge@4.6.7': resolution: {integrity: sha512-OwxUJ9E50gw3LnAefSHJPHaBLGEKmQBQ7CZe/xflHkyy/wH2zVyEIAKReHvVrrn7zKdF58p16We9kMfh7v0RRQ==} @@ -20488,7 +20506,7 @@ packages: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} compose-function@3.0.3: - resolution: {integrity: sha512-xzhzTJ5eC+gmIzvZq+C3kCJHsp9os6tJkrigDRZclyGtOKINbZtE8n1Tzmeh32jW+BUDPbvZpibwvJHBLGMVwg==} + resolution: {integrity: sha1-ntZ18TzFRQHTCVCkhv9qe6OrGF8=} compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} @@ -23392,7 +23410,7 @@ packages: optional: true fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} engines: {node: '>= 0.6'} from2@2.3.0: @@ -25705,6 +25723,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-logic-js@2.0.5: + resolution: {integrity: sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==} + json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -31818,7 +31839,7 @@ packages: engines: {node: '>=10'} serve-favicon@2.5.0: - resolution: {integrity: sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==} + resolution: {integrity: sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=} engines: {node: '>= 0.8.0'} serve-index@1.9.1: @@ -33233,7 +33254,7 @@ packages: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} toposort@2.0.2: - resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + resolution: {integrity: sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=} totalist@1.1.0: resolution: {integrity: sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==} @@ -35816,8 +35837,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36018,8 +36039,8 @@ snapshots: '@aws-crypto/sha1-browser': 3.0.0 '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-bucket-endpoint': 3.575.0 @@ -36245,11 +36266,11 @@ snapshots: - aws-crt optional: true - '@aws-sdk/client-sso-oidc@3.575.0': + '@aws-sdk/client-sso-oidc@3.575.0(@aws-sdk/client-sts@3.575.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36288,6 +36309,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.7.0 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.637.0(@aws-sdk/client-sts@3.637.0)': @@ -36672,11 +36694,11 @@ snapshots: - aws-crt optional: true - '@aws-sdk/client-sts@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)': + '@aws-sdk/client-sts@3.575.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36715,7 +36737,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.7.0 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.637.0': @@ -36945,7 +36966,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0)': dependencies: - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/credential-provider-env': 3.575.0 '@aws-sdk/credential-provider-process': 3.575.0 '@aws-sdk/credential-provider-sso': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) @@ -37256,7 +37277,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.575.0(@aws-sdk/client-sts@3.575.0)': dependencies: - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/types': 3.575.0 '@smithy/property-provider': 3.1.3 '@smithy/types': 3.3.0 @@ -37777,7 +37798,7 @@ snapshots: '@aws-sdk/token-providers@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/types': 3.575.0 '@smithy/property-provider': 3.1.3 '@smithy/shared-ini-file-loader': 3.1.4 @@ -37786,7 +37807,7 @@ snapshots: '@aws-sdk/token-providers@3.614.0(@aws-sdk/client-sso-oidc@3.575.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/types': 3.609.0 '@smithy/property-provider': 3.1.3 '@smithy/shared-ini-file-loader': 3.1.4 @@ -51317,16 +51338,16 @@ snapshots: '@rjsf/validator-ajv8@5.17.1(@rjsf/utils@5.20.0(react@18.3.1))': dependencies: '@rjsf/utils': 5.20.0(react@18.3.1) - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) lodash: 4.17.21 lodash-es: 4.17.21 '@rjsf/validator-ajv8@5.17.1(@rjsf/utils@5.20.1(react@18.3.1))': dependencies: '@rjsf/utils': 5.20.1(react@18.3.1) - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) lodash: 4.17.21 lodash-es: 4.17.21 @@ -53944,12 +53965,6 @@ snapshots: '@stdlib/utils-constructor-name': 0.0.8 '@stdlib/utils-global': 0.0.7 - '@stoplight/better-ajv-errors@1.0.3(ajv@8.12.0)': - dependencies: - ajv: 8.12.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - '@stoplight/better-ajv-errors@1.0.3(ajv@8.13.0)': dependencies: ajv: 8.13.0 @@ -54015,7 +54030,7 @@ snapshots: '@stoplight/spectral-core@1.18.3(encoding@0.1.13)': dependencies: - '@stoplight/better-ajv-errors': 1.0.3(ajv@8.12.0) + '@stoplight/better-ajv-errors': 1.0.3(ajv@8.13.0) '@stoplight/json': 3.21.0 '@stoplight/path': 1.3.2 '@stoplight/spectral-parsers': 1.0.3 @@ -54024,9 +54039,9 @@ snapshots: '@stoplight/types': 13.6.0 '@types/es-aggregate-error': 1.0.6 '@types/json-schema': 7.0.15 - ajv: 8.12.0 - ajv-errors: 3.0.0(ajv@8.12.0) - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-errors: 3.0.0(ajv@8.13.0) + ajv-formats: 2.1.1(ajv@8.13.0) es-aggregate-error: 1.0.11 jsonpath-plus: 7.1.0 lodash: 4.17.21 @@ -54128,7 +54143,7 @@ snapshots: '@stoplight/types': 13.20.0 '@stoplight/yaml': 4.2.3 '@types/node': 20.16.5 - ajv: 8.12.0 + ajv: 8.13.0 ast-types: 0.14.2 astring: 1.8.6 reserved: 0.1.2 @@ -54140,7 +54155,7 @@ snapshots: '@stoplight/spectral-rulesets@1.18.1(encoding@0.1.13)': dependencies: '@asyncapi/specs': 4.3.1 - '@stoplight/better-ajv-errors': 1.0.3(ajv@8.12.0) + '@stoplight/better-ajv-errors': 1.0.3(ajv@8.13.0) '@stoplight/json': 3.21.0 '@stoplight/spectral-core': 1.18.3(encoding@0.1.13) '@stoplight/spectral-formats': 1.6.0(encoding@0.1.13) @@ -54148,8 +54163,8 @@ snapshots: '@stoplight/spectral-runtime': 1.1.2(encoding@0.1.13) '@stoplight/types': 13.20.0 '@types/json-schema': 7.0.15 - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) json-schema-traverse: 1.0.0 lodash: 4.17.21 tslib: 2.7.0 @@ -57403,6 +57418,8 @@ snapshots: '@types/tough-cookie': 4.0.2 parse5: 7.1.2 + '@types/json-logic-js@2.0.8': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -57450,6 +57467,10 @@ snapshots: dependencies: '@types/lodash': 4.14.192 + '@types/lodash.isequal@4.5.8': + dependencies: + '@types/lodash': 4.14.192 + '@types/lodash.merge@4.6.7': dependencies: '@types/lodash': 4.14.192 @@ -59722,10 +59743,6 @@ snapshots: optionalDependencies: ajv: 8.13.0 - ajv-errors@3.0.0(ajv@8.12.0): - dependencies: - ajv: 8.12.0 - ajv-errors@3.0.0(ajv@8.13.0): dependencies: ajv: 8.13.0 @@ -70081,6 +70098,8 @@ snapshots: json-buffer@3.0.1: {} + json-logic-js@2.0.5: {} + json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {}