diff --git a/apps/api/src/app/workflows-v2/shared/build-string-schema.ts b/apps/api/src/app/workflows-v2/shared/build-string-schema.ts new file mode 100644 index 00000000000..92d413018a0 --- /dev/null +++ b/apps/api/src/app/workflows-v2/shared/build-string-schema.ts @@ -0,0 +1,20 @@ +import { JSONSchema } from 'json-schema-to-ts'; + +/** + * Builds a JSON schema object where each variable becomes a string property. + */ +export function buildJSONSchema(variables: Record): JSONSchema { + const properties: Record = {}; + + for (const [variableKey, variableValue] of Object.entries(variables)) { + properties[variableKey] = { + type: 'string', + default: variableValue, + }; + } + + return { + type: 'object', + properties, + }; +} diff --git a/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.usecase.ts b/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.usecase.ts index e4f731acab7..9d8b67bb501 100644 --- a/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.usecase.ts @@ -7,6 +7,7 @@ import { mapStepTypeToResult } from '../../shared'; import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase'; import { InvalidStepException } from '../../exceptions/invalid-step.exception'; import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder'; +import { buildJSONSchema } from '../../shared/build-string-schema'; @Injectable() export class GetStepDataUsecase { @@ -39,12 +40,12 @@ export class GetStepDataUsecase { }; } - private buildPayloadSchema(controlValues: Record) { + private buildPayloadSchema(controlValues: Record) { const payloadVariables = this.buildDefaultPayloadUseCase.execute({ controlValues, }).previewPayload.payload; - return buildStringSchema(payloadVariables || {}); + return buildJSONSchema(payloadVariables || {}); } private async fetchWorkflow(command: GetStepDataCommand) { @@ -158,22 +159,3 @@ function buildPreviousStepsSchema(previousSteps: NotificationStepEntity[] | unde description: 'Previous Steps Results', } as const satisfies JSONSchema; } - -/** - * Builds a JSON schema object where each variable becomes a string property. - */ -function buildStringSchema(variables: Record): JSONSchema { - const properties: Record = {}; - - for (const [variableKey, variableValue] of Object.entries(variables)) { - properties[variableKey] = { - type: 'string', - default: variableValue, - }; - } - - return { - type: 'object', - properties, - }; -} diff --git a/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts b/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts index bf1e5d8f583..b27bc27a002 100644 --- a/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts @@ -1,57 +1,89 @@ import { JSONSchema } from 'json-schema-to-ts'; import { Injectable } from '@nestjs/common'; -import { NotificationTemplateEntity } from '@novu/dal'; -import { UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared'; +import { ControlValuesRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; +import { ControlValuesLevelEnum, StepTypeEnum, UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared'; import { WorkflowTestDataCommand } from './test-data.command'; import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase'; import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command'; - -const buildToFieldSchema = ({ user }: { user: UserSessionData }) => - ({ - type: 'object', - properties: { - subscriberId: { type: 'string', default: user._id }, - /* - * TODO: the email and phone fields should be dynamic based on the workflow steps - * if the workflow has has an email step, then email is required etc - */ - email: { type: 'string', default: user.email ?? '', format: 'email' }, - phone: { type: 'string', default: '' }, - }, - required: ['subscriberId', 'email', 'phone'], - additionalProperties: false, - }) as const satisfies JSONSchema; - -const buildPayloadSchema = () => - ({ - type: 'object', - description: 'Schema representing the workflow payload', - properties: { - /* - * TODO: the properties should be dynamic based on the workflow variables - */ - example: { type: 'string', description: 'Example field', default: 'payload.example' }, - }, - required: ['subscriberId', 'email', 'phone'], - additionalProperties: false, - }) as const satisfies JSONSchema; +import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder'; +import { buildJSONSchema } from '../../shared/build-string-schema'; @Injectable() export class WorkflowTestDataUseCase { - constructor(private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase) {} + constructor( + private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, + private controlValuesRepository: ControlValuesRepository, + private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase + ) {} async execute(command: WorkflowTestDataCommand): Promise { - const _workflowEntity: NotificationTemplateEntity | null = await this.getWorkflowByIdsUseCase.execute( + const _workflowEntity: NotificationTemplateEntity = await this.fetchWorkflow(command); + const toSchema = buildToFieldSchema({ user: command.user, steps: _workflowEntity.steps }); + const payloadSchema = await this.buildPayloadSchema(command, _workflowEntity); + + return { + to: toSchema, + payload: payloadSchema, + }; + } + + private async fetchWorkflow(command: WorkflowTestDataCommand): Promise { + return await this.getWorkflowByIdsUseCase.execute( GetWorkflowByIdsCommand.create({ ...command, identifierOrInternalId: command.identifierOrInternalId, }) ); + } - return { - to: buildToFieldSchema({ user: command.user }), - payload: buildPayloadSchema(), - }; + private async buildPayloadSchema(command: WorkflowTestDataCommand, _workflowEntity: NotificationTemplateEntity) { + let payloadVariables: Record = {}; + for (const step of _workflowEntity.steps) { + const newValues = await this.getValues(command.user, step._templateId, _workflowEntity._id); + + /* + * we need to build the payload defaults for each step, + * because of possible duplicated values (like subject, body, etc...) + */ + const currPayloadVariables = this.buildDefaultPayloadUseCase.execute({ + controlValues: newValues, + }).previewPayload.payload; + payloadVariables = { ...payloadVariables, ...currPayloadVariables }; + } + + return buildJSONSchema(payloadVariables || {}); + } + + private async getValues(user: UserSessionData, _stepId: string, _workflowId: string) { + const controlValuesEntity = await this.controlValuesRepository.findOne({ + _environmentId: user.environmentId, + _organizationId: user.organizationId, + _workflowId, + _stepId, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }); + + return controlValuesEntity?.controls || {}; } } + +const buildToFieldSchema = ({ user, steps }: { user: UserSessionData; steps: NotificationStepEntity[] }) => { + const isEmailExist = isContainsStepType(steps, StepTypeEnum.EMAIL); + const isSmsExist = isContainsStepType(steps, StepTypeEnum.SMS); + + return { + type: 'object', + properties: { + subscriberId: { type: 'string', default: user._id }, + ...(isEmailExist ? { email: { type: 'string', default: user.email ?? '', format: 'email' } } : {}), + ...(isSmsExist ? { phone: { type: 'string', default: '' } } : {}), + }, + required: ['subscriberId', ...(isEmailExist ? ['email'] : []), ...(isSmsExist ? ['phone'] : [])], + additionalProperties: false, + } as const satisfies JSONSchema; +}; + +function isContainsStepType(steps: NotificationStepEntity[], type: StepTypeEnum) { + return steps.some((step) => step.template?.type === type); +} 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 62816b8ab91..7094dd0f212 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -462,6 +462,70 @@ describe('Workflow Controller E2E API Testing', () => { }); }); + describe('Get Test Data Permutations', () => { + it('should get test data', async () => { + const steps = [ + { + ...buildEmailStep(), + controlValues: { + body: 'Welcome to our newsletter {{bodyText}}{{bodyText2}}{{payload.emailPrefixBodyText}}', + subject: 'Welcome to our newsletter {{subjectText}} {{payload.prefixSubjectText}}', + }, + }, + { ...buildInAppStep(), controlValues: { subject: 'Welcome to our newsletter {{payload.inAppSubjectText}}' } }, + ]; + const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('', { steps }); + const res = await session.testAgent.post(`${v2Prefix}/workflows`).send(createWorkflowDto); + expect(res.status).to.be.equal(201); + const workflowCreated: WorkflowResponseDto = res.body.data; + const workflowTestData = await getWorkflowTestData(workflowCreated._id); + + expect(workflowTestData).to.be.ok; + expect(workflowTestData.payload).to.deep.equal({ + type: 'object', + properties: { + emailPrefixBodyText: { + type: 'string', + default: '{{payload.emailPrefixBodyText}}', + }, + prefixSubjectText: { + type: 'string', + default: '{{payload.prefixSubjectText}}', + }, + inAppSubjectText: { + type: 'string', + default: '{{payload.inAppSubjectText}}', + }, + }, + }); + + /* + * Validate the 'to' schema + * Note: Can't use deep comparison since emails differ between local and CI environments due to user sessions + */ + const toSchema = workflowTestData.to; + if ( + typeof toSchema === 'boolean' || + typeof toSchema.properties?.subscriberId === 'boolean' || + typeof toSchema.properties?.email === 'boolean' + ) { + expect((toSchema as any).type).to.be.a('boolean'); + expect(((toSchema as any).properties?.subscriberId as any).type).to.be.a('boolean'); + expect(((toSchema as any).properties?.email as any).type).to.be.a('boolean'); + throw new Error('To schema is not a boolean'); + } + expect(toSchema.type).to.equal('object'); + expect(toSchema.properties?.subscriberId.type).to.equal('string'); + expect(toSchema.properties?.subscriberId.default).to.equal(session.user._id); + expect(toSchema.properties?.email.type).to.equal('string'); + expect(toSchema.properties?.email.format).to.equal('email'); + expect(toSchema.properties?.email.default).to.be.a('string'); + expect(toSchema.properties?.email.default).to.not.equal(''); + expect(toSchema.required).to.deep.equal(['subscriberId', 'email']); + expect(toSchema.additionalProperties).to.be.false; + }); + }); + async function updateWorkflowRest(id: string, workflow: UpdateWorkflowDto): Promise { const novuRestResult = await workflowsClient.updateWorkflow(id, workflow); if (novuRestResult.isSuccessResult()) { @@ -522,6 +586,18 @@ describe('Workflow Controller E2E API Testing', () => { return value; } + async function getWorkflowTestData(workflowId: string, envId?: string) { + const novuRestResult = await createWorkflowClient(session.serverUrl, getHeaders(envId)).getWorkflowTestData( + workflowId + ); + if (!novuRestResult.isSuccessResult()) { + throw new Error(novuRestResult.error!.responseText); + } + const { value } = novuRestResult; + + return value; + } + async function getWorkflowStepControlValues( workflow: WorkflowResponseDto, step: StepDto & { _id: string; slug: Slug; stepId: string }, diff --git a/packages/shared/src/clients/workflows-client.ts b/packages/shared/src/clients/workflows-client.ts index 55a20ac23b0..47828c16f1b 100644 --- a/packages/shared/src/clients/workflows-client.ts +++ b/packages/shared/src/clients/workflows-client.ts @@ -46,6 +46,7 @@ export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) ): Promise> => { return await baseClient.safeGet(`/v2/workflows/${workflowId}/steps/${stepId}`); }; + const deleteWorkflow = async (workflowId: string): Promise> => { return await baseClient.safeDelete(`/v2/workflows/${workflowId}`); }; diff --git a/packages/shared/src/dto/workflows/create-workflow-dto.ts b/packages/shared/src/dto/workflows/create-workflow-dto.ts index 460cb1a4f98..ad5a58b0b58 100644 --- a/packages/shared/src/dto/workflows/create-workflow-dto.ts +++ b/packages/shared/src/dto/workflows/create-workflow-dto.ts @@ -1,4 +1,3 @@ -import { IsDefined, IsNotEmpty, IsString } from 'class-validator'; import { PreferencesRequestDto, StepCreateDto, WorkflowCommonsFields } from './workflow-commons-fields'; import { WorkflowCreationSourceEnum } from '../../types';