Skip to content

Commit

Permalink
bug(api): support previous steps
Browse files Browse the repository at this point in the history
  • Loading branch information
tatarco committed Nov 11, 2024
1 parent e07f315 commit 3c71872
Show file tree
Hide file tree
Showing 14 changed files with 383 additions and 174 deletions.
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"swagger-ui-express": "^4.4.0",
"twilio": "^4.14.1","zod": "^3.23.8",
"json-schema-to-ts": "^3.0.0",
"json-schema-generator": "^2.0.6",
"uuid": "^8.3.2"
},
"devDependencies": {
Expand All @@ -104,6 +105,7 @@
"@stoplight/spectral-cli": "^6.11.0",
"@types/bcrypt": "^3.0.0",
"@types/bull": "^3.15.8",
"@types/json-schema-generator": "^2.0.3",
"@types/chai": "^4.2.11",
"@types/express": "4.17.17",
"@types/mocha": "^10.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { workflow } from '@novu/framework/express';
import {
ActionStep,
Expand Down Expand Up @@ -41,21 +41,20 @@ export class ConstructFrameworkWorkflow {
}
}

return this.constructFrameworkWorkflow(dbWorkflow, command.action);
return this.constructFrameworkWorkflow(dbWorkflow);
}

private constructFrameworkWorkflow(newWorkflow, action) {
private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity): Workflow {
return workflow(
newWorkflow.triggers[0].identifier,
async ({ step, payload, subscriber }) => {
const fullPayloadForRender: FullPayloadForRender = { payload, subscriber, steps: {} };
for await (const staticStep of newWorkflow.steps) {
try {
const stepOutputs = await this.constructStep(step, staticStep, fullPayloadForRender);
fullPayloadForRender.steps[staticStep.stepId || staticStep._templateId] = stepOutputs;
} catch (e) {
Logger.log(`Cannot Construct Step ${staticStep.stepId || staticStep._templateId}`, e);
}
fullPayloadForRender.steps[staticStep.stepId || staticStep._templateId] = await this.constructStep(
step,
staticStep,
fullPayloadForRender
);
}
},
{
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/app/workflows-v2/generate-preview.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ describe('Generate Preview', () => {
const previewResponseDto = await generatePreview(workflowId, emailStepDatabaseId, requestDto, 'testing steps');
expect(previewResponseDto.result!.preview).to.exist;
expect(previewResponseDto.previewPayloadExample).to.exist;
console.log(previewResponseDto.previewPayloadExample);
expect(previewResponseDto.previewPayloadExample?.steps?.digeststep).to.be.ok;
});

Expand Down Expand Up @@ -258,7 +257,6 @@ describe('Generate Preview', () => {
if (!workflowResult.isSuccessResult()) {
throw new Error(`Failed to create workflow ${JSON.stringify(workflowResult.error)}`);
}
console.log(workflowResult.value);

return {
workflowId: workflowResult.value._id,
Expand Down Expand Up @@ -354,13 +352,19 @@ function buildChatControlValuesPayload() {
body: 'Hello, World! {{subscriber.firstName}}',
};
}
function buildDigestControlValuesPayload() {
return {
cron: '0 3 * * 0',
};
}

export const getTestControlValues = (stepId?: string) => ({
[StepTypeEnum.SMS]: buildSmsControlValuesPayload(),
[StepTypeEnum.EMAIL]: buildEmailControlValuesPayload(stepId) as unknown as Record<string, unknown>,
[StepTypeEnum.PUSH]: buildPushControlValuesPayload(),
[StepTypeEnum.CHAT]: buildChatControlValuesPayload(),
[StepTypeEnum.IN_APP]: buildInAppControlValues(stepId),
[StepTypeEnum.DIGEST]: buildDigestControlValuesPayload(),
});

async function assertHttpError(
Expand Down
18 changes: 16 additions & 2 deletions apps/api/src/app/workflows-v2/maily-test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export function fullCodeSnippet(stepId?: string) {
{
type: 'for',
attrs: {
each: stepId ? `steps.${stepId}.origins` : 'payload.origins',
each: stepId ? `steps.${stepId}.events` : 'payload.origins',
isUpdatingKey: false,
},
content: [
Expand Down Expand Up @@ -337,7 +337,21 @@ export function fullCodeSnippet(stepId?: string) {
{
type: 'payloadValue',
attrs: {
id: 'origin.country',
id: 'payload.country',
label: null,
},
},
{
type: 'payloadValue',
attrs: {
id: 'id',
label: null,
},
},
{
type: 'payloadValue',
attrs: {
id: 'time',
label: null,
},
},
Expand Down
20 changes: 0 additions & 20 deletions apps/api/src/app/workflows-v2/shared/build-string-schema.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
import { ActionStepEnum, actionStepSchemas, ChannelStepEnum, channelStepSchemas } from '@novu/framework/internal';
import { JSONSchema } from 'json-schema-to-ts';
import { StepTypeEnum } from '@novu/shared';

export const mapStepTypeToResult = {
[ChannelStepEnum.SMS]: channelStepSchemas[ChannelStepEnum.SMS].result,
[ChannelStepEnum.EMAIL]: channelStepSchemas[ChannelStepEnum.EMAIL].result,
[ChannelStepEnum.PUSH]: channelStepSchemas[ChannelStepEnum.PUSH].result,
[ChannelStepEnum.CHAT]: channelStepSchemas[ChannelStepEnum.CHAT].result,
[ChannelStepEnum.IN_APP]: channelStepSchemas[ChannelStepEnum.IN_APP].result,
[ActionStepEnum.DELAY]: actionStepSchemas[ActionStepEnum.DELAY].result,
[ActionStepEnum.DIGEST]: actionStepSchemas[ActionStepEnum.DIGEST].result,
};
export function computeResultSchema(stepType: StepTypeEnum, payloadSchema?: JSONSchema) {
const mapStepTypeToResult: Record<ChannelStepEnum & ActionStepEnum, JSONSchema> = {
[ChannelStepEnum.SMS]: channelStepSchemas[ChannelStepEnum.SMS].result,
[ChannelStepEnum.EMAIL]: channelStepSchemas[ChannelStepEnum.EMAIL].result,
[ChannelStepEnum.PUSH]: channelStepSchemas[ChannelStepEnum.PUSH].result,
[ChannelStepEnum.CHAT]: channelStepSchemas[ChannelStepEnum.CHAT].result,
[ChannelStepEnum.IN_APP]: channelStepSchemas[ChannelStepEnum.IN_APP].result,
[ActionStepEnum.DELAY]: actionStepSchemas[ActionStepEnum.DELAY].result,
[ActionStepEnum.DIGEST]: buildDigestResult(payloadSchema),
};

return mapStepTypeToResult[stepType];
}

function buildDigestResult(payloadSchema?: JSONSchema) {
return {
type: 'object',
properties: {
events: {
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string',
},
time: {
type: 'string',
},
payload: payloadSchema || {
type: 'object',
},
},
required: ['id', 'time', 'payload'],
additionalProperties: false,
},
},
},
required: ['events'],
additionalProperties: false,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { JSONSchema } from 'json-schema-to-ts';
import { NotificationStepEntity } from '@novu/dal';
import { JSONSchemaDto } from '@novu/shared';
import { computeResultSchema } from '../../shared';

@Injectable()
class BuildAvailableVariableSchemaCommand {
previousSteps: NotificationStepEntity[] | undefined;
payloadSchema: JSONSchema;
}

export class BuildAvailableVariableSchemaUsecase {
execute(command: BuildAvailableVariableSchemaCommand): JSONSchema {
const { previousSteps, payloadSchema } = command;

return {
type: 'object',
properties: {
subscriber: buildSubscriberSchema(),
steps: buildPreviousStepsSchema(previousSteps, payloadSchema),
payload: payloadSchema,
},
additionalProperties: false,
} as const satisfies JSONSchema;
}
}
function buildPreviousStepsSchema(previousSteps: NotificationStepEntity[] | undefined, payloadSchema?: JSONSchema) {
type StepExternalId = string;
let previousStepsProperties: Record<StepExternalId, JSONSchema> = {};

previousStepsProperties = (previousSteps || []).reduce(
(acc, step) => {
if (step.stepId && step.template?.type) {
acc[step.stepId] = computeResultSchema(step.template.type, payloadSchema);
}

return acc;
},
{} as Record<StepExternalId, JSONSchema>
);

return {
type: 'object',
properties: previousStepsProperties,
required: [],
additionalProperties: false,
description: 'Previous Steps Results',
} as const satisfies JSONSchema;
}
const buildSubscriberSchema = () =>
({
type: 'object',
description: 'Schema representing the subscriber entity',
properties: {
firstName: { type: 'string', description: "Subscriber's first name" },
lastName: { type: 'string', description: "Subscriber's last name" },
email: { type: 'string', description: "Subscriber's email address" },
phone: { type: 'string', description: "Subscriber's phone number (optional)" },
avatar: { type: 'string', description: "URL to the subscriber's avatar image (optional)" },
locale: { type: 'string', description: 'Locale for the subscriber (optional)' },
subscriberId: { type: 'string', description: 'Unique identifier for the subscriber' },
isOnline: { type: 'boolean', description: 'Indicates if the subscriber is online (optional)' },
lastOnlineAt: {
type: 'string',
format: 'date-time',
description: 'The last time the subscriber was online (optional)',
},
},
required: ['firstName', 'lastName', 'email', 'subscriberId'],
additionalProperties: false,
}) as const satisfies JSONSchemaDto;
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ControlValuesLevelEnum, StepDataDto } from '@novu/shared';
import { JSONSchema } from 'json-schema-to-ts';
import { ControlValuesRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal';
import { GetStepDataCommand } from './get-step-data.command';
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';
import { BuildAvailableVariableSchemaUsecase } from './build-available-variable-schema-usecase.service';
import { convertJsonToSchemaWithDefaults } from '../../util/jsonToSchema';

@Injectable()
export class GetStepDataUsecase {
constructor(
private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase,
private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase,
private controlValuesRepository: ControlValuesRepository
private controlValuesRepository: ControlValuesRepository,
private buildAvailableVariableSchemaUsecase: BuildAvailableVariableSchemaUsecase // Dependency injection for new use case
) {}

async execute(command: GetStepDataCommand): Promise<StepDataDto> {
const workflow = await this.fetchWorkflow(command);

const { currentStep, previousSteps } = await this.findSteps(command, workflow);
const { currentStep, previousSteps } = await this.loadStepsFromDb(command, workflow);
if (!currentStep.name || !currentStep._templateId || !currentStep.stepId) {
throw new InvalidStepException(currentStep);
}
Expand All @@ -33,7 +33,10 @@ export class GetStepDataUsecase {
uiSchema: currentStep.template?.controls?.uiSchema,
values: controlValues,
},
variables: buildVariablesSchema(previousSteps, payloadSchema),
variables: this.buildAvailableVariableSchemaUsecase.execute({
previousSteps,
payloadSchema,
}), // Use the new use case to build variables schema
name: currentStep.name,
_id: currentStep._templateId,
stepId: currentStep.stepId,
Expand All @@ -45,7 +48,7 @@ export class GetStepDataUsecase {
controlValues,
}).previewPayload.payload;

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

private async fetchWorkflow(command: GetStepDataCommand) {
Expand Down Expand Up @@ -76,7 +79,7 @@ export class GetStepDataUsecase {
return controlValuesEntity?.controls || {};
}

private async findSteps(command: GetStepDataCommand, workflow: NotificationTemplateEntity) {
private async loadStepsFromDb(command: GetStepDataCommand, workflow: NotificationTemplateEntity) {
const currentStep = workflow.steps.find(
(stepItem) => stepItem._id === command.stepId || stepItem.stepId === command.stepId
);
Expand All @@ -97,65 +100,3 @@ export class GetStepDataUsecase {
return { currentStep, previousSteps };
}
}

const buildSubscriberSchema = () =>
({
type: 'object',
description: 'Schema representing the subscriber entity',
properties: {
firstName: { type: 'string', description: "Subscriber's first name" },
lastName: { type: 'string', description: "Subscriber's last name" },
email: { type: 'string', description: "Subscriber's email address" },
phone: { type: 'string', description: "Subscriber's phone number (optional)" },
avatar: { type: 'string', description: "URL to the subscriber's avatar image (optional)" },
locale: { type: 'string', description: 'Locale for the subscriber (optional)' },
subscriberId: { type: 'string', description: 'Unique identifier for the subscriber' },
isOnline: { type: 'boolean', description: 'Indicates if the subscriber is online (optional)' },
lastOnlineAt: {
type: 'string',
format: 'date-time',
description: 'The last time the subscriber was online (optional)',
},
},
required: ['firstName', 'lastName', 'email', 'subscriberId'],
additionalProperties: false,
}) as const satisfies JSONSchema;

function buildVariablesSchema(
previousSteps: NotificationStepEntity[] | undefined,
payloadSchema: JSONSchema
): JSONSchema {
return {
type: 'object',
properties: {
subscriber: buildSubscriberSchema(),
steps: buildPreviousStepsSchema(previousSteps),
payload: payloadSchema,
},
additionalProperties: false,
} as const satisfies JSONSchema;
}

function buildPreviousStepsSchema(previousSteps: NotificationStepEntity[] | undefined) {
type StepExternalId = string;
let previousStepsProperties: Record<StepExternalId, JSONSchema> = {};

previousStepsProperties = (previousSteps || []).reduce(
(acc, step) => {
if (step.stepId && step.template?.type) {
acc[step.stepId] = mapStepTypeToResult[step.template.type];
}

return acc;
},
{} as Record<StepExternalId, JSONSchema>
);

return {
type: 'object',
properties: previousStepsProperties,
required: [],
additionalProperties: false,
description: 'Previous Steps Results',
} as const satisfies JSONSchema;
}
Loading

0 comments on commit 3c71872

Please sign in to comment.