From fc9a0fd2aabaa4e719fec732966f13a76023f4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Fri, 14 Aug 2026 19:46:01 +0200 Subject: [PATCH] fix(api-service): infer chat editorType from body fixes NV-8596 (#12341) Co-authored-by: Cursor Agent --- .../workflows-v2/e2e/upsert-workflow.e2e.ts | 67 +++++++++++++++++++ .../src/components/chat-editor-select.tsx | 12 +++- .../chat/derive-chat-editor-type.spec.ts | 3 +- .../src/pages/edit-step-template-v2.tsx | 17 ++++- .../src/dtos/workflow/chat-control.dto.ts | 13 +++- .../schemas/control/chat-control.schema.ts | 7 +- .../upsert-workflow.usecase.ts | 19 +++++- libs/application-generic/src/utils/index.ts | 1 + .../src/utils/issues.spec.ts | 47 +++++++++++++ .../utils/resolve-chat-editor-type.spec.ts | 30 +++++++++ .../src/utils/resolve-chat-editor-type.ts | 25 +++++++ .../src/utils/sanitize-control-values.spec.ts | 26 ++++++- .../src/utils/sanitize-control-values.ts | 4 +- 13 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 libs/application-generic/src/utils/resolve-chat-editor-type.spec.ts create mode 100644 libs/application-generic/src/utils/resolve-chat-editor-type.ts diff --git a/apps/api/src/app/workflows-v2/e2e/upsert-workflow.e2e.ts b/apps/api/src/app/workflows-v2/e2e/upsert-workflow.e2e.ts index 2d7fa09503c..463eb722aa6 100644 --- a/apps/api/src/app/workflows-v2/e2e/upsert-workflow.e2e.ts +++ b/apps/api/src/app/workflows-v2/e2e/upsert-workflow.e2e.ts @@ -1186,6 +1186,73 @@ describe('Upsert Workflow #novu-v2', () => { }); }); + describe('chat editorType inference', () => { + const mailyBody = JSON.stringify({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello from blocks' }] }], + }); + + it('sets editorType to block when the chat body is Maily JSON', async () => { + const createResponse = await session.testAgent.post('/v2/workflows').send({ + __source: WorkflowCreationSourceEnum.Editor, + name: 'Chat EditorType Block Workflow', + workflowId: `chat-editor-type-block-${randomUUID()}`, + active: true, + steps: [ + { + name: 'Chat Step', + type: StepTypeEnum.CHAT, + controlValues: { body: mailyBody }, + }, + ], + }); + + expect(createResponse.status).to.equal(201); + expect(createResponse.body.data.steps[0].controls.values.editorType).to.equal('block'); + expect(createResponse.body.data.steps[0].issues?.controls?.editorType).to.equal(undefined); + }); + + it('sets editorType to text when the chat body is plain text', async () => { + const createResponse = await session.testAgent.post('/v2/workflows').send({ + __source: WorkflowCreationSourceEnum.Editor, + name: 'Chat EditorType Text Workflow', + workflowId: `chat-editor-type-text-${randomUUID()}`, + active: true, + steps: [ + { + name: 'Chat Step', + type: StepTypeEnum.CHAT, + controlValues: { body: 'hello {{payload.foo}}' }, + }, + ], + }); + + expect(createResponse.status).to.equal(201); + expect(createResponse.body.data.steps[0].controls.values.editorType).to.equal('text'); + expect(createResponse.body.data.steps[0].issues?.controls?.editorType).to.equal(undefined); + }); + + it('does not report editorType enum issues when editorType is empty and body is Maily JSON', async () => { + const createResponse = await session.testAgent.post('/v2/workflows').send({ + __source: WorkflowCreationSourceEnum.Editor, + name: 'Chat EditorType Empty Workflow', + workflowId: `chat-editor-type-empty-${randomUUID()}`, + active: true, + steps: [ + { + name: 'Chat Step', + type: StepTypeEnum.CHAT, + controlValues: { body: mailyBody, editorType: '' }, + }, + ], + }); + + expect(createResponse.status).to.equal(201); + expect(createResponse.body.data.steps[0].controls.values.editorType).to.equal('block'); + expect(createResponse.body.data.steps[0].issues?.controls?.editorType).to.equal(undefined); + }); + }); + describe('workflow agent assignment', () => { async function createTestAgent(identifier: string, name = identifier) { const response = await session.testAgent.post('/v1/agents').send({ name, identifier }); diff --git a/apps/dashboard/src/components/chat-editor-select.tsx b/apps/dashboard/src/components/chat-editor-select.tsx index fc957174323..b8f3a9fdc94 100644 --- a/apps/dashboard/src/components/chat-editor-select.tsx +++ b/apps/dashboard/src/components/chat-editor-select.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { RiCodeSSlashFill, RiDashboardLine } from 'react-icons/ri'; import { ConfirmationModal } from '@/components/confirmation-modal'; @@ -27,6 +27,16 @@ export const ChatEditorSelect = ({ const { control, setValue } = useFormContext(); const [pendingEditorType, setPendingEditorType] = useState(null); const body = useWatch({ name: 'body', control }); + const editorType = useWatch({ name: 'editorType', control }); + + useEffect(() => { + const resolvedEditorType = deriveChatEditorType(body, editorType, true); + if (editorType === resolvedEditorType) { + return; + } + + setValue('editorType', resolvedEditorType, { shouldDirty: false, shouldValidate: false }); + }, [body, editorType, setValue]); return ( { expect(deriveChatEditorType(mailyBody, 'text', true)).toBe('text'); }); - it('routes Maily JSON to block when editorType is unset', () => { + it('routes Maily JSON to block when editorType is unset or invalid', () => { expect(deriveChatEditorType(mailyBody, undefined, true)).toBe('block'); + expect(deriveChatEditorType(mailyBody, '', true)).toBe('block'); }); it('routes non-empty plain/Liquid bodies to text when editorType is unset', () => { diff --git a/apps/dashboard/src/pages/edit-step-template-v2.tsx b/apps/dashboard/src/pages/edit-step-template-v2.tsx index d6efb5398f3..da78f1ca839 100644 --- a/apps/dashboard/src/pages/edit-step-template-v2.tsx +++ b/apps/dashboard/src/pages/edit-step-template-v2.tsx @@ -1,13 +1,22 @@ -import { ContentIssueEnum, StepResponseDto, StepUpdateDto, WorkflowResponseDto } from '@novu/shared'; +import { + ContentIssueEnum, + FeatureFlagsKeysEnum, + StepResponseDto, + StepTypeEnum, + StepUpdateDto, + WorkflowResponseDto, +} from '@novu/shared'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { PageMeta } from '@/components/page-meta'; import { Form } from '@/components/primitives/form/form'; import { flattenIssues, updateStepInWorkflow } from '@/components/workflow-editor/step-utils'; +import { deriveChatEditorType } from '@/components/workflow-editor/steps/chat/derive-chat-editor-type'; import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; import { StepEditorLayout } from '@/components/workflow-editor/steps/step-editor-layout'; import { UpdateWorkflowFn, useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { useDataRef } from '@/hooks/use-data-ref'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useFormAutosave } from '@/hooks/use-form-autosave'; import { getControlsDefaultValues } from '@/utils/default-values'; @@ -40,6 +49,7 @@ type StepTemplateFormProps = { }; function StepTemplateForm({ workflow, step, update }: StepTemplateFormProps) { + const isChatBlockEditorEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_CHAT_BLOCK_EDITOR_ENABLED); const form = useForm({ defaultValues: getControlsDefaultValues(step), shouldFocusError: false, @@ -88,6 +98,11 @@ function StepTemplateForm({ workflow, step, update }: StepTemplateFormProps) { const { providerOverrides, ...controlValues } = data as Record & { providerOverrides?: StepUpdateDto['providerOverrides']; }; + + if (step.type === StepTypeEnum.CHAT && isChatBlockEditorEnabled) { + controlValues.editorType = deriveChatEditorType(controlValues.body, controlValues.editorType, true); + } + const fp = JSON.stringify({ v: controlValues, po: providerOverrides, diff --git a/libs/application-generic/src/dtos/workflow/chat-control.dto.ts b/libs/application-generic/src/dtos/workflow/chat-control.dto.ts index 21576e9c8ef..0ab87abd022 100644 --- a/libs/application-generic/src/dtos/workflow/chat-control.dto.ts +++ b/libs/application-generic/src/dtos/workflow/chat-control.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString } from 'class-validator'; +import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator'; import { SkipControlDto } from './skip.dto'; export class ChatControlDto extends SkipControlDto { @@ -7,4 +7,15 @@ export class ChatControlDto extends SkipControlDto { @IsString() @IsOptional() body: string; + + @ApiPropertyOptional({ + description: + 'Type of editor to use for the body. When omitted, inferred from the body: Maily JSON is "block", otherwise "text".', + enum: ['block', 'text'], + }) + @ValidateIf((_, value) => value !== undefined && value !== null && value !== '') + @IsIn(['block', 'text']) + @IsString() + @IsOptional() + editorType?: 'block' | 'text'; } diff --git a/libs/application-generic/src/schemas/control/chat-control.schema.ts b/libs/application-generic/src/schemas/control/chat-control.schema.ts index 6ab8bb73733..c962b297b63 100644 --- a/libs/application-generic/src/schemas/control/chat-control.schema.ts +++ b/libs/application-generic/src/schemas/control/chat-control.schema.ts @@ -8,9 +8,10 @@ export const chatControlZodSchema = z .object({ skip: skipZodSchema, body: z.string(), - // Optional with no static default so flag-off orgs never persist editorType. - // When IS_CHAT_BLOCK_EDITOR_ENABLED is on, the dashboard derives 'block' for - // empty/new steps and 'text' for legacy raw bodies. + // Optional with no static default so flag-off orgs never persist editorType + // for empty steps. When a body is present, upsert/sanitize infers 'block' + // from Maily JSON and 'text' from plain/Liquid content — matching email's + // persist-a-valid-editorType behavior without forcing a schema default. editorType: z.enum(['block', 'text']).optional(), }) .strict(); diff --git a/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts b/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts index d8fd3d56429..23502b07b2c 100644 --- a/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -26,7 +26,7 @@ import { StepIssuesDto } from '../../dtos/step-issues.dto'; import { EmailRenderOutput } from '../../dtos/workflow/generate-preview-response.dto'; import { WorkflowResponseDto } from '../../dtos/workflow/workflow-response.dto'; import { Instrument, InstrumentUsecase } from '../../instrumentation'; -import { EmailControlType } from '../../schemas/control'; +import { ChatControlType, EmailControlType } from '../../schemas/control'; import { AnalyticsService } from '../../services'; import { computeWorkflowStatus, @@ -37,6 +37,7 @@ import { slugifyOrRandom, } from '../../utils'; import { isStringifiedMailyJSONContent } from '../../utils/maily-utils'; +import { resolveChatEditorType } from '../../utils/resolve-chat-editor-type'; import { isStepResolverActive } from '../../utils/step-resolver-control-state'; import { NotificationStep } from '../../value-objects'; import { SendWebhookMessage } from '../../webhooks'; @@ -526,6 +527,22 @@ export class UpsertWorkflowUseCase { } } + if ( + step.template?.type === StepTypeEnum.CHAT && + (command.workflowDto.origin === ResourceOriginEnum.NOVU_CLOUD || + command.workflowDto.origin === ResourceOriginEnum.NOVU_CLOUD_V1) && + !isStepResolverActive(step.template?.stepResolverHash) + ) { + const chatControlValues = newControlValues as ChatControlType; + const resolvedEditorType = resolveChatEditorType(chatControlValues.body, chatControlValues.editorType); + + if (resolvedEditorType) { + chatControlValues.editorType = resolvedEditorType; + } else { + delete chatControlValues.editorType; + } + } + return this.upsertControlValuesUseCase.execute( UpsertControlValuesCommand.create({ organizationId: command.user.organizationId, diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts index 8fa6525df16..c10b384a77c 100644 --- a/libs/application-generic/src/utils/index.ts +++ b/libs/application-generic/src/utils/index.ts @@ -36,6 +36,7 @@ export * from './parse-payload-schema'; export * from './parse-step-variables'; export * from './payload'; export * from './provider-overrides'; +export * from './resolve-chat-editor-type'; export * from './safe-set-path'; export * from './sanitize-control-values'; export * from './shorten-environment-name'; diff --git a/libs/application-generic/src/utils/issues.spec.ts b/libs/application-generic/src/utils/issues.spec.ts index 724d8052a05..7a26ccfc805 100644 --- a/libs/application-generic/src/utils/issues.spec.ts +++ b/libs/application-generic/src/utils/issues.spec.ts @@ -1,7 +1,17 @@ import { JsonSchemaTypeEnum } from '@novu/dal'; import { ContentIssueEnum, StepTypeEnum } from '@novu/shared'; +import type { PinoLogger } from 'nestjs-pino'; import { describe, expect, it } from 'vitest'; +import { chatControlSchema } from '../schemas/control'; import { processControlValuesBySchema } from './issues'; +import { dashboardSanitizeControlValues } from './sanitize-control-values'; + +const logger = { error: () => {} } as unknown as PinoLogger; + +const mailyBody = JSON.stringify({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }], +}); describe('processControlValuesBySchema', () => { it('maps additionalProperties failures to UNSUPPORTED_PROPERTY for any strict schema', () => { @@ -28,4 +38,41 @@ describe('processControlValuesBySchema', () => { }, ]); }); + + it('rejects empty-string chat editorType against the control schema', () => { + const issues = processControlValuesBySchema({ + controlSchema: chatControlSchema, + controlValues: { body: mailyBody, editorType: '' }, + stepType: StepTypeEnum.CHAT, + }); + + expect(issues.controls?.editorType?.[0]?.message).toBe('must be equal to one of the allowed values'); + }); + + it('does not flag editorType after sanitizing a Maily chat body with an empty editorType', () => { + const sanitized = dashboardSanitizeControlValues(logger, { body: mailyBody, editorType: '' }, StepTypeEnum.CHAT); + + const issues = processControlValuesBySchema({ + controlSchema: chatControlSchema, + controlValues: sanitized || {}, + stepType: StepTypeEnum.CHAT, + }); + + expect(sanitized).toEqual({ body: mailyBody, editorType: 'block' }); + expect(issues.controls?.editorType).toBeUndefined(); + }); + + it('does not flag editorType for an empty chat step after sanitize', () => { + const sanitized = dashboardSanitizeControlValues(logger, { body: '', editorType: '' }, StepTypeEnum.CHAT); + + const issues = processControlValuesBySchema({ + controlSchema: chatControlSchema, + controlValues: sanitized || {}, + stepType: StepTypeEnum.CHAT, + }); + + expect(sanitized).not.toHaveProperty('editorType'); + expect(issues.controls?.editorType).toBeUndefined(); + expect(issues.controls?.body).toBeDefined(); + }); }); diff --git a/libs/application-generic/src/utils/resolve-chat-editor-type.spec.ts b/libs/application-generic/src/utils/resolve-chat-editor-type.spec.ts new file mode 100644 index 00000000000..77bf05616da --- /dev/null +++ b/libs/application-generic/src/utils/resolve-chat-editor-type.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { resolveChatEditorType } from './resolve-chat-editor-type'; + +const mailyBody = JSON.stringify({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }], +}); + +describe('resolveChatEditorType', () => { + it('prefers an explicit editorType', () => { + expect(resolveChatEditorType('{% if true %}x{% endif %}', 'block')).toBe('block'); + expect(resolveChatEditorType(mailyBody, 'text')).toBe('text'); + }); + + it('maps Maily JSON to block when editorType is unset or invalid', () => { + expect(resolveChatEditorType(mailyBody, undefined)).toBe('block'); + expect(resolveChatEditorType(mailyBody, '')).toBe('block'); + expect(resolveChatEditorType(mailyBody, 'html')).toBe('block'); + }); + + it('maps non-empty plain/Liquid bodies to text when editorType is unset', () => { + expect(resolveChatEditorType('{% if true %}x{% endif %}', undefined)).toBe('text'); + expect(resolveChatEditorType('hello {{payload.foo}}', '')).toBe('text'); + }); + + it('returns undefined for empty bodies when editorType is unset', () => { + expect(resolveChatEditorType('', undefined)).toBeUndefined(); + expect(resolveChatEditorType(undefined, '')).toBeUndefined(); + }); +}); diff --git a/libs/application-generic/src/utils/resolve-chat-editor-type.ts b/libs/application-generic/src/utils/resolve-chat-editor-type.ts new file mode 100644 index 00000000000..7b6acaa4cda --- /dev/null +++ b/libs/application-generic/src/utils/resolve-chat-editor-type.ts @@ -0,0 +1,25 @@ +import { isStringifiedMailyJSONContent } from './maily-utils'; + +export type ChatEditorType = 'block' | 'text'; + +/** + * Resolve a persistable chat `editorType` from control values. + * Explicit `block`/`text` always wins. Otherwise Maily JSON bodies map to + * `block` and any other non-empty string maps to `text`. Empty/missing bodies + * return `undefined` so flag-off orgs do not persist a value. + */ +export function resolveChatEditorType(body: unknown, editorType: unknown): ChatEditorType | undefined { + if (editorType === 'block' || editorType === 'text') { + return editorType; + } + + if (typeof body === 'string' && isStringifiedMailyJSONContent(body)) { + return 'block'; + } + + if (typeof body === 'string' && body.length > 0) { + return 'text'; + } + + return undefined; +} diff --git a/libs/application-generic/src/utils/sanitize-control-values.spec.ts b/libs/application-generic/src/utils/sanitize-control-values.spec.ts index 0351c1b05f3..f0ffcdf09b5 100644 --- a/libs/application-generic/src/utils/sanitize-control-values.spec.ts +++ b/libs/application-generic/src/utils/sanitize-control-values.spec.ts @@ -15,7 +15,7 @@ describe('dashboardSanitizeControlValues', () => { stepType ); - expect(sanitized).toEqual({ body: 'hello', providerOverrides: { slack: { text: 'hi' } } }); + expect(sanitized).toMatchObject({ body: 'hello', providerOverrides: { slack: { text: 'hi' } } }); } ); @@ -31,9 +31,31 @@ describe('dashboardSanitizeControlValues', () => { expect(sanitized).toEqual({ body: 'hello', editorType: 'text' }); }); - it('omits chat editorType when absent', () => { + it('infers chat editorType as text from a plain body when editorType is absent', () => { const sanitized = dashboardSanitizeControlValues(logger, { body: 'hello' }, StepTypeEnum.CHAT); + expect(sanitized).toEqual({ body: 'hello', editorType: 'text' }); + }); + + it('infers chat editorType as block from Maily JSON when editorType is unset or invalid', () => { + const mailyBody = JSON.stringify({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }], + }); + + expect(dashboardSanitizeControlValues(logger, { body: mailyBody, editorType: '' }, StepTypeEnum.CHAT)).toEqual({ + body: mailyBody, + editorType: 'block', + }); + expect(dashboardSanitizeControlValues(logger, { body: mailyBody }, StepTypeEnum.CHAT)).toEqual({ + body: mailyBody, + editorType: 'block', + }); + }); + + it('omits chat editorType when body and editorType are both empty', () => { + const sanitized = dashboardSanitizeControlValues(logger, { body: '', editorType: '' }, StepTypeEnum.CHAT); + expect(sanitized).not.toHaveProperty('editorType'); }); }); diff --git a/libs/application-generic/src/utils/sanitize-control-values.ts b/libs/application-generic/src/utils/sanitize-control-values.ts index 5aa12ccd4de..6e693820f6d 100644 --- a/libs/application-generic/src/utils/sanitize-control-values.ts +++ b/libs/application-generic/src/utils/sanitize-control-values.ts @@ -19,6 +19,7 @@ import { ToolControlType, } from '../schemas/control'; import { InAppActionType, InAppControlType } from '../schemas/control/in-app-control.schema'; +import { resolveChatEditorType } from './resolve-chat-editor-type'; // Cast input T_Type to trigger Ajv validation errors - possible undefined function sanitizeEmptyInput(input: T_Type, defaultValue: T_Type = undefined as unknown as T_Type): T_Type { @@ -139,10 +140,11 @@ function keepProviderOverrides( } function sanitizeChat(controlValues: WithProviderOverrides) { + const editorType = resolveChatEditorType(controlValues.body, controlValues.editorType); const mappedValues: ChatControlType = { body: sanitizeEmptyInput(controlValues.body), skip: controlValues.skip, - editorType: controlValues.editorType, + ...(editorType ? { editorType } : {}), }; return keepProviderOverrides(filterNullishValues(mappedValues) as Record, controlValues);