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/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/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 9e5a372a161..41d3065de7d 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 @@ -2,9 +2,11 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@no import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import { skipControl } from './skip-control.schema'; export const EmailStepControlZodSchema = z .object({ + skip: skipControl.schema, body: z.string().optional().default(''), subject: z.string().optional().default(''), }) @@ -23,5 +25,6 @@ export const emailStepUiSchema: UiSchema = { subject: { component: UiComponentEnum.TEXT_INLINE_LABEL, }, + skip: skipControl.uiSchema.properties.skip, }, }; 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/packages/shared/src/dto/workflows/step.dto.ts b/packages/shared/src/dto/workflows/step.dto.ts index 236a2909439..f2f99bd9706 100644 --- a/packages/shared/src/dto/workflows/step.dto.ts +++ b/packages/shared/src/dto/workflows/step.dto.ts @@ -62,6 +62,7 @@ export enum UiSchemaGroupEnum { SMS = 'SMS', CHAT = 'CHAT', PUSH = 'PUSH', + SKIP = 'SKIP', } export enum UiComponentEnum { @@ -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/pnpm-lock.yaml b/pnpm-lock.yaml index 192656f3944..9bd339fcb8f 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 @@ -17275,6 +17281,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==} @@ -25705,6 +25714,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==} @@ -57403,6 +57415,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': {} @@ -70081,6 +70095,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: {}