diff --git a/apps/api/src/app/step-schemas/dtos/step-schema.dto.ts b/apps/api/src/app/step-schemas/dtos/step-schema.dto.ts index 36460ba0fbd..ff865338e91 100644 --- a/apps/api/src/app/step-schemas/dtos/step-schema.dto.ts +++ b/apps/api/src/app/step-schemas/dtos/step-schema.dto.ts @@ -1,6 +1,102 @@ import { JSONSchema } from 'json-schema-to-ts'; +import { StepType } from '@novu/shared'; + +import { ActionStepEnum, ChannelStepEnum } from '@novu/framework/internal'; + +const chatProperties = { + body: { type: 'chatBody' }, +}; + +const emailProperties = { + subject: { type: 'emailSubject' }, + body: { type: 'emailBody' }, +}; + +const inAppProperties = { + subject: { type: 'inAppSubject' }, + body: { type: 'inAppBody' }, + avatar: { type: 'inAppAvatar' }, + primaryAction: { type: 'inAppPrimaryAction' }, + secondaryAction: { type: 'inAppSecondaryAction' }, + data: { type: 'inAppData' }, + redirect: { type: 'inAppRedirect' }, +}; + +const pushProperties = { + subject: { type: 'pushSubject' }, + body: { type: 'pushBody' }, +}; + +const smsProperties = { + body: { type: 'smsBody' }, +}; + +const delayProperties = { + type: { type: 'delayType' }, + amount: { type: 'delayAmount' }, + unit: { type: 'delayUnit' }, +}; + +const digestProperties = { + amount: { type: 'digestAmount' }, + unit: { type: 'digestUnit' }, + digestKey: { type: 'digestKey' }, + lookBackWindow: { type: 'digestLookBackWindow' }, +}; + +const customProperties = {}; + +export type UiSchema = + | { + type: 'email'; + properties: typeof emailProperties; + } + | { + type: 'sms'; + properties: typeof smsProperties; + } + | { + type: 'push'; + properties: typeof pushProperties; + } + | { + type: 'chat'; + properties: typeof chatProperties; + } + | { + type: 'in_app'; + properties: typeof inAppProperties; + } + | { + type: 'delay'; + properties: typeof delayProperties; + } + | { + type: 'digest'; + properties: typeof digestProperties; + } + | { + type: 'custom'; + properties: typeof customProperties; + }; + +export const mapStepTypeToUiSchema = { + [ChannelStepEnum.SMS]: { type: 'sms', properties: smsProperties }, + [ChannelStepEnum.EMAIL]: { type: 'email', properties: emailProperties }, + [ChannelStepEnum.PUSH]: { type: 'push', properties: pushProperties }, + [ChannelStepEnum.CHAT]: { type: 'chat', properties: chatProperties }, + [ChannelStepEnum.IN_APP]: { type: 'in_app', properties: inAppProperties }, + [ActionStepEnum.DELAY]: { type: 'delay', properties: delayProperties }, + [ActionStepEnum.DIGEST]: { type: 'digest', properties: digestProperties }, + [ActionStepEnum.CUSTOM]: { type: 'custom', properties: customProperties }, +} as const satisfies Record; + +export class ControlsDto { + schema: JSONSchema; + uiSchema: UiSchema; +} export type StepSchemaDto = { - controls: JSONSchema; + controls: ControlsDto; variables: JSONSchema; }; diff --git a/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts b/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts index 7a719bf4c18..4b66f364f01 100644 --- a/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts +++ b/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { UserSession } from '@novu/testing'; import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum, WorkflowResponseDto } from '@novu/shared'; -describe('Get Step Schema - /step-schemas?workflowId=:workflowId&stepId=:stepId&stepType=:stepType (GET)', async () => { +describe('Get Step Schema - /steps?workflowId=:workflowId&stepId=:stepId&stepType=:stepType (GET)', async () => { let session: UserSession; let createdWorkflow: WorkflowResponseDto; @@ -16,64 +16,75 @@ describe('Get Step Schema - /step-schemas?workflowId=:workflowId&stepId=:stepId& it('should get step schema for in app step type', async function () { const { data } = (await getStepSchema({ session, stepType: StepTypeEnum.IN_APP })).body; - // in app output schema - expect(data.controls.type).to.equal('object'); - expect(data.controls.properties).to.have.property('subject'); - expect(data.controls.properties.subject.type).to.equal('string'); - expect(data.controls.properties).to.have.property('body'); - expect(data.controls.properties.body.type).to.equal('string'); - expect(data.controls.properties).to.have.property('avatar'); - expect(data.controls.properties.avatar.type).to.equal('string'); - expect(data.controls.properties.avatar.format).to.equal('uri'); - expect(data.controls.properties).to.have.property('primaryAction'); - expect(data.controls.properties.primaryAction.type).to.equal('object'); - expect(data.controls.properties.primaryAction.properties).to.have.property('label'); - expect(data.controls.properties.primaryAction.properties.label.type).to.equal('string'); - expect(data.controls.properties.primaryAction.properties).to.have.property('redirect'); - expect(data.controls.properties.primaryAction.properties.redirect.type).to.equal('object'); - expect(data.controls.properties.primaryAction.required).to.deep.equal(['label']); - expect(data.controls.properties).to.have.property('secondaryAction'); - expect(data.controls.properties.secondaryAction.type).to.equal('object'); - expect(data.controls.properties.secondaryAction.properties).to.have.property('label'); - expect(data.controls.properties.secondaryAction.properties.label.type).to.equal('string'); - expect(data.controls.properties.secondaryAction.properties).to.have.property('redirect'); - expect(data.controls.properties.secondaryAction.properties.redirect.type).to.equal('object'); - expect(data.controls.properties.secondaryAction.required).to.deep.equal(['label']); - expect(data.controls.properties).to.have.property('data'); - expect(data.controls.properties.data.type).to.equal('object'); - expect(data.controls.properties.data.additionalProperties).to.be.true; - expect(data.controls.properties).to.have.property('redirect'); - expect(data.controls.properties.redirect.type).to.equal('object'); - expect(data.controls.properties.redirect.properties).to.have.property('url'); - expect(data.controls.properties.redirect.properties.url.type).to.equal('string'); - expect(data.controls.properties.redirect.properties).to.have.property('target'); - expect(data.controls.properties.redirect.properties.target.type).to.equal('string'); - expect(data.controls.required).to.deep.equal(['body']); - expect(data.controls.additionalProperties).to.be.false; - expect(data.controls.description).to.not.be.empty; + const { controls, variables } = data; + const { schema, uiSchema } = controls; - expect(data.variables.type).to.equal('object'); - expect(data.variables.description).to.not.be.empty; - expect(data.variables.properties).to.have.property('subscriber'); - expect(data.variables.properties.subscriber.type).to.equal('object'); - expect(data.variables.properties.subscriber.description).to.not.be.empty; - expect(data.variables.properties.subscriber.properties).to.have.property('firstName'); - expect(data.variables.properties.subscriber.properties.firstName.type).to.equal('string'); - expect(data.variables.properties.subscriber.properties).to.have.property('lastName'); - expect(data.variables.properties.subscriber.properties.lastName.type).to.equal('string'); - expect(data.variables.properties.subscriber.properties).to.have.property('email'); - expect(data.variables.properties.subscriber.properties.email.type).to.equal('string'); - expect(data.variables.properties.subscriber.required).to.deep.equal([ + expect(schema.type).to.equal('object'); + expect(schema.properties).to.have.property('subject'); + expect(schema.properties.subject.type).to.equal('string'); + expect(schema.properties).to.have.property('body'); + expect(schema.properties.body.type).to.equal('string'); + expect(schema.properties).to.have.property('avatar'); + expect(schema.properties.avatar.type).to.equal('string'); + expect(schema.properties.avatar.format).to.equal('uri'); + expect(schema.properties).to.have.property('primaryAction'); + expect(schema.properties.primaryAction.type).to.equal('object'); + expect(schema.properties.primaryAction.properties).to.have.property('label'); + expect(schema.properties.primaryAction.properties.label.type).to.equal('string'); + expect(schema.properties.primaryAction.properties).to.have.property('redirect'); + expect(schema.properties.primaryAction.properties.redirect.type).to.equal('object'); + expect(schema.properties.primaryAction.required).to.deep.equal(['label']); + expect(schema.properties).to.have.property('secondaryAction'); + expect(schema.properties.secondaryAction.type).to.equal('object'); + expect(schema.properties.secondaryAction.properties).to.have.property('label'); + expect(schema.properties.secondaryAction.properties.label.type).to.equal('string'); + expect(schema.properties.secondaryAction.properties).to.have.property('redirect'); + expect(schema.properties.secondaryAction.properties.redirect.type).to.equal('object'); + expect(schema.properties.secondaryAction.required).to.deep.equal(['label']); + expect(schema.properties).to.have.property('data'); + expect(schema.properties.data.type).to.equal('object'); + expect(schema.properties.data.additionalProperties).to.be.true; + expect(schema.properties).to.have.property('redirect'); + expect(schema.properties.redirect.type).to.equal('object'); + expect(schema.properties.redirect.properties).to.have.property('url'); + expect(schema.properties.redirect.properties.url.type).to.equal('string'); + expect(schema.properties.redirect.properties).to.have.property('target'); + expect(schema.properties.redirect.properties.target.type).to.equal('string'); + expect(schema.required).to.deep.equal(['body']); + expect(schema.additionalProperties).to.be.false; + expect(schema.description).to.not.be.empty; + + expect(uiSchema.type).to.equal('in_app'); + expect(uiSchema.properties.subject.type).to.equal('inAppSubject'); + expect(uiSchema.properties.body.type).to.equal('inAppBody'); + expect(uiSchema.properties.avatar.type).to.equal('inAppAvatar'); + expect(uiSchema.properties.primaryAction.type).to.equal('inAppPrimaryAction'); + expect(uiSchema.properties.secondaryAction.type).to.equal('inAppSecondaryAction'); + expect(uiSchema.properties.data.type).to.equal('inAppData'); + expect(uiSchema.properties.redirect.type).to.equal('inAppRedirect'); + + expect(variables.type).to.equal('object'); + expect(variables.description).to.not.be.empty; + expect(variables.properties).to.have.property('subscriber'); + expect(variables.properties.subscriber.type).to.equal('object'); + expect(variables.properties.subscriber.description).to.not.be.empty; + expect(variables.properties.subscriber.properties).to.have.property('firstName'); + expect(variables.properties.subscriber.properties.firstName.type).to.equal('string'); + expect(variables.properties.subscriber.properties).to.have.property('lastName'); + expect(variables.properties.subscriber.properties.lastName.type).to.equal('string'); + expect(variables.properties.subscriber.properties).to.have.property('email'); + expect(variables.properties.subscriber.properties.email.type).to.equal('string'); + expect(variables.properties.subscriber.required).to.deep.equal([ 'firstName', 'lastName', 'email', 'subscriberId', ]); - expect(data.variables.properties).to.have.property('steps'); - expect(data.variables.properties.steps.type).to.equal('object'); - expect(data.variables.properties.steps.description).to.not.be.empty; - expect(data.variables.required).to.deep.equal(['subscriber']); - expect(data.variables.additionalProperties).to.be.false; + expect(variables.properties).to.have.property('steps'); + expect(variables.properties.steps.type).to.equal('object'); + expect(variables.properties.steps.description).to.not.be.empty; + expect(variables.required).to.deep.equal(['subscriber']); + expect(variables.additionalProperties).to.be.false; }); it('should get error for invalid step type', async function () { @@ -130,5 +141,5 @@ const getStepSchema = async ({ if (stepId) queryParams.append('stepId', stepId); if (stepType) queryParams.append('stepType', stepType); - return await session.testAgent.get(`/v1/step-schemas?${queryParams.toString()}`); + return await session.testAgent.get(`/v1/steps?${queryParams.toString()}`); }; diff --git a/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts b/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts index 55084ad9e1a..986489caaf7 100644 --- a/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts +++ b/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts @@ -1,12 +1,45 @@ import { ActionStepEnum, actionStepSchemas, ChannelStepEnum, channelStepSchemas } from '@novu/framework/internal'; import { EmailStepControlSchema } from '@novu/shared'; +import { ControlsDto, mapStepTypeToUiSchema } from '../../dtos/step-schema.dto'; -export const mapStepTypeToOutput = { - [ChannelStepEnum.SMS]: channelStepSchemas[ChannelStepEnum.SMS].output, - [ChannelStepEnum.EMAIL]: EmailStepControlSchema, - [ChannelStepEnum.PUSH]: channelStepSchemas[ChannelStepEnum.PUSH].output, - [ChannelStepEnum.CHAT]: channelStepSchemas[ChannelStepEnum.CHAT].output, - [ChannelStepEnum.IN_APP]: channelStepSchemas[ChannelStepEnum.IN_APP].output, - [ActionStepEnum.DELAY]: actionStepSchemas[ActionStepEnum.DELAY].output, - [ActionStepEnum.DIGEST]: actionStepSchemas[ActionStepEnum.DIGEST].output, +export const PERMISSIVE_EMPTY_SCHEMA = { + type: 'object', + properties: {}, + required: [], + additionalProperties: true, +} as const; + +export const mapStepTypeToControlSchema: Record = { + [ChannelStepEnum.SMS]: { + schema: channelStepSchemas[ChannelStepEnum.SMS].output, + uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.SMS], + }, + [ChannelStepEnum.EMAIL]: { + schema: EmailStepControlSchema, + uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.EMAIL], + }, + [ChannelStepEnum.PUSH]: { + schema: channelStepSchemas[ChannelStepEnum.PUSH].output, + uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.PUSH], + }, + [ChannelStepEnum.CHAT]: { + schema: channelStepSchemas[ChannelStepEnum.CHAT].output, + uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.CHAT], + }, + [ChannelStepEnum.IN_APP]: { + schema: channelStepSchemas[ChannelStepEnum.IN_APP].output, + uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.IN_APP], + }, + [ActionStepEnum.DELAY]: { + schema: actionStepSchemas[ActionStepEnum.DELAY].output, + uiSchema: mapStepTypeToUiSchema[ActionStepEnum.DELAY], + }, + [ActionStepEnum.DIGEST]: { + schema: actionStepSchemas[ActionStepEnum.DIGEST].output, + uiSchema: mapStepTypeToUiSchema[ActionStepEnum.DIGEST], + }, + [ActionStepEnum.CUSTOM]: { + schema: PERMISSIVE_EMPTY_SCHEMA, + uiSchema: mapStepTypeToUiSchema[ActionStepEnum.CUSTOM], + }, }; diff --git a/apps/api/src/app/step-schemas/step-schemas.controller.ts b/apps/api/src/app/step-schemas/step-schemas.controller.ts index fc16c2a3163..9583065eb35 100644 --- a/apps/api/src/app/step-schemas/step-schemas.controller.ts +++ b/apps/api/src/app/step-schemas/step-schemas.controller.ts @@ -10,7 +10,7 @@ import { GetStepSchemaUseCase } from './usecases/get-step-schema/get-step-schema import { StepSchemaDto } from './dtos/step-schema.dto'; import { ParseSlugIdPipe } from '../workflows-v2/pipes/parse-slug-id.pipe'; -@Controller('/step-schemas') +@Controller('/steps') @UserAuthentication() @UseInterceptors(ClassSerializerInterceptor) export class StepSchemasController { diff --git a/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts b/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts index 5843d006c34..ecada75985c 100644 --- a/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts +++ b/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts @@ -1,17 +1,17 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { JSONSchema } from 'json-schema-to-ts'; import { type StepType } from '@novu/framework/internal'; import { NotificationStepEntity, NotificationTemplateRepository } from '@novu/dal'; +import { StepTypeEnum } from '@novu/shared'; import { GetExistingStepSchemaCommand, GetStepSchemaCommand, GetStepTypeSchemaCommand, } from './get-step-schema.command'; -import { StepSchemaDto } from '../../dtos/step-schema.dto'; -import { mapStepTypeToOutput, mapStepTypeToResult } from '../../shared'; -import { encodeBase62 } from '../../../shared/helpers'; +import { ControlsDto, StepSchemaDto } from '../../dtos/step-schema.dto'; +import { mapStepTypeToControlSchema, mapStepTypeToResult } from '../../shared'; @Injectable() export class GetStepSchemaUseCase { @@ -19,14 +19,35 @@ export class GetStepSchemaUseCase { async execute(command: GetStepSchemaCommand): Promise { if (isGetByStepType(command)) { - return { controls: buildControlsSchema({ stepType: command.stepType }), variables: buildVariablesSchema() }; + return { + controls: buildControlsSchema({ stepType: command.stepType }), + variables: buildVariablesSchema(), + }; } if (isGetByStepId(command)) { const { currentStep, previousSteps } = await this.findSteps(command); + if (!currentStep.template?.type) { + throw new BadRequestException('No step type found'); + } + + if (!currentStep.template?.controls?.schema) { + throw new BadRequestException('No controls schema found'); + } + + if (!isStepType(currentStep.template?.type)) { + throw new BadRequestException({ + message: 'Invalid step type', + stepType: currentStep.template?.type, + }); + } + return { - controls: buildControlsSchema({ controlsSchema: currentStep.template?.controls?.schema }), + controls: buildControlsSchema({ + stepType: currentStep.template?.type, + controlsSchema: currentStep.template?.controls?.schema, + }), variables: buildVariablesSchema(previousSteps), }; } @@ -77,24 +98,32 @@ export const buildControlsSchema = ({ stepType, controlsSchema, }: { - stepType?: StepType; + stepType: StepType; controlsSchema?: JSONSchema; -}): JSONSchema => { +}): ControlsDto => { + let schemaRes: JSONSchema | null = null; if (controlsSchema && typeof controlsSchema === 'object') { - return { - ...controlsSchema, - description: 'Output of the step, including any controls defined in the Bridge App', - }; + schemaRes = controlsSchema; } if (stepType) { - return { - ...mapStepTypeToOutput[stepType], - description: 'Output of the step, including any controls defined in the Bridge App', - }; + schemaRes = mapStepTypeToControlSchema[stepType].schema; } - throw new Error('No controls schema found'); + if (!schemaRes || typeof schemaRes !== 'object') { + throw new NotFoundException({ + message: 'No controls schema found', + stepType, + }); + } + + return { + schema: { + ...schemaRes, + description: 'Output of the step, including any controls defined in the Bridge App', + }, + uiSchema: mapStepTypeToControlSchema[stepType].uiSchema, + }; }; const buildSubscriberSchema = () => @@ -158,3 +187,7 @@ function buildPreviousStepsSchema(previousSteps: NotificationStepEntity[] | unde description: 'Previous Steps Results', } as const satisfies JSONSchema; } + +function isStepType(value: string): value is StepType { + return Object.values(StepTypeEnum).includes(value as StepTypeEnum); +} 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 ffdc7da8ff8..be01fe0d4f1 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 @@ -40,7 +40,7 @@ import { StepUpsertMechanismFailedMissingIdException } from '../../exceptions/st import { toResponseWorkflowDto } from '../../mappers/notification-template-mapper'; import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase'; import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command'; -import { mapStepTypeToOutput } from '../../../step-schemas/shared'; +import { mapStepTypeToControlSchema } from '../../../step-schemas/shared'; function buildUpsertControlValuesCommand( command: UpsertWorkflowCommand, @@ -298,7 +298,7 @@ export class UpsertWorkflowUseCase { template: { type: step.type, name: step.name, - controls: foundPersistedStep?.template?.controls || { schema: mapStepTypeToOutput[step.type] }, + controls: foundPersistedStep?.template?.controls || mapStepTypeToControlSchema[step.type], content: '', }, stepId: slugify(step.name),