Skip to content

Commit

Permalink
Merge branch 'next' into start-project-update
Browse files Browse the repository at this point in the history
  • Loading branch information
scopsy committed Nov 7, 2024
2 parents 9fd3388 + 445ef50 commit 564f55b
Show file tree
Hide file tree
Showing 15 changed files with 694 additions and 132 deletions.
20 changes: 20 additions & 0 deletions apps/api/src/app/workflows-v2/shared/build-string-schema.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): JSONSchema {
const properties: Record<string, JSONSchema> = {};

for (const [variableKey, variableValue] of Object.entries(variables)) {
properties[variableKey] = {
type: 'string',
default: variableValue,
};
}

return {
type: 'object',
properties,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -39,12 +40,12 @@ export class GetStepDataUsecase {
};
}

private buildPayloadSchema(controlValues: Record<string, any>) {
private buildPayloadSchema(controlValues: Record<string, unknown>) {
const payloadVariables = this.buildDefaultPayloadUseCase.execute({
controlValues,
}).previewPayload.payload;

return buildStringSchema(payloadVariables || {});
return buildJSONSchema(payloadVariables || {});
}

private async fetchWorkflow(command: GetStepDataCommand) {
Expand Down Expand Up @@ -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<string, unknown>): JSONSchema {
const properties: Record<string, JSONSchema> = {};

for (const [variableKey, variableValue] of Object.entries(variables)) {
properties[variableKey] = {
type: 'string',
default: variableValue,
};
}

return {
type: 'object',
properties,
};
}
108 changes: 70 additions & 38 deletions apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts
Original file line number Diff line number Diff line change
@@ -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<WorkflowTestDataResponseDto> {
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<NotificationTemplateEntity> {
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<string, unknown> = {};
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);
}
76 changes: 76 additions & 0 deletions apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkflowResponseDto> {
const novuRestResult = await workflowsClient.updateWorkflow(id, workflow);
if (novuRestResult.isSuccessResult()) {
Expand Down Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@novu/react": "workspace:*",
"@novu/shared": "workspace:*",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
Expand Down
Loading

0 comments on commit 564f55b

Please sign in to comment.