From fa0c2344c324ba9a0f22d971f2a0bc6d4786cae4 Mon Sep 17 00:00:00 2001 From: GalTidhar <39020298+tatarco@users.noreply.github.com> Date: Thu, 7 Nov 2024 23:18:47 +0100 Subject: [PATCH] feat(api): Add Error Handling 2XX issues (#6884) --- .../exceptions/step-not-found-exception.ts | 5 + .../app/workflows-v2/generate-preview.e2e.ts | 24 +- .../src/app/workflows-v2/maily-test-data.ts | 900 +++++++++--------- .../mappers/notification-template-mapper.ts | 18 +- .../build-default-payload-use-case.service.ts | 10 +- .../generate-preview.usecase.ts | 35 +- .../sync-to-environment.usecase.ts | 159 ++-- .../upsert-workflow.usecase.ts | 15 +- ...ate-and-persist-workflow-issues.usecase.ts | 202 ++++ .../validate-workflow.command.ts | 8 + ...-default-control-values-usecase.service.ts | 46 + .../build-default-control-values.command.ts | 6 + apps/api/src/app/workflows-v2/util/utils.ts | 25 + .../workflows-v2/workflow.controller.e2e.ts | 202 +++- .../src/app/workflows-v2/workflow.module.ts | 12 +- apps/api/src/config/env.validators.ts | 3 +- .../notification-template.entity.ts | 9 + .../notification-template.repository.ts | 10 + .../notification-template.schema.ts | 5 + packages/framework/src/client.ts | 4 +- .../shared/src/clients/workflows-client.ts | 4 +- packages/shared/src/dto/workflows/index.ts | 3 +- .../workflows/preview-step-response.dto.ts | 13 +- .../dto/workflows/step-content-issue.enum.ts | 9 + .../dto/workflows/workflow-commons-fields.ts | 26 +- .../dto/workflows/workflow-response-dto.ts | 16 +- .../notification-template.interface.ts | 8 +- 27 files changed, 1145 insertions(+), 632 deletions(-) create mode 100644 apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-and-persist-workflow-issues.usecase.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-workflow.command.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values-usecase.service.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values.command.ts create mode 100644 apps/api/src/app/workflows-v2/util/utils.ts create mode 100644 packages/shared/src/dto/workflows/step-content-issue.enum.ts diff --git a/apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts b/apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts index bc59a05d452..0e2e8438ce5 100644 --- a/apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts +++ b/apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts @@ -10,3 +10,8 @@ export class StepMissingControlsException extends InternalServerErrorException { super({ message: 'Step cannot be found using the UUID Supplied', stepDatabaseId, step }); } } +export class StepMissingStepIdException extends InternalServerErrorException { + constructor(stepDatabaseId: string, step: any) { + super({ message: 'Step Missing StepId', stepDatabaseId, step }); + } +} diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts index 985206f2d90..04dec02caa5 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -84,7 +84,7 @@ describe('Generate Preview', () => { .exist; if (type !== StepTypeEnum.EMAIL) { - expect(previewResponseDto.result!.preview).to.deep.equal(getControlValues(stepId)[type]); + expect(previewResponseDto.result!.preview).to.deep.equal(getTestControlValues(stepId)[type]); } else { assertEmail(previewResponseDto); } @@ -99,7 +99,7 @@ describe('Generate Preview', () => { workflowId, stepDatabaseId, { - controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL], + controlValues: getTestControlValues(stepId)[StepTypeEnum.EMAIL], previewPayload: { payload: { params: { isPayedUser: 'false' } } }, }, 'email' @@ -115,7 +115,7 @@ describe('Generate Preview', () => { workflowId, stepDatabaseId, { - controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL], + controlValues: getTestControlValues(stepId)[StepTypeEnum.EMAIL], previewPayload: { payload: { params: { isPayedUser: 'true' } } }, }, 'email' @@ -131,7 +131,7 @@ describe('Generate Preview', () => { workflowId, stepDatabaseId, { - controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL], + controlValues: getTestControlValues(stepId)[StepTypeEnum.EMAIL], previewPayload: { payload: { params: { isPayedUser: true } } }, }, 'email' @@ -147,7 +147,7 @@ describe('Generate Preview', () => { workflowId, stepDatabaseId, { - controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL], + controlValues: getTestControlValues(stepId)[StepTypeEnum.EMAIL], previewPayload: { payload: { params: { isPayedUser: 'true' } } }, }, 'email' @@ -269,19 +269,19 @@ describe('Generate Preview', () => { function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto { return { - controlValues: getControlValues(stepId)[stepTypeEnum], + controlValues: getTestControlValues(stepId)[stepTypeEnum], }; } function buildDtoWithPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto { return { - controlValues: getControlValues(stepId)[stepTypeEnum], + controlValues: getTestControlValues(stepId)[stepTypeEnum], previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } }, }; } function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto { - const stepTypeToElement = getControlValues(stepId)[stepTypeEnum]; + const stepTypeToElement = getTestControlValues(stepId)[stepTypeEnum]; if (stepTypeEnum === StepTypeEnum.EMAIL) { delete stepTypeToElement.subject; } else { @@ -294,7 +294,7 @@ function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum, stepId: st }; } -function buildEmailControlValuesPayload(stepId: string): EmailStepControlSchemaDto { +function buildEmailControlValuesPayload(stepId?: string): EmailStepControlSchemaDto { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, emailEditor: JSON.stringify(fullCodeSnippet(stepId)), @@ -306,10 +306,10 @@ function buildSimpleForEmail(): EmailStepControlSchemaDto { emailEditor: JSON.stringify(forSnippet), }; } -function buildInAppControlValues(stepId: string) { +function buildInAppControlValues(stepId?: string) { return { subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, - body: 'Hello, World! {{payload.placeholder.body}}', + body: `${stepId ? `steps.${stepId}.origins` : '{{payload.origins}}'} Hello, World! {{payload.placeholder.body}}`, avatar: 'https://www.example.com/avatar.png', primaryAction: { label: '{{payload.secondaryUrl}}', @@ -354,7 +354,7 @@ function buildChatControlValuesPayload() { }; } -const getControlValues = (stepId: string) => ({ +export const getTestControlValues = (stepId?: string) => ({ [StepTypeEnum.SMS]: buildSmsControlValuesPayload(), [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload(stepId) as unknown as Record, [StepTypeEnum.PUSH]: buildPushControlValuesPayload(), diff --git a/apps/api/src/app/workflows-v2/maily-test-data.ts b/apps/api/src/app/workflows-v2/maily-test-data.ts index c0452241f44..555122376f1 100644 --- a/apps/api/src/app/workflows-v2/maily-test-data.ts +++ b/apps/api/src/app/workflows-v2/maily-test-data.ts @@ -77,494 +77,496 @@ export const forSnippet = { ], }; -export const fullCodeSnippet = (stepId) => ({ - type: 'doc', - content: [ - { - type: 'logo', - attrs: { - src: 'https://maily.to/brand/logo.png', - alt: null, - title: null, - 'maily-component': 'logo', - size: 'md', - alignment: 'left', +export function fullCodeSnippet(stepId?: string) { + return { + type: 'doc', + content: [ + { + type: 'logo', + attrs: { + src: 'https://maily.to/brand/logo.png', + alt: null, + title: null, + 'maily-component': 'logo', + size: 'md', + alignment: 'left', + }, }, - }, - { - type: 'spacer', - attrs: { - height: 'xl', + { + type: 'spacer', + attrs: { + height: 'xl', + }, }, - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, + { + type: 'heading', + attrs: { + textAlign: 'left', + level: 2, + }, + content: [ + { + type: 'text', + marks: [ + { + type: 'bold', + }, + ], + text: 'Discover Maily', + }, + ], }, - content: [ - { - type: 'text', - marks: [ - { - type: 'bold', - }, - ], - text: 'Discover Maily', + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + content: [ + { + type: 'text', + text: 'Are you ready to transform your email communication? Introducing Maily, the powerful emaly.', + }, + ], }, - content: [ - { - type: 'text', - text: 'Are you ready to transform your email communication? Introducing Maily, the powerful emaly.', + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + content: [ + { + type: 'text', + text: 'Elevate your email communication with Maily! Click below to try it out:', + }, + ], }, - content: [ - { - type: 'text', - text: 'Elevate your email communication with Maily! Click below to try it out:', + { + type: 'button', + attrs: { + text: 'Try Maily Now →', + url: '', + alignment: 'left', + variant: 'filled', + borderRadius: 'round', + buttonColor: '#000000', + textColor: '#ffffff', }, - ], - }, - { - type: 'button', - attrs: { - text: 'Try Maily Now →', - url: '', - alignment: 'left', - variant: 'filled', - borderRadius: 'round', - buttonColor: '#000000', - textColor: '#ffffff', }, - }, - { - type: 'section', - attrs: { - show: 'payload.params.isPayedUser', - borderRadius: 0, - backgroundColor: '#f7f7f7', - align: 'left', - borderWidth: 1, - borderColor: '#e2e2e2', - paddingTop: 5, - paddingRight: 5, - paddingBottom: 5, - paddingLeft: 5, - marginTop: 0, - marginRight: 0, - marginBottom: 0, - marginLeft: 0, + { + type: 'section', + attrs: { + show: 'payload.params.isPayedUser', + borderRadius: 0, + backgroundColor: '#f7f7f7', + align: 'left', + borderWidth: 1, + borderColor: '#e2e2e2', + paddingTop: 5, + paddingRight: 5, + paddingBottom: 5, + paddingLeft: 5, + marginTop: 0, + marginRight: 0, + marginBottom: 0, + marginLeft: 0, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'variable', + attrs: { + id: 'payload.hidden.section', + label: null, + fallback: 'should be the fallback value', + }, + }, + { + type: 'text', + text: ' ', + }, + { + type: 'variable', + attrs: { + id: 'subscriber.fullName', + label: null, + fallback: 'should be the fallback value', + }, + }, + ], + }, + ], }, - content: [ - { - type: 'paragraph', - attrs: { - textAlign: 'left', + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'Join our vibrant community of users and developers on GitHub, where Maily is an ', }, - content: [ - { - type: 'variable', - attrs: { - id: 'payload.hidden.section', - label: null, - fallback: 'should be the fallback value', + { + type: 'text', + marks: [ + { + type: 'link', + attrs: { + href: 'https://github.com/arikchakma/maily.to', + target: '_blank', + rel: 'noopener noreferrer nofollow', + class: null, + }, }, - }, - { - type: 'text', - text: ' ', - }, - { - type: 'variable', - attrs: { - id: 'subscriber.fullName', - label: null, - fallback: 'should be the fallback value', + { + type: 'italic', }, - }, - ], - }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + ], + text: 'open-source', + }, + { + type: 'text', + text: " project. Together, we'll shape the future of email editing.", + }, + ], }, - content: [ - { - type: 'text', - text: 'Join our vibrant community of users and developers on GitHub, where Maily is an ', + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - { - type: 'text', - marks: [ - { - type: 'link', - attrs: { - href: 'https://github.com/arikchakma/maily.to', - target: '_blank', - rel: 'noopener noreferrer nofollow', - class: null, - }, - }, - { - type: 'italic', + content: [ + { + type: 'text', + text: '@this is a placeholder value of name payload.body|| ', + }, + { + type: 'variable', + attrs: { + id: 'payload.body', + label: null, + fallback: null, }, - ], - text: 'open-source', - }, - { - type: 'text', - text: " project. Together, we'll shape the future of email editing.", - }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + }, + { + type: 'text', + text: ' |||the value should have been here', + }, + ], }, - content: [ - { - type: 'text', - text: '@this is a placeholder value of name payload.body|| ', + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - { - type: 'variable', - attrs: { - id: 'payload.body', - label: null, - fallback: null, + content: [ + { + type: 'text', + text: 'this is a regular for block showing multiple comments:', }, - }, - { - type: 'text', - text: ' |||the value should have been here', - }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + ], }, - content: [ - { - type: 'text', - text: 'this is a regular for block showing multiple comments:', + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + content: [ + { + type: 'text', + text: 'This will be two for each one in another column: ', + }, + ], }, - content: [ - { - type: 'text', - text: 'This will be two for each one in another column: ', + { + type: 'columns', + attrs: { + width: '100%', }, - ], - }, - { - type: 'columns', - attrs: { - width: '100%', - }, - content: [ - { - type: 'column', - attrs: { - columnId: '394bcc6f-c674-4d56-aced-f3f54434482e', - width: 50, - verticalAlign: 'top', - borderRadius: 0, - backgroundColor: 'transparent', - borderWidth: 0, - borderColor: 'transparent', - paddingTop: 0, - paddingRight: 0, - paddingBottom: 0, - paddingLeft: 0, - }, - content: [ - { - type: 'for', - attrs: { - each: `steps.${stepId}.origins`, - isUpdatingKey: false, - }, - content: [ - { - type: 'orderedList', - attrs: { - start: 1, - }, - content: [ - { - type: 'listItem', - attrs: { - color: null, - }, - content: [ - { - type: 'paragraph', - attrs: { - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'a list item: ', + content: [ + { + type: 'column', + attrs: { + columnId: '394bcc6f-c674-4d56-aced-f3f54434482e', + width: 50, + verticalAlign: 'top', + borderRadius: 0, + backgroundColor: 'transparent', + borderWidth: 0, + borderColor: 'transparent', + paddingTop: 0, + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0, + }, + content: [ + { + type: 'for', + attrs: { + each: stepId ? `steps.${stepId}.origins` : 'payload.origins', + isUpdatingKey: false, + }, + content: [ + { + type: 'orderedList', + attrs: { + start: 1, + }, + content: [ + { + type: 'listItem', + attrs: { + color: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - { - type: 'payloadValue', - attrs: { - id: 'origin.country', - label: null, + content: [ + { + type: 'text', + text: 'a list item: ', }, - }, - { - type: 'text', - text: ' ', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - { - type: 'column', - attrs: { - columnId: 'a61ae45e-ea27-4a2b-a356-bfad769ea50f', - width: 50, - verticalAlign: 'top', - borderRadius: 0, - backgroundColor: 'transparent', - borderWidth: 0, - borderColor: 'transparent', - paddingTop: 0, - paddingRight: 0, - paddingBottom: 0, - paddingLeft: 0, - }, - content: [ - { - type: 'for', - attrs: { - each: 'payload.students', - isUpdatingKey: false, - }, - content: [ - { - type: 'bulletList', - content: [ - { - type: 'listItem', - attrs: { - color: null, - }, - content: [ - { - type: 'paragraph', - attrs: { - textAlign: 'left', + { + type: 'payloadValue', + attrs: { + id: 'origin.country', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], }, - content: [ - { - type: 'text', - text: 'bulleted list item: ', + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'column', + attrs: { + columnId: 'a61ae45e-ea27-4a2b-a356-bfad769ea50f', + width: 50, + verticalAlign: 'top', + borderRadius: 0, + backgroundColor: 'transparent', + borderWidth: 0, + borderColor: 'transparent', + paddingTop: 0, + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0, + }, + content: [ + { + type: 'for', + attrs: { + each: 'payload.students', + isUpdatingKey: false, + }, + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + attrs: { + color: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - { - type: 'payloadValue', - attrs: { - id: 'id', - label: null, + content: [ + { + type: 'text', + text: 'bulleted list item: ', }, - }, - { - type: 'text', - text: ' and name: ', - }, - { - type: 'payloadValue', - attrs: { - id: 'name', - label: null, + { + type: 'payloadValue', + attrs: { + id: 'id', + label: null, + }, }, - }, - { - type: 'text', - text: ' ', - }, - ], - }, - ], - }, - { - type: 'listItem', - attrs: { - color: null, - }, - content: [ - { - type: 'paragraph', - attrs: { - textAlign: 'left', + { + type: 'text', + text: ' and name: ', + }, + { + type: 'payloadValue', + attrs: { + id: 'name', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], }, - content: [ - { - type: 'text', - text: 'buffer bullet item', - }, - ], + ], + }, + { + type: 'listItem', + attrs: { + color: null, }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', - }, - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'buffer bullet item', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], }, - content: [ - { - type: 'text', - text: 'This will be a nested for block', + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - ], - }, - { - type: 'for', - attrs: { - each: 'payload.food.items', - isUpdatingKey: false, }, - content: [ - { - type: 'paragraph', - attrs: { - textAlign: 'left', + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'This will be a nested for block', }, - content: [ - { - type: 'text', - text: 'this is a food item with name ', + ], + }, + { + type: 'for', + attrs: { + each: 'payload.food.items', + isUpdatingKey: false, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - { - type: 'payloadValue', - attrs: { - id: 'name', - label: null, + content: [ + { + type: 'text', + text: 'this is a food item with name ', }, - }, - { - type: 'text', - text: ' ', - }, - ], - }, - { - type: 'for', - attrs: { - each: 'payload.food.warnings', - isUpdatingKey: false, - }, - content: [ - { - type: 'bulletList', - content: [ - { - type: 'listItem', - attrs: { - color: null, - }, - content: [ - { - type: 'paragraph', - attrs: { - textAlign: 'left', - }, - content: [ - { - type: 'payloadValue', - attrs: { - id: 'header', - label: null, - }, - }, - { - type: 'text', - text: ' ', - }, - ], - }, - ], + { + type: 'payloadValue', + attrs: { + id: 'name', + label: null, }, - ], + }, + { + type: 'text', + text: ' ', + }, + ], + }, + { + type: 'for', + attrs: { + each: 'payload.food.warnings', + isUpdatingKey: false, }, - ], - }, - ], - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', - }, - }, - { - type: 'paragraph', - attrs: { - textAlign: 'left', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + attrs: { + color: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'payloadValue', + attrs: { + id: 'header', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], }, - content: [ - { - type: 'text', - text: 'Regards,', - }, - { - type: 'hardBreak', + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - { - type: 'text', - text: 'Arikko', + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', }, - ], - }, - ], -}); + content: [ + { + type: 'text', + text: 'Regards,', + }, + { + type: 'hardBreak', + }, + { + type: 'text', + text: 'Arikko', + }, + ], + }, + ], + }; +} diff --git a/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts b/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts index 34c720ef626..0c397470099 100644 --- a/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts +++ b/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts @@ -2,9 +2,11 @@ import { DEFAULT_WORKFLOW_PREFERENCES, PreferencesResponseDto, PreferencesTypeEnum, + RuntimeIssue, ShortIsPrefixEnum, StepResponseDto, StepTypeEnum, + WorkflowCreateAndUpdateKeys, WorkflowListResponseDto, WorkflowOriginEnum, WorkflowResponseDto, @@ -38,7 +40,8 @@ export function toResponseWorkflowDto( origin: computeOrigin(template), updatedAt: template.updatedAt || 'Missing Updated At', createdAt: template.createdAt || 'Missing Create At', - status: WorkflowStatusEnum.ACTIVE, + status: template.status || WorkflowStatusEnum.ACTIVE, + issues: template.issues as unknown as Record, }; } @@ -73,15 +76,16 @@ export function toWorkflowsMinifiedDtos(templates: NotificationTemplateEntity[]) return templates.map(toMinifiedWorkflowDto); } -function toStepResponseDto(step: NotificationStepEntity): StepResponseDto { - const stepName = step.name || 'Missing Name'; +function toStepResponseDto(persistedStep: NotificationStepEntity): StepResponseDto { + const stepName = persistedStep.name || 'Missing Name'; return { - _id: step._templateId, - slug: buildSlug(stepName, ShortIsPrefixEnum.STEP, step._templateId), + _id: persistedStep._templateId, + slug: buildSlug(stepName, ShortIsPrefixEnum.STEP, persistedStep._templateId), name: stepName, - stepId: step.stepId || 'Missing Step Id', - type: step.template?.type || StepTypeEnum.EMAIL, + stepId: persistedStep.stepId || 'Missing Step Id', + type: persistedStep.template?.type || StepTypeEnum.EMAIL, + issues: persistedStep.issues, } satisfies StepResponseDto; } diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts index 5bd1d6e9f7c..a71ebaf8d04 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ import { Injectable } from '@nestjs/common'; -import { ControlPreviewIssue, ControlPreviewIssueTypeEnum, PreviewPayload } from '@novu/shared'; +import { ContentIssue, PreviewPayload, StepContentIssueEnum } from '@novu/shared'; import { BaseCommand } from '@novu/application-generic'; import _ = require('lodash'); import { CreateMockPayloadForSingleControlValueUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase'; @@ -16,7 +16,7 @@ export class BuildDefaultPayloadUseCase { execute(command: BuildDefaultPayloadCommand): { previewPayload: PreviewPayload; - issues: Record; + issues: Record; } { let aggregatedDefaultValues = {}; const aggregatedDefaultValuesForControl: Record> = {}; @@ -96,13 +96,13 @@ export class BuildDefaultPayloadUseCase { private buildPayloadIssues( missingVariables: string[], variableToControlValueKeys: Record - ): Record { - const record: Record = {}; + ): Record { + const record: Record = {}; missingVariables.forEach((missingVariable) => { variableToControlValueKeys[missingVariable].forEach((controlValueKey) => { record[controlValueKey] = [ { - issueType: ControlPreviewIssueTypeEnum.MISSING_VARIABLE_IN_PAYLOAD, + issueType: StepContentIssueEnum.MISSING_VARIABLE_IN_PAYLOAD, message: `Variable payload.${missingVariable} is missing in payload`, variableName: `payload.${missingVariable}`, }, diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts index 27b9ad5c577..4781c689add 100644 --- a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts @@ -1,13 +1,12 @@ import { Injectable } from '@nestjs/common'; import { ChannelTypeEnum, - ControlPreviewIssue, - ControlPreviewIssueTypeEnum, + ContentIssue, ControlSchemas, GeneratePreviewResponseDto, JobStatusEnum, - JSONSchemaDto, PreviewPayload, + StepContentIssueEnum, StepTypeEnum, WorkflowOriginEnum, } from '@novu/shared'; @@ -16,19 +15,19 @@ import _ = require('lodash'); import { GeneratePreviewCommand } from './generate-preview-command'; import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; import { StepMissingControlsException, StepNotFoundException } from '../../exceptions/step-not-found-exception'; -import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults.usecase'; import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase'; import { OriginMissingException, StepIdMissingException } from './step-id-missing.exception'; import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder'; import { FrameworkPreviousStepsOutputState } from '../../../bridge/usecases/preview-step/preview-step.command'; +import { ValidateControlValuesAndConstructPassableStructureUsecase } from '../validate-control-values/build-default-control-values-usecase.service'; @Injectable() export class GeneratePreviewUsecase { constructor( private legacyPreviewStepUseCase: PreviewStep, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private extractDefaultsUseCase: ExtractDefaultsUsecase, - private constructPayloadUseCase: BuildDefaultPayloadUseCase + private constructPayloadUseCase: BuildDefaultPayloadUseCase, + private controlValuesUsecase: ValidateControlValuesAndConstructPassableStructureUsecase ) {} async execute(command: GeneratePreviewCommand): Promise { @@ -63,16 +62,11 @@ export class GeneratePreviewUsecase { return { previewPayload, issues }; } - 3; private addMissingValuesToControlValues(command: GeneratePreviewCommand, stepControlSchema: ControlSchemas) { - const defaultValues = this.extractDefaultsUseCase.execute({ - jsonSchemaDto: stepControlSchema.schema as JSONSchemaDto, + return this.controlValuesUsecase.execute({ + controlSchema: stepControlSchema, + controlValues: command.generatePreviewRequestDto.controlValues || {}, }); - - return { - augmentedControlValues: merge(defaultValues, command.generatePreviewRequestDto.controlValues), - issuesMissingValues: this.buildMissingControlValuesIssuesList(defaultValues, command), - }; } private buildMissingControlValuesIssuesList(defaultValues: Record, command: GeneratePreviewCommand) { @@ -81,16 +75,16 @@ export class GeneratePreviewUsecase { command.generatePreviewRequestDto.controlValues || {} ); - return this.buildControlPreviewIssues(missingRequiredControlValues); + return this.buildContentIssues(missingRequiredControlValues); } - private buildControlPreviewIssues(keys: string[]): Record { - const record: Record = {}; + private buildContentIssues(keys: string[]): Record { + const record: Record = {}; keys.forEach((key) => { record[key] = [ { - issueType: ControlPreviewIssueTypeEnum.MISSING_VALUE, + issueType: StepContentIssueEnum.MISSING_VALUE, message: `Value is missing on a required control`, }, ]; @@ -115,7 +109,6 @@ export class GeneratePreviewUsecase { } const state = buildState(hydratedPayload.steps); - console.log('state', JSON.stringify(state, null, 2)); return await this.legacyPreviewStepUseCase.execute( PreviewStepCommand.create({ @@ -158,8 +151,8 @@ export class GeneratePreviewUsecase { } function buildResponse( - missingValuesIssue: Record, - missingPayloadVariablesIssue: Record, + missingValuesIssue: Record, + missingPayloadVariablesIssue: Record, executionOutput, stepType: StepTypeEnum, augmentedPayload: PreviewPayload diff --git a/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts b/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts index 5db647f185e..9205f323ec3 100644 --- a/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts @@ -3,24 +3,21 @@ import { CreateWorkflowDto, PreferencesTypeEnum, StepCreateDto, - StepTypeEnum, + StepDataDto, + StepResponseDto, StepUpdateDto, UpdateWorkflowDto, WorkflowCreationSourceEnum, WorkflowPreferences, WorkflowResponseDto, } from '@novu/shared'; -import { - NotificationStepEntity, - NotificationTemplateEntity, - PreferencesEntity, - PreferencesRepository, -} from '@novu/dal'; +import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; import { SyncToEnvironmentCommand } from './sync-to-environment.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'; import { UpsertWorkflowUseCase } from '../upsert-workflow/upsert-workflow.usecase'; import { UpsertWorkflowCommand } from '../upsert-workflow/upsert-workflow.command'; +import { GetWorkflowUseCase } from '../get-workflow/get-workflow.usecase'; +import { GetStepDataUsecase } from '../get-step-schema/get-step-data.usecase'; /** * This usecase is used to sync a workflow from one environment to another. @@ -34,9 +31,10 @@ import { UpsertWorkflowCommand } from '../upsert-workflow/upsert-workflow.comman @Injectable() export class SyncToEnvironmentUseCase { constructor( - private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, + private getWorkflowUseCase: GetWorkflowUseCase, private preferencesRepository: PreferencesRepository, - private upsertWorkflowUseCase: UpsertWorkflowUseCase + private upsertWorkflowUseCase: UpsertWorkflowUseCase, + private getStepData: GetStepDataUsecase ) {} async execute(command: SyncToEnvironmentCommand): Promise { @@ -45,27 +43,35 @@ export class SyncToEnvironmentUseCase { } const workflowToClone = await this.getWorkflowToClone(command); - const preferencesToClone = await this.getWorkflowPreferences(workflowToClone._id, workflowToClone._environmentId); - const externalId = workflowToClone.triggers[0].identifier; + const preferencesToClone = await this.getWorkflowPreferences(workflowToClone._id, command.user.environmentId); + const externalId = workflowToClone.workflowId; const existingWorkflow = await this.findWorkflowInTargetEnvironment(command, externalId); + const workflowDto = await this.buildRequestDto(workflowToClone, preferencesToClone, command, existingWorkflow); - const workflowDto = existingWorkflow - ? await this.mapWorkflowToUpdateWorkflowDto(workflowToClone, existingWorkflow, preferencesToClone) - : await this.mapWorkflowToCreateWorkflowDto(workflowToClone, preferencesToClone); - - const upsertedWorkflow = await this.upsertWorkflowUseCase.execute( + return await this.upsertWorkflowUseCase.execute( UpsertWorkflowCommand.create({ user: { ...command.user, environmentId: command.targetEnvironmentId }, identifierOrInternalId: existingWorkflow?._id, workflowDto, }) ); + } + + private async buildRequestDto( + workflowToClone: WorkflowResponseDto, + preferencesToClone: PreferencesEntity[], + command: SyncToEnvironmentCommand, + existingWorkflow?: WorkflowResponseDto + ) { + if (existingWorkflow) { + return await this.mapWorkflowToUpdateWorkflowDto(workflowToClone, existingWorkflow, preferencesToClone, command); + } - return upsertedWorkflow; + return await this.mapWorkflowToCreateWorkflowDto(workflowToClone, preferencesToClone, command); } - private async getWorkflowToClone(command: SyncToEnvironmentCommand): Promise { - return this.getWorkflowByIdsUseCase.execute( + private async getWorkflowToClone(command: SyncToEnvironmentCommand): Promise { + return this.getWorkflowUseCase.execute( GetWorkflowByIdsCommand.create({ user: command.user, identifierOrInternalId: command.identifierOrInternalId, @@ -76,9 +82,9 @@ export class SyncToEnvironmentUseCase { private async findWorkflowInTargetEnvironment( command: SyncToEnvironmentCommand, externalId: string - ): Promise { + ): Promise { try { - return await this.getWorkflowByIdsUseCase.execute( + return await this.getWorkflowUseCase.execute( GetWorkflowByIdsCommand.create({ user: { ...command.user, environmentId: command.targetEnvironmentId }, identifierOrInternalId: externalId, @@ -90,56 +96,93 @@ export class SyncToEnvironmentUseCase { } private async mapWorkflowToCreateWorkflowDto( - workflow: NotificationTemplateEntity, - preferences: PreferencesEntity[] + workflowToClone: WorkflowResponseDto, + preferences: PreferencesEntity[], + command: SyncToEnvironmentCommand ): Promise { return { - workflowId: workflow.triggers[0].identifier, - name: workflow.name, - active: workflow.active, - tags: workflow.tags, - description: workflow.description, + workflowId: workflowToClone.workflowId, + name: workflowToClone.name, + active: workflowToClone.active, + tags: workflowToClone.tags, + description: workflowToClone.description, __source: WorkflowCreationSourceEnum.DASHBOARD, - steps: this.mapStepsToDto(workflow.steps), + steps: await this.mapStepsToDto(workflowToClone.steps, command), preferences: this.mapPreferences(preferences), }; } private async mapWorkflowToUpdateWorkflowDto( - workflow: NotificationTemplateEntity, - existingWorkflow: NotificationTemplateEntity, - preferences: PreferencesEntity[] + originWorkflow: WorkflowResponseDto, + existingWorkflowInProd: WorkflowResponseDto | undefined, + preferencesToClone: PreferencesEntity[], + command: SyncToEnvironmentCommand ): Promise { return { - workflowId: workflow.triggers[0].identifier, - name: workflow.name, - active: workflow.active, - tags: workflow.tags, - description: workflow.description, - steps: this.mapStepsToDto(workflow.steps, existingWorkflow.steps), - preferences: this.mapPreferences(preferences), + workflowId: originWorkflow.workflowId, + name: originWorkflow.name, + active: originWorkflow.active, + tags: originWorkflow.tags, + description: originWorkflow.description, + steps: await this.mapStepsToDto(originWorkflow.steps, command, existingWorkflowInProd?.steps), + preferences: this.mapPreferences(preferencesToClone), }; } - private mapStepsToDto( - steps: NotificationStepEntity[], - existingWorkflowSteps?: NotificationStepEntity[] - ): StepUpdateDto[] | StepCreateDto[] { - return steps.map((step) => ({ - /* - * If we are updating an existing workflow, we need to map the updated steps to the existing steps - * (!) 'existingWorkflowSteps' are from a different environment than 'steps' - the only thing that doesn't change - * in steps across environments is the stepId (TODO) - */ - ...(existingWorkflowSteps && { - _id: - existingWorkflowSteps.find((existingStep) => existingStep.stepId === step.stepId)?._templateId ?? - step._templateId, - }), + private async mapStepsToDto( + steps: StepResponseDto[], + command: SyncToEnvironmentCommand, + existingWorkflowSteps?: StepResponseDto[] + ): Promise<(StepUpdateDto | StepCreateDto)[]> { + const augmentedSteps: (StepUpdateDto | StepCreateDto)[] = []; + for (const step of steps) { + const idAsOptionalObject = this.prodDbIdAsOptionalObject(existingWorkflowSteps, step); + const stepDataDto = await this.getStepData.execute({ + identifierOrInternalId: command.identifierOrInternalId, + stepId: step.stepId, + user: command.user, + }); + + augmentedSteps.push(this.buildSingleStepRequest(idAsOptionalObject, step, stepDataDto)); + } + + return augmentedSteps; + } + /* + * If we are updating an existing workflow, we need to map the updated steps to the existing steps + * (!) 'existingWorkflowSteps' are from a different environment than 'steps' - the only thing that doesn't change + * in steps across environments is the stepId (TODO) + */ + private buildSingleStepRequest( + idAsOptionalObject: { _id: string } | {}, + step: StepResponseDto, + stepDataDto: StepDataDto + ): StepUpdateDto | StepCreateDto { + return { + ...idAsOptionalObject, name: step.name ?? '', - type: step.template?.type ?? StepTypeEnum.TRIGGER, - controlValues: step.controlVariables ?? {}, - })); + type: step.type, + controlValues: stepDataDto.controls.values ?? {}, + }; + } + + private prodDbIdAsOptionalObject(existingWorkflowSteps: StepResponseDto[] | undefined, step: StepResponseDto) { + const prodDatabaseId = this.findDatabaseIdInProdByExternalId(existingWorkflowSteps, step); + + if (prodDatabaseId) { + return { + _id: prodDatabaseId, + }; + } else { + return {}; + } + } + + private findDatabaseIdInProdByExternalId( + existingWorkflowSteps: StepResponseDto[] | undefined, + step: StepResponseDto + ) { + return existingWorkflowSteps?.find((existingStep) => existingStep.stepId === step.stepId)?._id ?? step._id; } private mapPreferences(preferences: PreferencesEntity[]): { 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 d31a30094db..96392730e7f 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 @@ -45,6 +45,7 @@ import { toResponseWorkflowDto } from '../../mappers/notification-template-mappe 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 { stepTypeToDefaultDashboardControlSchema } from '../../shared'; +import { ValidateAndPersistWorkflowIssuesUsecase } from './validate-and-persist-workflow-issues.usecase'; function buildUpsertControlValuesCommand( command: UpsertWorkflowCommand, @@ -69,16 +70,24 @@ export class UpsertWorkflowUseCase { private notificationGroupRepository: NotificationGroupRepository, private upsertPreferencesUsecase: UpsertPreferences, private upsertControlValuesUseCase: UpsertControlValuesUseCase, + private validateWorkflowUsecase: ValidateAndPersistWorkflowIssuesUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private getPreferencesUseCase: GetPreferences ) {} async execute(command: UpsertWorkflowCommand): Promise { const workflowForUpdate = await this.queryWorkflow(command); + const workflow = await this.createOrUpdateWorkflow(workflowForUpdate, command); - await this.upsertControlValues(workflow, command); + const stepIdToControlValuesMap = await this.upsertControlValues(workflow, command); const preferences = await this.upsertPreference(command, workflow); - - return toResponseWorkflowDto(workflow, preferences); + const validatedWorkflowWithIssues = await this.validateWorkflowUsecase.execute({ + user: command.user, + workflow, + preferences, + stepIdToControlValuesMap, + }); + + return toResponseWorkflowDto(validatedWorkflowWithIssues, preferences); } private async queryWorkflow(command: UpsertWorkflowCommand): Promise { diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-and-persist-workflow-issues.usecase.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-and-persist-workflow-issues.usecase.ts new file mode 100644 index 00000000000..33c92015246 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-and-persist-workflow-issues.usecase.ts @@ -0,0 +1,202 @@ +import { + ContentIssue, + RuntimeIssue, + StepIssueEnum, + StepIssues, + StepIssuesDto, + WorkflowIssueTypeEnum, + WorkflowResponseDto, + WorkflowStatusEnum, +} from '@novu/shared'; +import { + ControlValuesEntity, + NotificationStepEntity, + NotificationTemplateEntity, + NotificationTemplateRepository, +} from '@novu/dal'; +import { Injectable } from '@nestjs/common'; +import { ValidateWorkflowCommand } from './validate-workflow.command'; +import { WorkflowNotFoundException } from '../../exceptions/workflow-not-found-exception'; +import { ValidateControlValuesAndConstructPassableStructureUsecase } from '../validate-control-values/build-default-control-values-usecase.service'; + +@Injectable() +export class ValidateAndPersistWorkflowIssuesUsecase { + constructor( + private notificationTemplateRepository: NotificationTemplateRepository, + private buildDefaultControlValuesUsecase: ValidateControlValuesAndConstructPassableStructureUsecase + ) {} + + async execute(command: ValidateWorkflowCommand): Promise { + const workflowIssues = await this.validateWorkflow(command); + const stepIssues = this.validateSteps(command.workflow.steps, command.stepIdToControlValuesMap); + const workflowWithIssues = this.updateIssuesOnWorkflow(command.workflow, workflowIssues, stepIssues); + await this.persistWorkflow(command, workflowWithIssues); + + return await this.getWorkflow(command); + } + + private async persistWorkflow(command: ValidateWorkflowCommand, workflowWithIssues: NotificationTemplateEntity) { + const isWorkflowCompleteAndValid = this.isWorkflowCompleteAndValid(workflowWithIssues); + const status = this.calculateStatus(isWorkflowCompleteAndValid, workflowWithIssues); + await this.notificationTemplateRepository.update( + { + _id: command.workflow._id, + _environmentId: command.user.environmentId, + }, + { + ...workflowWithIssues, + status, + } + ); + } + + private calculateStatus(isGoodWorkflow: boolean, workflowWithIssues: NotificationTemplateEntity) { + if (workflowWithIssues.active === false) { + return WorkflowStatusEnum.INACTIVE; + } + + if (isGoodWorkflow) { + return WorkflowStatusEnum.ACTIVE; + } + + return WorkflowStatusEnum.ERROR; + } + + private isWorkflowCompleteAndValid(workflowWithIssues: NotificationTemplateEntity) { + const workflowIssues = workflowWithIssues.issues && Object.keys(workflowWithIssues.issues).length > 0; + const hasInnerIssues = + workflowWithIssues.steps + .map((step) => step.issues) + .filter((issue) => issue != null) + .filter((issue) => this.hasBodyIssues(issue) || this.hasControlIssues(issue)).length > 0; + + return !hasInnerIssues && !workflowIssues; + } + + private hasControlIssues(issue: StepIssues) { + return issue.controls && Object.keys(issue.controls).length > 0; + } + + private hasBodyIssues(issue: StepIssues) { + return issue.body && Object.keys(issue.body).length > 0; + } + + private async getWorkflow(command: ValidateWorkflowCommand) { + const entity = await this.notificationTemplateRepository.findById(command.workflow._id, command.user.environmentId); + if (entity == null) { + throw new WorkflowNotFoundException(command.workflow._id); + } + + return entity; + } + + private validateSteps( + steps: NotificationStepEntity[], + stepIdToControlValuesMap: { [p: string]: ControlValuesEntity } + ): Record { + const stepIdToIssues: Record = {}; + for (const step of steps) { + // @ts-ignore + const stepIssues: Required = { body: {}, controls: {} }; + this.addControlIssues(step, stepIdToControlValuesMap, stepIssues); + this.addStepBodyIssues(step, stepIssues); + stepIdToIssues[step._templateId] = stepIssues; + } + + return stepIdToIssues; + } + + private addControlIssues( + step: NotificationStepEntity, + stepIdToControlValuesMap: { + [p: string]: ControlValuesEntity; + }, + stepIssues: StepIssuesDto + ) { + if (step.template?.controls) { + const { issuesMissingValues } = this.buildDefaultControlValuesUsecase.execute({ + controlSchema: step.template?.controls, + controlValues: stepIdToControlValuesMap, + }); + // eslint-disable-next-line no-param-reassign + stepIssues.controls = issuesMissingValues; + } + } + private async validateWorkflow( + command: ValidateWorkflowCommand + ): Promise> { + // @ts-ignore + const issues: Record = {}; + await this.addTriggerIdentifierNotUniqueIfApplicable(command, issues); + this.addNameMissingIfApplicable(command, issues); + this.addDescriptionTooLongIfApplicable(command, issues); + + return issues; + } + + private addNameMissingIfApplicable( + command: ValidateWorkflowCommand, + issues: Record + ) { + if (!command.workflow.name || command.workflow.name.trim() === '') { + // eslint-disable-next-line no-param-reassign + issues.name = [{ issueType: WorkflowIssueTypeEnum.MISSING_VALUE, message: 'Name is missing' }]; + } + } + private addDescriptionTooLongIfApplicable( + command: ValidateWorkflowCommand, + issues: Record + ) { + if (command.workflow.description && command.workflow.description.length > 160) { + // eslint-disable-next-line no-param-reassign + issues.description = [ + { issueType: WorkflowIssueTypeEnum.MAX_LENGTH_ACCESSED, message: 'Description is too long' }, + ]; + } + } + private async addTriggerIdentifierNotUniqueIfApplicable( + command: ValidateWorkflowCommand, + issues: Record + ) { + const findAllByTriggerIdentifier = await this.notificationTemplateRepository.findAllByTriggerIdentifier( + command.user.environmentId, + command.workflow.triggers[0].identifier + ); + if (findAllByTriggerIdentifier && findAllByTriggerIdentifier.length > 1) { + // eslint-disable-next-line no-param-reassign + command.workflow.triggers[0].identifier = `${command.workflow.triggers[0].identifier}-${command.workflow._id}`; + // eslint-disable-next-line no-param-reassign + issues.workflowId = [ + { + issueType: WorkflowIssueTypeEnum.WORKFLOW_ID_ALREADY_EXISTS, + message: 'Trigger identifier is not unique', + }, + ]; + } + } + + private addStepBodyIssues(step: NotificationStepEntity, stepIssues: Required) { + if (!step.name || step.name.trim() === '') { + // eslint-disable-next-line no-param-reassign + stepIssues.body.name = { + issueType: StepIssueEnum.MISSING_REQUIRED_VALUE, + message: 'Step name is missing', + }; + } + } + + private updateIssuesOnWorkflow( + workflow: NotificationTemplateEntity, + workflowIssues: Record, + stepIssuesMap: Record + ): NotificationTemplateEntity { + const issues = workflowIssues as unknown as Record; + for (const step of workflow.steps) { + if (stepIssuesMap[step._templateId]) { + step.issues = stepIssuesMap[step._templateId]; + } + } + + return { ...workflow, issues }; + } +} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-workflow.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-workflow.command.ts new file mode 100644 index 00000000000..0f9689c099e --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/validate-workflow.command.ts @@ -0,0 +1,8 @@ +import { EnvironmentWithUserObjectCommand, GetPreferencesResponseDto } from '@novu/application-generic'; +import { ControlValuesEntity, NotificationTemplateEntity } from '@novu/dal'; + +export class ValidateWorkflowCommand extends EnvironmentWithUserObjectCommand { + workflow: NotificationTemplateEntity; + preferences?: GetPreferencesResponseDto; + stepIdToControlValuesMap: { [p: string]: ControlValuesEntity }; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values-usecase.service.ts b/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values-usecase.service.ts new file mode 100644 index 00000000000..45256be9153 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values-usecase.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ContentIssue, JSONSchemaDto, StepContentIssueEnum } from '@novu/shared'; +import _ = require('lodash'); +import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults.usecase'; +import { BuildDefaultControlValuesCommand } from './build-default-control-values.command'; +import { findMissingKeys } from '../../util/utils'; + +@Injectable() +export class ValidateControlValuesAndConstructPassableStructureUsecase { + constructor(private extractDefaultsUseCase: ExtractDefaultsUsecase) {} + + execute(command: BuildDefaultControlValuesCommand): { + augmentedControlValues: Record; + issuesMissingValues: Record; + } { + const defaultValues = this.extractDefaultsUseCase.execute({ + jsonSchemaDto: command.controlSchema.schema as JSONSchemaDto, + }); + + return { + augmentedControlValues: _.merge(defaultValues, command.controlValues), + issuesMissingValues: this.buildMissingControlValuesIssuesList(defaultValues, command.controlValues), + }; + } + + private buildMissingControlValuesIssuesList(defaultValues: Record, controlValues: Record) { + const missingRequiredControlValues = findMissingKeys(defaultValues, controlValues); + + return this.buildContentIssues(missingRequiredControlValues); + } + + private buildContentIssues(keys: string[]): Record { + const record: Record = {}; + + keys.forEach((key) => { + record[key] = [ + { + issueType: StepContentIssueEnum.MISSING_VALUE, + message: `Value is missing on a required control`, // Custom message for the issue + }, + ]; + }); + + return record; + } +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values.command.ts b/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values.command.ts new file mode 100644 index 00000000000..06da962c65f --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values.command.ts @@ -0,0 +1,6 @@ +import { ControlsSchema } from '@novu/shared'; + +export class BuildDefaultControlValuesCommand { + controlSchema: ControlsSchema; + controlValues: Record; +} diff --git a/apps/api/src/app/workflows-v2/util/utils.ts b/apps/api/src/app/workflows-v2/util/utils.ts new file mode 100644 index 00000000000..97536bafabe --- /dev/null +++ b/apps/api/src/app/workflows-v2/util/utils.ts @@ -0,0 +1,25 @@ +import _ = require('lodash'); + +export function findMissingKeys(requiredRecord: Record, actualRecord: Record) { + const requiredKeys = collectKeys(requiredRecord); + const actualKeys = collectKeys(actualRecord); + + return _.difference(requiredKeys, actualKeys); +} + +export function collectKeys(obj, prefix = '') { + return _.reduce( + obj, + (result, value, key) => { + const newKey = prefix ? `${prefix}.${key}` : key; + if (_.isObject(value) && !_.isArray(value)) { + result.push(...collectKeys(value, newKey)); + } else { + result.push(newKey); + } + + return result; + }, + [] + ); +} 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 7094dd0f212..570937ddf7f 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -7,11 +7,12 @@ import { DEFAULT_WORKFLOW_PREFERENCES, isStepUpdateBody, ListWorkflowResponse, + PreferencesRequestDto, ShortIsPrefixEnum, - Slug, slugify, + StepContentIssueEnum, StepCreateDto, - StepDto, + StepIssueEnum, StepResponseDto, StepTypeEnum, StepUpdateDto, @@ -19,13 +20,18 @@ import { UpdateWorkflowDto, UpsertStepBody, UpsertWorkflowBody, + WorkflowCommonsFields, WorkflowCreationSourceEnum, + WorkflowIssueTypeEnum, WorkflowListResponseDto, + WorkflowOriginEnum, WorkflowResponseDto, + WorkflowStatusEnum, } from '@novu/shared'; import { encodeBase62 } from '../shared/helpers'; import { stepTypeToDefaultDashboardControlSchema } from './shared'; +import { getTestControlValues } from './generate-preview.e2e'; const v2Prefix = '/v2'; const PARTIAL_UPDATED_NAME = 'Updated'; @@ -35,6 +41,9 @@ const TEST_WORKFLOW_NAME = 'Test Workflow Name'; const TEST_TAGS = ['test']; let session: UserSession; +const LONG_DESCRIPTION = `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`; describe('Workflow Controller E2E API Testing', () => { let workflowsClient: ReturnType; @@ -63,6 +72,56 @@ describe('Workflow Controller E2E API Testing', () => { await deleteWorkflowAndValidateDeletion(workflowCreated._id); }); + describe('Error Handling', () => { + describe('Should show status ok when no problems', () => { + it('should show status ok when no problems', async () => { + const workflowCreated = await createWorkflowAndValidate(); + await getWorkflowAndValidate(workflowCreated); + }); + }); + describe('Workflow Body Issues', () => { + it('should show description issue when too long', async () => { + const issues = await createWorkflowAndReturnIssues({ description: LONG_DESCRIPTION }); + expect(issues?.description).to.be.ok; + if (issues?.description) { + expect(issues?.description[0]?.issueType, JSON.stringify(issues)).to.be.equal( + WorkflowIssueTypeEnum.MAX_LENGTH_ACCESSED + ); + } + }); + }); + describe('Workflow Step Body Issues', () => { + it('should show name issue when missing', async () => { + const { issues, status } = await createWorkflowAndReturnStepIssues( + { steps: [{ ...buildEmailStep(), name: '' }] }, + 0 + ); + expect(status).to.be.equal(WorkflowStatusEnum.ERROR); + expect(issues).to.be.ok; + if (issues.body) { + expect(issues.body).to.be.ok; + expect(issues.body.name).to.be.ok; + expect(issues.body.name?.issueType, JSON.stringify(issues)).to.be.equal(StepIssueEnum.MISSING_REQUIRED_VALUE); + } + }); + }); + describe('Workflow Step content Issues', () => { + it('should show control value required when missing', async () => { + const { issues, status } = await createWorkflowAndReturnStepIssues( + { steps: [{ ...buildEmailStep(), controlValues: {} }] }, + 0 + ); + expect(status, JSON.stringify(issues)).to.equal(WorkflowStatusEnum.ERROR); + expect(issues).to.be.ok; + if (issues.controls) { + expect(issues.controls?.emailEditor).to.be.ok; + if (issues.controls?.emailEditor) { + expect(issues.controls?.emailEditor[0].issueType).to.be.equal(StepContentIssueEnum.MISSING_VALUE); + } + } + }); + }); + }); describe('Create Workflow Permutations', () => { it('should allow creating two workflows for the same user with the same name', async () => { const nameSuffix = `Test Workflow${new Date().toString()}`; @@ -598,11 +657,7 @@ describe('Workflow Controller E2E API Testing', () => { return value; } - async function getWorkflowStepControlValues( - workflow: WorkflowResponseDto, - step: StepDto & { _id: string; slug: Slug; stepId: string }, - envId: string - ) { + async function getWorkflowStepControlValues(workflow: WorkflowResponseDto, step: StepResponseDto, envId: string) { const value = await getStepData(workflow._id, step._id, envId); return value.controls.values; @@ -646,49 +701,104 @@ describe('Workflow Controller E2E API Testing', () => { } } } -}); + async function create10Workflows(prefix: string) { + // eslint-disable-next-line no-plusplus + for (let i = 0; i < 10; i++) { + await createWorkflowAndValidate(`${prefix}-ABC${i}`); + } + } + async function createWorkflowAndValidate(nameSuffix: string = ''): Promise { + const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto(nameSuffix); + const res = await workflowsClient.createWorkflow(createWorkflowDto); + if (!res.isSuccessResult()) { + throw new Error(res.error!.responseText); + } + validateCreateWorkflowResponse(res.value, createWorkflowDto); -async function createWorkflowAndValidate(nameSuffix: string = ''): Promise { - const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto(nameSuffix); - const res = await session.testAgent.post(`${v2Prefix}/workflows`).send(createWorkflowDto); - const workflowResponseDto: WorkflowResponseDto = res.body.data; - const errorMessageOnFailure = JSON.stringify(res, null, 2); - expect(workflowResponseDto, errorMessageOnFailure).to.be.ok; - expect(workflowResponseDto._id, errorMessageOnFailure).to.be.ok; - expect(workflowResponseDto.updatedAt, errorMessageOnFailure).to.be.ok; - expect(workflowResponseDto.createdAt, errorMessageOnFailure).to.be.ok; - expect(workflowResponseDto.preferences, errorMessageOnFailure).to.be.ok; - expect(workflowResponseDto.status, errorMessageOnFailure).to.be.ok; - expect(workflowResponseDto.origin, errorMessageOnFailure).to.be.eq('novu-cloud'); - for (const step of workflowResponseDto.steps) { - expect(step._id, errorMessageOnFailure).to.be.ok; - expect(step.slug, errorMessageOnFailure).to.be.ok; + return res.value; + } + function workflowAsString(workflowResponseDto: any) { + return JSON.stringify(workflowResponseDto, null, 2); } - const createdWorkflowWithoutUpdateDate = removeFields( - workflowResponseDto, - '_id', - 'origin', - 'preferences', - 'updatedAt', - 'createdAt', - 'status', - 'slug' - ); - createdWorkflowWithoutUpdateDate.steps = createdWorkflowWithoutUpdateDate.steps.map((step) => - removeFields(step, '_id', 'slug', 'slug', 'stepId') - ); - expect(createdWorkflowWithoutUpdateDate).to.deep.equal( - removeFields(createWorkflowDto, '__source') - // buildErrorMsg(createWorkflowDto, createdWorkflowWithoutUpdateDate) - ); - return workflowResponseDto; -} + function assertWorkflowResponseBodyData(workflowResponseDto: WorkflowResponseDto) { + expect(workflowResponseDto, workflowAsString(workflowResponseDto)).to.be.ok; + expect(workflowResponseDto._id, workflowAsString(workflowResponseDto)).to.be.ok; + expect(workflowResponseDto.updatedAt, workflowAsString(workflowResponseDto)).to.be.ok; + expect(workflowResponseDto.createdAt, workflowAsString(workflowResponseDto)).to.be.ok; + expect(workflowResponseDto.preferences, workflowAsString(workflowResponseDto)).to.be.ok; + expect(workflowResponseDto.status, workflowAsString(workflowResponseDto)).to.be.ok; + expect(workflowResponseDto.origin, workflowAsString(workflowResponseDto)).to.be.eq(WorkflowOriginEnum.NOVU_CLOUD); + expect(Object.keys(workflowResponseDto.issues || {}).length, workflowAsString(workflowResponseDto)).to.be.equal(0); + } + + function assertStepResponse(workflowResponseDto: WorkflowResponseDto, createWorkflowDto: CreateWorkflowDto) { + // eslint-disable-next-line no-plusplus + for (let i = 0; i < workflowResponseDto.steps.length; i++) { + const stepInRequest = createWorkflowDto.steps[i]; + const step = workflowResponseDto.steps[i]; + expect(step._id, workflowAsString(step)).to.be.ok; + expect(step.slug, workflowAsString(step)).to.be.ok; + expect(step.name, workflowAsString(step)).to.be.equal(stepInRequest.name); + expect(step.type, workflowAsString(step)).to.be.equal(stepInRequest.type); + expect(Object.keys(step.issues?.body || {}).length, workflowAsString(step)).to.be.eq(0); + } + } + async function createWorkflowAndReturnIssues(overrideDto: Partial) { + const workflowCreated = await createWorkflowAndReturn(overrideDto); + const { issues } = workflowCreated; + expect(issues, JSON.stringify(workflowCreated)).to.be.ok; + + return issues; + } + async function createWorkflowAndReturn( + overrideDto: Partial< + WorkflowCommonsFields & { + workflowId: string; + steps: StepCreateDto[]; + __source: WorkflowCreationSourceEnum; + preferences?: PreferencesRequestDto; + } + > + ) { + const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('nameSuffix'); + const dtoWithoutName = { ...createWorkflowDto, ...overrideDto }; + + const res = await workflowsClient.createWorkflow(dtoWithoutName); + if (!res.isSuccessResult()) { + throw new Error(res.error!.responseText); + } + const workflowCreated: WorkflowResponseDto = res.value; + + return workflowCreated; + } + + async function createWorkflowAndReturnStepIssues(overrideDto: Partial, stepIndex: number) { + const workflowCreated = await createWorkflowAndReturn(overrideDto); + const { steps } = workflowCreated; + expect(steps, JSON.stringify(workflowCreated)).to.be.ok; + const step = steps[stepIndex]; + const { issues } = step; + expect(issues, JSON.stringify(step)).to.be.ok; + if (issues) { + return { issues, status: workflowCreated.status }; + } + throw new Error('Issues not found'); + } + function validateCreateWorkflowResponse( + workflowResponseDto: WorkflowResponseDto, + createWorkflowDto: CreateWorkflowDto + ) { + assertWorkflowResponseBodyData(workflowResponseDto); + assertStepResponse(workflowResponseDto, createWorkflowDto); + } +}); function buildEmailStep(): StepCreateDto { return { name: 'Email Test Step', type: StepTypeEnum.EMAIL, + controlValues: getTestControlValues()[StepTypeEnum.EMAIL], }; } @@ -696,6 +806,7 @@ function buildInAppStep(): StepCreateDto { return { name: 'In-App Test Step', type: StepTypeEnum.IN_APP, + controlValues: getTestControlValues()[StepTypeEnum.IN_APP], }; } @@ -847,13 +958,6 @@ function buildIdSet( return new Set([...extractIDs(listWorkflowResponse1), ...extractIDs(listWorkflowResponse2)]); } -async function create10Workflows(prefix: string) { - // eslint-disable-next-line no-plusplus - for (let i = 0; i < 10; i++) { - await createWorkflowAndValidate(`${prefix}-ABC${i}`); - } -} - function removeFields(obj: T, ...keysToRemove: (keyof T)[]): T { const objCopy = JSON.parse(JSON.stringify(obj)); keysToRemove.forEach((key) => { diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index c70cb692e99..f973a3a745f 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -13,7 +13,6 @@ import { AuthModule } from '../auth/auth.module'; import { IntegrationModule } from '../integrations/integrations.module'; import { WorkflowController } from './workflow.controller'; import { UpsertWorkflowUseCase } from './usecases/upsert-workflow/upsert-workflow.usecase'; -import { GetWorkflowUseCase } from './usecases/get-workflow/get-workflow.usecase'; import { ListWorkflowsUseCase } from './usecases/list-workflows/list-workflow.usecase'; import { DeleteWorkflowUseCase } from './usecases/delete-workflow/delete-workflow.usecase'; import { GetWorkflowByIdsUseCase } from './usecases/get-workflow-by-ids/get-workflow-by-ids.usecase'; @@ -25,8 +24,11 @@ import { ExtractDefaultsUsecase } from './usecases/get-default-values-from-schem import { HydrateEmailSchemaUseCase } from '../environments-v1/usecases/output-renderers'; import { WorkflowTestDataUseCase } from './usecases/test-data/test-data.usecase'; import { GetStepDataUsecase } from './usecases/get-step-schema/get-step-data.usecase'; +import { ValidateAndPersistWorkflowIssuesUsecase } from './usecases/upsert-workflow/validate-and-persist-workflow-issues.usecase'; import { BuildPayloadNestedStructureUsecase } from './usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase'; -import { BuildDefaultPayloadUseCase } from './usecases/build-payload-from-placeholder/build-default-payload-use-case.service'; +import { GetWorkflowUseCase } from './usecases/get-workflow/get-workflow.usecase'; +import { BuildDefaultPayloadUseCase } from './usecases/build-payload-from-placeholder'; +import { ValidateControlValuesAndConstructPassableStructureUsecase } from './usecases/validate-control-values/build-default-control-values-usecase.service'; @Module({ imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule], @@ -35,7 +37,6 @@ import { BuildDefaultPayloadUseCase } from './usecases/build-payload-from-placeh CreateWorkflow, UpdateWorkflow, UpsertWorkflowUseCase, - GetWorkflowUseCase, ListWorkflowsUseCase, DeleteWorkflowUseCase, UpsertPreferences, @@ -49,8 +50,11 @@ import { BuildDefaultPayloadUseCase } from './usecases/build-payload-from-placeh ExtractDefaultsUsecase, BuildPayloadNestedStructureUsecase, WorkflowTestDataUseCase, - BuildDefaultPayloadUseCase, + GetWorkflowUseCase, HydrateEmailSchemaUseCase, + ValidateAndPersistWorkflowIssuesUsecase, + BuildDefaultPayloadUseCase, + ValidateControlValuesAndConstructPassableStructureUsecase, ], }) export class WorkflowModule implements NestModule { diff --git a/apps/api/src/config/env.validators.ts b/apps/api/src/config/env.validators.ts index e50279dcc6e..c69013e775a 100644 --- a/apps/api/src/config/env.validators.ts +++ b/apps/api/src/config/env.validators.ts @@ -52,8 +52,7 @@ export const envValidators = { NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER: str({ default: undefined }), HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID: str({ default: undefined }), HUBSPOT_PRIVATE_APP_ACCESS_TOKEN: str({ default: undefined }), - PLAIN_SUPPORT_KEY: str(), - // Feature Flags + PLAIN_SUPPORT_KEY: str({ default: undefined }), // Feature Flags ...Object.keys(FeatureFlagsKeysEnum).reduce( (acc, key) => { return { diff --git a/libs/dal/src/repositories/notification-template/notification-template.entity.ts b/libs/dal/src/repositories/notification-template/notification-template.entity.ts index c04f8aee540..03d30a7dcbf 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.entity.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.entity.ts @@ -2,6 +2,7 @@ import { Types } from 'mongoose'; import { BuilderFieldType, BuilderGroupValues, + ContentIssue, ControlSchemas, ControlsDto, FilterParts, @@ -16,8 +17,10 @@ import { ITriggerReservedVariable, IWorkflowStepMetadata, NotificationTemplateCustomData, + StepIssues, TriggerTypeEnum, WorkflowOriginEnum, + WorkflowStatusEnum, WorkflowTypeEnum, } from '@novu/shared'; import { NotificationGroupEntity } from '../notification-group'; @@ -83,6 +86,10 @@ export class NotificationTemplateEntity implements INotificationTemplate { rawData?: any; payloadSchema?: any; + + issues: Record; + + status?: WorkflowStatusEnum; } export type NotificationTemplateDBModel = ChangePropsValueType< @@ -111,6 +118,8 @@ export class StepVariantEntity implements IStepVariant { stepId?: string; + issues?: StepIssues; + name?: string; _templateId: string; diff --git a/libs/dal/src/repositories/notification-template/notification-template.repository.ts b/libs/dal/src/repositories/notification-template/notification-template.repository.ts index d19fa027cd6..5753c87782e 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.repository.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.repository.ts @@ -38,6 +38,16 @@ export class NotificationTemplateRepository extends BaseRepository< return this.mapEntity(item); } + async findAllByTriggerIdentifier(environmentId: string, identifier: string): Promise { + const requestQuery: NotificationTemplateQuery = { + _environmentId: environmentId, + 'triggers.identifier': identifier, + }; + + const query = await this._model.find(requestQuery, { _id: 1, 'triggers.identifier': 1 }); + + return this.mapEntities(query); + } async findById(id: string, environmentId: string) { return this.findByIdQuery({ id, environmentId }); diff --git a/libs/dal/src/repositories/notification-template/notification-template.schema.ts b/libs/dal/src/repositories/notification-template/notification-template.schema.ts index d47c5f9ce45..2c9a7454f3a 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.schema.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.schema.ts @@ -19,6 +19,7 @@ const variantSchemePart = { type: Schema.Types.Boolean, default: false, }, + issues: Schema.Types.Mixed, uuid: Schema.Types.String, stepId: Schema.Types.String, name: Schema.Types.String, @@ -200,6 +201,9 @@ const notificationTemplateSchema = new Schema( origin: { type: Schema.Types.String, }, + status: { + type: Schema.Types.String, + }, _environmentId: { type: Schema.Types.ObjectId, ref: 'Environment', @@ -219,6 +223,7 @@ const notificationTemplateSchema = new Schema( data: Schema.Types.Mixed, rawData: Schema.Types.Mixed, payloadSchema: Schema.Types.Mixed, + issues: Schema.Types.Mixed, }, { ...schemaOptions, minimize: false } ); diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index ebd0ce6a4a4..e604585a61c 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -9,14 +9,14 @@ import { ExecutionStateCorruptError, ExecutionStateOutputInvalidError, ExecutionStateResultInvalidError, + isFrameworkError, ProviderExecutionFailedError, ProviderNotFoundError, StepControlCompilationFailedError, + StepExecutionFailedError, StepNotFoundError, WorkflowAlreadyExistsError, WorkflowNotFoundError, - StepExecutionFailedError, - isFrameworkError, } from './errors'; import type { ActionStep, diff --git a/packages/shared/src/clients/workflows-client.ts b/packages/shared/src/clients/workflows-client.ts index 47828c16f1b..06026aeb99a 100644 --- a/packages/shared/src/clients/workflows-client.ts +++ b/packages/shared/src/clients/workflows-client.ts @@ -1,7 +1,7 @@ import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client'; import { CreateWorkflowDto, - GeneratePreviewResponseDto, + GeneratePreviewRequestDto, GetListQueryParams, ListWorkflowResponse, StepDataDto, @@ -10,7 +10,7 @@ import { WorkflowResponseDto, WorkflowTestDataResponseDto, } from '../dto'; -import { GeneratePreviewRequestDto } from '../dto/workflows/generate-preview-request.dto'; +import { GeneratePreviewResponseDto } from '../dto/workflows/preview-step-response.dto'; // Define the WorkflowClient as a function that utilizes the base client export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) => { diff --git a/packages/shared/src/dto/workflows/index.ts b/packages/shared/src/dto/workflows/index.ts index f21be1f4d29..565d6345636 100644 --- a/packages/shared/src/dto/workflows/index.ts +++ b/packages/shared/src/dto/workflows/index.ts @@ -12,7 +12,8 @@ export * from './workflow-status-enum'; export * from './get-list-query-params'; export * from './workflow-test-data-response-dto'; export * from './step-data.dto'; -export * from './preview-step-response.dto'; export * from './generate-preview-request.dto'; export * from './control-schemas'; export * from './json-schema-dto'; +export * from './preview-step-response.dto'; +export * from './step-content-issue.enum'; diff --git a/packages/shared/src/dto/workflows/preview-step-response.dto.ts b/packages/shared/src/dto/workflows/preview-step-response.dto.ts index f9a9f7b8ccd..c125bfd1f59 100644 --- a/packages/shared/src/dto/workflows/preview-step-response.dto.ts +++ b/packages/shared/src/dto/workflows/preview-step-response.dto.ts @@ -1,5 +1,6 @@ import { ChannelTypeEnum } from '../../types'; import { SubscriberDto } from '../subscriber'; +import { ContentIssue } from './workflow-commons-fields'; export class RenderOutput {} @@ -53,17 +54,7 @@ export class InAppRenderOutput extends RenderOutput { target?: RedirectTargetEnum; }; } -export enum ControlPreviewIssueTypeEnum { - MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD', - VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH', - MISSING_VALUE = 'MISSING_VALUE', -} -export class ControlPreviewIssue { - issueType: ControlPreviewIssueTypeEnum; - variableName?: string; - message: string; -} export class PreviewPayload { subscriber?: Partial; payload?: Record; @@ -72,7 +63,7 @@ export class PreviewPayload { export class GeneratePreviewResponseDto { previewPayloadExample: PreviewPayload; - issues: Record; + issues: Record; result?: | { type: ChannelTypeEnum.EMAIL; diff --git a/packages/shared/src/dto/workflows/step-content-issue.enum.ts b/packages/shared/src/dto/workflows/step-content-issue.enum.ts new file mode 100644 index 00000000000..a5eb75d07c3 --- /dev/null +++ b/packages/shared/src/dto/workflows/step-content-issue.enum.ts @@ -0,0 +1,9 @@ +export enum StepContentIssueEnum { + MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD', + VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH', + MISSING_VALUE = 'MISSING_VALUE', +} +export enum StepIssueEnum { + STEP_ID_EXISTS = 'STEP_ID_EXISTS', + MISSING_REQUIRED_VALUE = 'MISSING_REQUIRED_VALUE', +} diff --git a/packages/shared/src/dto/workflows/workflow-commons-fields.ts b/packages/shared/src/dto/workflows/workflow-commons-fields.ts index 50e787cc53f..5bbdd2ea631 100644 --- a/packages/shared/src/dto/workflows/workflow-commons-fields.ts +++ b/packages/shared/src/dto/workflows/workflow-commons-fields.ts @@ -1,13 +1,37 @@ import { IsArray, IsBoolean, IsDefined, IsOptional, IsString } from 'class-validator'; +import { JSONSchema } from 'json-schema-to-ts'; import { WorkflowResponseDto } from './workflow-response-dto'; import { Slug, StepTypeEnum, WorkflowPreferences } from '../../types'; +import { StepContentIssueEnum, StepIssueEnum } from './step-content-issue.enum'; +export class ControlsSchema { + schema: JSONSchema; +} +export type StepCreateAndUpdateKeys = keyof StepCreateDto | keyof StepUpdateDto; + +export class StepIssuesDto { + body?: Record; + controls?: Record; +} +// eslint-disable-next-line @typescript-eslint/naming-convention +interface Issue { + issueType: T; + variableName?: string; + message: string; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ContentIssue extends Issue {} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface StepIssue extends Issue {} export type IdentifierOrInternalId = string; export type StepResponseDto = StepDto & { _id: string; slug: Slug; stepId: string; + issues?: StepIssuesDto; }; export type StepUpdateDto = StepCreateDto & { @@ -50,8 +74,6 @@ export class WorkflowCommonsFields { @IsBoolean() active?: boolean; - @IsString() - @IsDefined() name: string; @IsString() diff --git a/packages/shared/src/dto/workflows/workflow-response-dto.ts b/packages/shared/src/dto/workflows/workflow-response-dto.ts index 7654d6a62ac..c92fe3ea78b 100644 --- a/packages/shared/src/dto/workflows/workflow-response-dto.ts +++ b/packages/shared/src/dto/workflows/workflow-response-dto.ts @@ -2,6 +2,8 @@ import { IsArray, IsDefined, IsEnum, IsObject, IsOptional, IsString } from 'clas import { PreferencesResponseDto, StepResponseDto, WorkflowCommonsFields } from './workflow-commons-fields'; import { Slug, WorkflowOriginEnum } from '../../types'; import { WorkflowStatusEnum } from './workflow-status-enum'; +import { CreateWorkflowDto } from './create-workflow-dto'; +import { UpdateWorkflowDto } from './update-workflow-dto'; export class WorkflowResponseDto extends WorkflowCommonsFields { @IsString() @@ -36,12 +38,22 @@ export class WorkflowResponseDto extends WorkflowCommonsFields { @IsDefined() status: WorkflowStatusEnum; - // TODO: provide better types for issues @IsObject() @IsOptional() - issues?: Record; + issues?: Record; @IsString() @IsDefined() workflowId: string; } +export type WorkflowCreateAndUpdateKeys = keyof CreateWorkflowDto | keyof UpdateWorkflowDto; +export class RuntimeIssue { + issueType: WorkflowIssueTypeEnum; + variableName?: string; + message: string; +} +export enum WorkflowIssueTypeEnum { + MISSING_VALUE = 'MISSING_VALUE', + MAX_LENGTH_ACCESSED = 'MAX_LENGTH_ACCESSED', + WORKFLOW_ID_ALREADY_EXISTS = 'WORKFLOW_ID_ALREADY_EXISTS', +} diff --git a/packages/shared/src/entities/notification-template/notification-template.interface.ts b/packages/shared/src/entities/notification-template/notification-template.interface.ts index b3d4661aba1..548ad1e9080 100644 --- a/packages/shared/src/entities/notification-template/notification-template.interface.ts +++ b/packages/shared/src/entities/notification-template/notification-template.interface.ts @@ -12,7 +12,7 @@ import { ControlSchemas, IMessageTemplate } from '../message-template'; import { IPreferenceChannels } from '../subscriber-preference'; import { IWorkflowStepMetadata } from '../step'; import { INotificationGroup } from '../notification-group'; -import { ControlsDto } from '../../index'; +import { ContentIssue, ControlsDto, StepIssue } from '../../index'; export interface INotificationTemplate { _id?: string; @@ -82,11 +82,15 @@ export interface INotificationTriggerVariable { value?: any; type?: TemplateVariableTypeEnum; } - +export class StepIssues { + body?: Record; + controls?: Record; +} export interface IStepVariant { _id?: string; uuid?: string; stepId?: string; + issues?: StepIssues; name?: string; filters?: IMessageFilter[]; _templateId?: string;