From 5abdaaa9b523ba23872e684c2ab0544876f8c6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= <2233092+davidsoderberg@users.noreply.github.com> Date: Wed, 28 Aug 2024 12:45:31 +0200 Subject: [PATCH] feat(framework): Add `preferences` to `workflow` builder (#6326) Co-authored-by: Richard Fontein <32132657+rifont@users.noreply.github.com> --- packages/framework/src/resources/workflow.ts | 387 ------------------ .../workflow/build-preferences.test.ts | 178 ++++++++ .../resources/workflow/build-preferences.ts | 30 ++ .../workflow/discover-action-step-factory.ts | 53 +++ .../workflow/discover-channel-step-factory.ts | 59 +++ .../workflow/discover-custom-step-factory.ts | 48 +++ .../resources/workflow/discover-providers.ts | 38 ++ .../src/resources/workflow/discover-step.ts | 10 + .../framework/src/resources/workflow/index.ts | 181 ++++++++ .../workflow/pretty-print-discovery.ts | 20 + .../resources/{ => workflow}/workflow.test.ts | 34 +- .../framework/src/types/discover.types.ts | 14 + .../framework/src/types/workflow.types.ts | 14 + 13 files changed, 677 insertions(+), 389 deletions(-) delete mode 100644 packages/framework/src/resources/workflow.ts create mode 100644 packages/framework/src/resources/workflow/build-preferences.test.ts create mode 100644 packages/framework/src/resources/workflow/build-preferences.ts create mode 100644 packages/framework/src/resources/workflow/discover-action-step-factory.ts create mode 100644 packages/framework/src/resources/workflow/discover-channel-step-factory.ts create mode 100644 packages/framework/src/resources/workflow/discover-custom-step-factory.ts create mode 100644 packages/framework/src/resources/workflow/discover-providers.ts create mode 100644 packages/framework/src/resources/workflow/discover-step.ts create mode 100644 packages/framework/src/resources/workflow/index.ts create mode 100644 packages/framework/src/resources/workflow/pretty-print-discovery.ts rename packages/framework/src/resources/{ => workflow}/workflow.test.ts (92%) diff --git a/packages/framework/src/resources/workflow.ts b/packages/framework/src/resources/workflow.ts deleted file mode 100644 index 81d59ec94c5..00000000000 --- a/packages/framework/src/resources/workflow.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { ActionStepEnum, ChannelStepEnum } from '../constants'; -import { MissingSecretKeyError, StepAlreadyExistsError, WorkflowPayloadInvalidError } from '../errors'; -import { channelStepSchemas, delayActionSchemas, digestActionSchemas, emptySchema, providerSchemas } from '../schemas'; -import type { - Awaitable, - CancelEventTriggerResponse, - CustomStep, - DiscoverStepOutput, - DiscoverWorkflowOutput, - Execute, - FromSchema, - Schema, - StepType, - EventTriggerResponse, - Workflow, - WorkflowOptions, - ChannelStep, - ActionStep, - StepOutput, - FromSchemaUnvalidated, -} from '../types'; -import { WithPassthrough } from '../types/provider.types'; -import { EMOJI, getBridgeUrl, initApiClient, log } from '../utils'; -import { transformSchema, validateData } from '../validators'; - -/** - * Define a new notification workflow. - */ -export function workflow< - T_PayloadSchema extends Schema, - T_ControlSchema extends Schema, - T_PayloadValidated extends Record = FromSchema, - T_PayloadUnvalidated extends Record = FromSchemaUnvalidated, - T_Controls extends Record = FromSchema ->( - workflowId: string, - execute: Execute, - workflowOptions?: WorkflowOptions -): Workflow { - const options = workflowOptions ? workflowOptions : {}; - - const apiClient = initApiClient(process.env.NOVU_SECRET_KEY as string); - - const trigger: Workflow['trigger'] = async (event) => { - if (!process.env.NOVU_SECRET_KEY) { - throw new MissingSecretKeyError(); - } - - const unvalidatedData = (event.payload || {}) as T_PayloadUnvalidated; - let validatedData: T_PayloadValidated; - if (options.payloadSchema) { - const validationResult = await validateData(options.payloadSchema, unvalidatedData); - if (validationResult.success === false) { - throw new WorkflowPayloadInvalidError(workflowId, validationResult.errors); - } - validatedData = validationResult.data; - } else { - // This type coercion provides support to trigger Workflows without a payload schema - validatedData = event.payload as unknown as T_PayloadValidated; - } - const bridgeUrl = await getBridgeUrl(); - - const requestPayload = { - name: workflowId, - to: event.to, - payload: { - ...validatedData, - }, - ...(event.transactionId && { transactionId: event.transactionId }), - ...(event.overrides && { overrides: event.overrides }), - ...(event.actor && { actor: event.actor }), - ...(bridgeUrl && { bridgeUrl }), - }; - - const result = await apiClient.post('/events/trigger', requestPayload); - - const cancel = async () => { - return apiClient.delete(`/events/trigger/${result.transactionId}`); - }; - - return { - cancel, - data: result, - }; - }; - - const newWorkflow: DiscoverWorkflowOutput = { - workflowId, - options: { - ...options, - /* - * TODO: Transformation added for backwards compatibility, remove this additional transform after we - * start using `data.schema` and `control.schema` in UI. - */ - inputSchema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), - controlSchema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), - payloadSchema: transformSchema(options.payloadSchema || emptySchema), - }, - steps: [], - code: execute.toString(), - /** @deprecated */ - data: { - schema: transformSchema(options.payloadSchema || emptySchema), - unknownSchema: options.payloadSchema || emptySchema, - }, - payload: { - schema: transformSchema(options.payloadSchema || emptySchema), - unknownSchema: options.payloadSchema || emptySchema, - }, - /** @deprecated */ - inputs: { - schema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), - unknownSchema: options.controlSchema || options.inputSchema || emptySchema, - }, - controls: { - schema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), - unknownSchema: options.controlSchema || options.inputSchema || emptySchema, - }, - tags: options.tags || [], - execute: execute as Execute, Record>, - }; - - execute({ - payload: {} as T_PayloadValidated, - subscriber: {}, - environment: {}, - controls: {} as T_Controls, - input: {} as T_Controls, - step: { - push: discoverChannelStepFactory( - newWorkflow, - ChannelStepEnum.PUSH, - channelStepSchemas.push.output, - channelStepSchemas.push.result - ), - chat: discoverChannelStepFactory( - newWorkflow, - ChannelStepEnum.CHAT, - channelStepSchemas.chat.output, - channelStepSchemas.chat.result - ), - email: discoverChannelStepFactory( - newWorkflow, - ChannelStepEnum.EMAIL, - channelStepSchemas.email.output, - channelStepSchemas.email.result - ), - sms: discoverChannelStepFactory( - newWorkflow, - ChannelStepEnum.SMS, - channelStepSchemas.sms.output, - channelStepSchemas.sms.result - ), - inApp: discoverChannelStepFactory( - newWorkflow, - ChannelStepEnum.IN_APP, - channelStepSchemas.in_app.output, - channelStepSchemas.in_app.result - ), - digest: discoverActionStepFactory( - newWorkflow, - ActionStepEnum.DIGEST, - digestActionSchemas.output, - digestActionSchemas.result - ), - delay: discoverActionStepFactory( - newWorkflow, - ActionStepEnum.DELAY, - delayActionSchemas.output, - delayActionSchemas.result - ), - custom: discoverCustomStepFactory(newWorkflow, ActionStepEnum.CUSTOM), - } as never, - // eslint-disable-next-line promise/always-return - }).then(() => { - prettyPrintDiscovery(newWorkflow); - }); - - return { - trigger, - definition: newWorkflow, - }; -} - -function discoverChannelStepFactory( - targetWorkflow: DiscoverWorkflowOutput, - type: ChannelStepEnum, - outputSchema: Schema, - resultSchema: Schema - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): ChannelStep { - return async (stepId, resolve, options = {}) => { - const controlSchema = options?.controlSchema || options?.inputSchema || emptySchema; - - const step: DiscoverStepOutput = { - stepId, - type, - inputs: { - schema: transformSchema(controlSchema), - unknownSchema: controlSchema, - }, - controls: { - schema: transformSchema(controlSchema), - unknownSchema: controlSchema, - }, - outputs: { - schema: transformSchema(outputSchema), - unknownSchema: outputSchema, - }, - results: { - schema: transformSchema(resultSchema), - unknownSchema: resultSchema, - }, - resolve: resolve as (controls: Record) => Awaitable>, - code: resolve.toString(), - options, - providers: [], - }; - - discoverStep(targetWorkflow, stepId, step); - - if (Object.keys(options.providers || {}).length > 0) { - discoverProviders(step, type as ChannelStepEnum, options.providers || {}); - } - - return { - _ctx: { - timestamp: Date.now(), - state: { - status: 'pending', - error: false, - }, - }, - }; - }; -} - -function discoverActionStepFactory( - targetWorkflow: DiscoverWorkflowOutput, - type: ActionStepEnum, - outputSchema: Schema, - resultSchema: Schema - // TODO: fix typing for `resolve` to use generic typings - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): ActionStep { - return async (stepId, resolve, options = {}) => { - const controlSchema = options?.controlSchema || options?.inputSchema || emptySchema; - - discoverStep(targetWorkflow, stepId, { - stepId, - type, - inputs: { - schema: transformSchema(controlSchema), - unknownSchema: controlSchema, - }, - controls: { - schema: transformSchema(controlSchema), - unknownSchema: controlSchema, - }, - outputs: { - schema: transformSchema(outputSchema), - unknownSchema: outputSchema, - }, - results: { - schema: transformSchema(resultSchema), - unknownSchema: resultSchema, - }, - resolve: resolve as (controls: Record) => Awaitable>, - code: resolve.toString(), - options, - providers: [], - }); - - return { - _ctx: { - timestamp: Date.now(), - state: { - status: 'pending', - error: false, - }, - }, - }; - }; -} - -function discoverStep(targetWorkflow: DiscoverWorkflowOutput, stepId: string, step: DiscoverStepOutput): void { - if (targetWorkflow.steps.some((workflowStep) => workflowStep.stepId === stepId)) { - throw new StepAlreadyExistsError(stepId); - } else { - targetWorkflow.steps.push(step); - } -} - -function discoverProviders( - step: DiscoverStepOutput, - channelType: ChannelStepEnum, - providers: Record< - string, - ({ - controls, - outputs, - }: { - controls: Record; - outputs: Record; - }) => Awaitable>> - > -): void { - const channelSchemas = providerSchemas[channelType]; - - Object.entries(providers).forEach(([type, resolve]) => { - // eslint-disable-next-line multiline-comment-style - // TODO: fix the typing for `type` to use the keyof providerSchema[channelType] - // @ts-expect-error - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type - const schemas = channelSchemas[type]; - step.providers.push({ - type, - code: resolve.toString(), - resolve, - outputs: { - schema: transformSchema(schemas.output), - unknownSchema: schemas.output, - }, - }); - }); -} - -function discoverCustomStepFactory(targetWorkflow: DiscoverWorkflowOutput, type: StepType): CustomStep { - return async (stepId, resolve, options = {}) => { - const controlSchema = options?.controlSchema || options?.inputSchema || emptySchema; - const outputSchema = options?.outputSchema || emptySchema; - - discoverStep(targetWorkflow, stepId, { - stepId, - type, - inputs: { - schema: transformSchema(controlSchema), - unknownSchema: controlSchema, - }, - controls: { - schema: transformSchema(controlSchema), - unknownSchema: controlSchema, - }, - outputs: { - schema: transformSchema(outputSchema), - unknownSchema: outputSchema, - }, - results: { - schema: transformSchema(outputSchema), - unknownSchema: outputSchema, - }, - resolve: resolve as (controls: Record) => Awaitable>, - code: resolve.toString(), - options, - providers: [], - }); - - return { - _ctx: { - timestamp: Date.now(), - state: { - status: 'pending', - error: false, - }, - }, - // TODO: fix typing for `resolve` to use generic typings - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as Awaited>; - }; -} - -function prettyPrintDiscovery(discoveredWorkflow: DiscoverWorkflowOutput): void { - // eslint-disable-next-line no-console - console.log(`\n${log.bold(log.underline('Discovered workflowId:'))} '${discoveredWorkflow.workflowId}'`); - discoveredWorkflow.steps.forEach((step, i) => { - const isLastStep = i === discoveredWorkflow.steps.length - 1; - const prefix = isLastStep ? '└' : '├'; - // eslint-disable-next-line no-console - console.log(`${prefix} ${EMOJI.STEP} Discovered stepId: '${step.stepId}'\tType: '${step.type}'`); - step.providers.forEach((provider, providerIndex) => { - const isLastProvider = providerIndex === step.providers.length - 1; - const stepPrefix = isLastStep ? ' ' : '│'; - const providerPrefix = isLastProvider ? '└' : '├'; - // eslint-disable-next-line no-console - console.log(`${stepPrefix} ${providerPrefix} ${EMOJI.PROVIDER} Discovered provider: '${provider.type}'`); - }); - }); -} diff --git a/packages/framework/src/resources/workflow/build-preferences.test.ts b/packages/framework/src/resources/workflow/build-preferences.test.ts new file mode 100644 index 00000000000..e73367142f6 --- /dev/null +++ b/packages/framework/src/resources/workflow/build-preferences.test.ts @@ -0,0 +1,178 @@ +import { it, describe, expect } from 'vitest'; +import { buildPreferences } from './build-preferences'; + +describe('build preferences function', () => { + it('should build a default preferences object', () => { + const result = buildPreferences(); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: false }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + + it('should build a default preferences object for a channel', () => { + const result = buildPreferences({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: true }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: true }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + + it('should build a default preferences object for a workflow', () => { + const result = buildPreferences({ + channels: { + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: true }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: true }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + + describe('should build pick up each channel', () => { + it('should build pick up in_app', () => { + const result = buildPreferences({ + channels: { + in_app: { defaultValue: true, readOnly: true }, + }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: true }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + + it('should build pick up email', () => { + const result = buildPreferences({ + channels: { + email: { defaultValue: true, readOnly: true }, + }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: true }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: false }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + + it('should build pick up sms', () => { + const result = buildPreferences({ + channels: { + sms: { defaultValue: true, readOnly: true }, + }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: true }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: false }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + + it('should build pick up push', () => { + const result = buildPreferences({ + channels: { + push: { defaultValue: true, readOnly: true }, + }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: true }, + in_app: { defaultValue: true, readOnly: false }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + + it('should build pick up chat', () => { + const result = buildPreferences({ + channels: { + chat: { defaultValue: true, readOnly: true }, + }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: false }, + chat: { defaultValue: true, readOnly: true }, + }, + }); + }); + }); + + it('should build pick up workflow', () => { + const result = buildPreferences({ + workflow: { defaultValue: true, readOnly: true }, + }); + + expect(result).to.deep.equal({ + workflow: { defaultValue: true, readOnly: true }, + channels: { + email: { defaultValue: true, readOnly: false }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: false }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); +}); diff --git a/packages/framework/src/resources/workflow/build-preferences.ts b/packages/framework/src/resources/workflow/build-preferences.ts new file mode 100644 index 00000000000..309b900f257 --- /dev/null +++ b/packages/framework/src/resources/workflow/build-preferences.ts @@ -0,0 +1,30 @@ +import { ChannelTypeEnum } from '@novu/shared'; +import type { + WorkflowOptionsPreferences, + DiscoverWorkflowOutputPreferences, + ChannelPreference, + WorkflowOptionChannelPreference, +} from '../../types'; + +const setPreference = (preference: WorkflowOptionChannelPreference = {}): ChannelPreference => { + const defaultValue: boolean = preference?.defaultValue !== undefined ? (preference?.defaultValue as boolean) : true; + const readOnly: ChannelPreference['readOnly'] = preference?.readOnly ? preference?.readOnly : false; + + return { + defaultValue, + readOnly, + }; +}; + +export function buildPreferences(preferences?: WorkflowOptionsPreferences): DiscoverWorkflowOutputPreferences { + return { + workflow: setPreference(preferences?.workflow), + channels: { + [ChannelTypeEnum.EMAIL]: setPreference(preferences?.channels?.[ChannelTypeEnum.EMAIL]), + [ChannelTypeEnum.SMS]: setPreference(preferences?.channels?.[ChannelTypeEnum.SMS]), + [ChannelTypeEnum.PUSH]: setPreference(preferences?.channels?.[ChannelTypeEnum.PUSH]), + [ChannelTypeEnum.IN_APP]: setPreference(preferences?.channels?.[ChannelTypeEnum.IN_APP]), + [ChannelTypeEnum.CHAT]: setPreference(preferences?.channels?.[ChannelTypeEnum.CHAT]), + }, + }; +} diff --git a/packages/framework/src/resources/workflow/discover-action-step-factory.ts b/packages/framework/src/resources/workflow/discover-action-step-factory.ts new file mode 100644 index 00000000000..be75d3c82f5 --- /dev/null +++ b/packages/framework/src/resources/workflow/discover-action-step-factory.ts @@ -0,0 +1,53 @@ +import { ActionStepEnum } from '../../constants'; +import { emptySchema } from '../../schemas'; +import type { Awaitable, DiscoverWorkflowOutput, Schema, ActionStep } from '../../types'; +import { transformSchema } from '../../validators'; +import { discoverStep } from './discover-step'; + +export function discoverActionStepFactory( + targetWorkflow: DiscoverWorkflowOutput, + type: ActionStepEnum, + outputSchema: Schema, + resultSchema: Schema + // TODO: fix typing for `resolve` to use generic typings + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): ActionStep { + return async (stepId, resolve, options = {}) => { + const controlSchema = options?.controlSchema || options?.inputSchema || emptySchema; + + discoverStep(targetWorkflow, stepId, { + stepId, + type, + inputs: { + schema: transformSchema(controlSchema), + unknownSchema: controlSchema, + }, + controls: { + schema: transformSchema(controlSchema), + unknownSchema: controlSchema, + }, + outputs: { + schema: transformSchema(outputSchema), + unknownSchema: outputSchema, + }, + results: { + schema: transformSchema(resultSchema), + unknownSchema: resultSchema, + }, + resolve: resolve as (controls: Record) => Awaitable>, + code: resolve.toString(), + options, + providers: [], + }); + + return { + _ctx: { + timestamp: Date.now(), + state: { + status: 'pending', + error: false, + }, + }, + }; + }; +} diff --git a/packages/framework/src/resources/workflow/discover-channel-step-factory.ts b/packages/framework/src/resources/workflow/discover-channel-step-factory.ts new file mode 100644 index 00000000000..712d43b5025 --- /dev/null +++ b/packages/framework/src/resources/workflow/discover-channel-step-factory.ts @@ -0,0 +1,59 @@ +import { ChannelStepEnum } from '../../constants'; +import { emptySchema } from '../../schemas'; +import type { Awaitable, DiscoverStepOutput, DiscoverWorkflowOutput, Schema, ChannelStep } from '../../types'; +import { transformSchema } from '../../validators'; +import { discoverProviders } from './discover-providers'; +import { discoverStep } from './discover-step'; + +export function discoverChannelStepFactory( + targetWorkflow: DiscoverWorkflowOutput, + type: ChannelStepEnum, + outputSchema: Schema, + resultSchema: Schema + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): ChannelStep { + return async (stepId, resolve, options = {}) => { + const controlSchema = options?.controlSchema || options?.inputSchema || emptySchema; + + const step: DiscoverStepOutput = { + stepId, + type, + inputs: { + schema: transformSchema(controlSchema), + unknownSchema: controlSchema, + }, + controls: { + schema: transformSchema(controlSchema), + unknownSchema: controlSchema, + }, + outputs: { + schema: transformSchema(outputSchema), + unknownSchema: outputSchema, + }, + results: { + schema: transformSchema(resultSchema), + unknownSchema: resultSchema, + }, + resolve: resolve as (controls: Record) => Awaitable>, + code: resolve.toString(), + options, + providers: [], + }; + + discoverStep(targetWorkflow, stepId, step); + + if (Object.keys(options.providers || {}).length > 0) { + discoverProviders(step, type as ChannelStepEnum, options.providers || {}); + } + + return { + _ctx: { + timestamp: Date.now(), + state: { + status: 'pending', + error: false, + }, + }, + }; + }; +} diff --git a/packages/framework/src/resources/workflow/discover-custom-step-factory.ts b/packages/framework/src/resources/workflow/discover-custom-step-factory.ts new file mode 100644 index 00000000000..821f352d281 --- /dev/null +++ b/packages/framework/src/resources/workflow/discover-custom-step-factory.ts @@ -0,0 +1,48 @@ +import { emptySchema } from '../../schemas'; +import type { Awaitable, CustomStep, DiscoverWorkflowOutput, StepType, StepOutput } from '../../types'; +import { transformSchema } from '../../validators'; +import { discoverStep } from './discover-step'; + +export function discoverCustomStepFactory(targetWorkflow: DiscoverWorkflowOutput, type: StepType): CustomStep { + return async (stepId, resolve, options = {}) => { + const controlSchema = options?.controlSchema || options?.inputSchema || emptySchema; + const outputSchema = options?.outputSchema || emptySchema; + + discoverStep(targetWorkflow, stepId, { + stepId, + type, + inputs: { + schema: transformSchema(controlSchema), + unknownSchema: controlSchema, + }, + controls: { + schema: transformSchema(controlSchema), + unknownSchema: controlSchema, + }, + outputs: { + schema: transformSchema(outputSchema), + unknownSchema: outputSchema, + }, + results: { + schema: transformSchema(outputSchema), + unknownSchema: outputSchema, + }, + resolve: resolve as (controls: Record) => Awaitable>, + code: resolve.toString(), + options, + providers: [], + }); + + return { + _ctx: { + timestamp: Date.now(), + state: { + status: 'pending', + error: false, + }, + }, + // TODO: fix typing for `resolve` to use generic typings + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Awaited>; + }; +} diff --git a/packages/framework/src/resources/workflow/discover-providers.ts b/packages/framework/src/resources/workflow/discover-providers.ts new file mode 100644 index 00000000000..14fd45cf66e --- /dev/null +++ b/packages/framework/src/resources/workflow/discover-providers.ts @@ -0,0 +1,38 @@ +import { ChannelStepEnum } from '../../constants'; +import { providerSchemas } from '../../schemas'; +import type { Awaitable, DiscoverStepOutput } from '../../types'; +import { WithPassthrough } from '../../types/provider.types'; +import { transformSchema } from '../../validators'; + +export function discoverProviders( + step: DiscoverStepOutput, + channelType: ChannelStepEnum, + providers: Record< + string, + ({ + controls, + outputs, + }: { + controls: Record; + outputs: Record; + }) => Awaitable>> + > +): void { + const channelSchemas = providerSchemas[channelType]; + + Object.entries(providers).forEach(([type, resolve]) => { + // eslint-disable-next-line multiline-comment-style + // TODO: fix the typing for `type` to use the keyof providerSchema[channelType] + // @ts-expect-error - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type + const schemas = channelSchemas[type]; + step.providers.push({ + type, + code: resolve.toString(), + resolve, + outputs: { + schema: transformSchema(schemas.output), + unknownSchema: schemas.output, + }, + }); + }); +} diff --git a/packages/framework/src/resources/workflow/discover-step.ts b/packages/framework/src/resources/workflow/discover-step.ts new file mode 100644 index 00000000000..161a61b25d1 --- /dev/null +++ b/packages/framework/src/resources/workflow/discover-step.ts @@ -0,0 +1,10 @@ +import { StepAlreadyExistsError } from '../../errors'; +import type { DiscoverStepOutput, DiscoverWorkflowOutput } from '../../types'; + +export function discoverStep(targetWorkflow: DiscoverWorkflowOutput, stepId: string, step: DiscoverStepOutput): void { + if (targetWorkflow.steps.some((workflowStep) => workflowStep.stepId === stepId)) { + throw new StepAlreadyExistsError(stepId); + } else { + targetWorkflow.steps.push(step); + } +} diff --git a/packages/framework/src/resources/workflow/index.ts b/packages/framework/src/resources/workflow/index.ts new file mode 100644 index 00000000000..ca4203c468b --- /dev/null +++ b/packages/framework/src/resources/workflow/index.ts @@ -0,0 +1,181 @@ +import { ActionStepEnum, ChannelStepEnum } from '../../constants'; +import { MissingSecretKeyError, WorkflowPayloadInvalidError } from '../../errors'; +import { channelStepSchemas, delayActionSchemas, digestActionSchemas, emptySchema } from '../../schemas'; +import type { + CancelEventTriggerResponse, + DiscoverWorkflowOutput, + Execute, + FromSchema, + Schema, + EventTriggerResponse, + Workflow, + WorkflowOptions, + FromSchemaUnvalidated, +} from '../../types'; +import { getBridgeUrl, initApiClient } from '../../utils'; +import { transformSchema, validateData } from '../../validators'; +import { discoverActionStepFactory } from './discover-action-step-factory'; +import { discoverChannelStepFactory } from './discover-channel-step-factory'; +import { discoverCustomStepFactory } from './discover-custom-step-factory'; +import { prettyPrintDiscovery } from './pretty-print-discovery'; +import { buildPreferences } from './build-preferences'; + +/** + * Define a new notification workflow. + */ +export function workflow< + T_PayloadSchema extends Schema, + T_ControlSchema extends Schema, + T_PayloadValidated extends Record = FromSchema, + T_PayloadUnvalidated extends Record = FromSchemaUnvalidated, + T_Controls extends Record = FromSchema +>( + workflowId: string, + execute: Execute, + workflowOptions?: WorkflowOptions +): Workflow { + const options = workflowOptions ? workflowOptions : {}; + + const apiClient = initApiClient(process.env.NOVU_SECRET_KEY as string); + + const trigger: Workflow['trigger'] = async (event) => { + if (!process.env.NOVU_SECRET_KEY) { + throw new MissingSecretKeyError(); + } + + const unvalidatedData = (event.payload || {}) as T_PayloadUnvalidated; + let validatedData: T_PayloadValidated; + if (options.payloadSchema) { + const validationResult = await validateData(options.payloadSchema, unvalidatedData); + if (validationResult.success === false) { + throw new WorkflowPayloadInvalidError(workflowId, validationResult.errors); + } + validatedData = validationResult.data; + } else { + // This type coercion provides support to trigger Workflows without a payload schema + validatedData = event.payload as unknown as T_PayloadValidated; + } + const bridgeUrl = await getBridgeUrl(); + + const requestPayload = { + name: workflowId, + to: event.to, + payload: { + ...validatedData, + }, + ...(event.transactionId && { transactionId: event.transactionId }), + ...(event.overrides && { overrides: event.overrides }), + ...(event.actor && { actor: event.actor }), + ...(bridgeUrl && { bridgeUrl }), + }; + + const result = await apiClient.post('/events/trigger', requestPayload); + + const cancel = async () => { + return apiClient.delete(`/events/trigger/${result.transactionId}`); + }; + + return { + cancel, + data: result, + }; + }; + + const newWorkflow: DiscoverWorkflowOutput = { + workflowId, + options: { + ...options, + /* + * TODO: Transformation added for backwards compatibility, remove this additional transform after we + * start using `data.schema` and `control.schema` in UI. + */ + inputSchema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), + controlSchema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), + payloadSchema: transformSchema(options.payloadSchema || emptySchema), + }, + steps: [], + code: execute.toString(), + /** @deprecated */ + data: { + schema: transformSchema(options.payloadSchema || emptySchema), + unknownSchema: options.payloadSchema || emptySchema, + }, + payload: { + schema: transformSchema(options.payloadSchema || emptySchema), + unknownSchema: options.payloadSchema || emptySchema, + }, + /** @deprecated */ + inputs: { + schema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), + unknownSchema: options.controlSchema || options.inputSchema || emptySchema, + }, + controls: { + schema: transformSchema(options.controlSchema || options.inputSchema || emptySchema), + unknownSchema: options.controlSchema || options.inputSchema || emptySchema, + }, + tags: options.tags || [], + preferences: buildPreferences(options.preferences), + execute: execute as Execute, Record>, + }; + + execute({ + payload: {} as T_PayloadValidated, + subscriber: {}, + environment: {}, + controls: {} as T_Controls, + input: {} as T_Controls, + step: { + push: discoverChannelStepFactory( + newWorkflow, + ChannelStepEnum.PUSH, + channelStepSchemas.push.output, + channelStepSchemas.push.result + ), + chat: discoverChannelStepFactory( + newWorkflow, + ChannelStepEnum.CHAT, + channelStepSchemas.chat.output, + channelStepSchemas.chat.result + ), + email: discoverChannelStepFactory( + newWorkflow, + ChannelStepEnum.EMAIL, + channelStepSchemas.email.output, + channelStepSchemas.email.result + ), + sms: discoverChannelStepFactory( + newWorkflow, + ChannelStepEnum.SMS, + channelStepSchemas.sms.output, + channelStepSchemas.sms.result + ), + inApp: discoverChannelStepFactory( + newWorkflow, + ChannelStepEnum.IN_APP, + channelStepSchemas.in_app.output, + channelStepSchemas.in_app.result + ), + digest: discoverActionStepFactory( + newWorkflow, + ActionStepEnum.DIGEST, + digestActionSchemas.output, + digestActionSchemas.result + ), + delay: discoverActionStepFactory( + newWorkflow, + ActionStepEnum.DELAY, + delayActionSchemas.output, + delayActionSchemas.result + ), + custom: discoverCustomStepFactory(newWorkflow, ActionStepEnum.CUSTOM), + } as never, + // eslint-disable-next-line promise/always-return + }).then(() => { + prettyPrintDiscovery(newWorkflow); + }); + + return { + trigger, + definition: newWorkflow, + }; +} diff --git a/packages/framework/src/resources/workflow/pretty-print-discovery.ts b/packages/framework/src/resources/workflow/pretty-print-discovery.ts new file mode 100644 index 00000000000..53091acfe9e --- /dev/null +++ b/packages/framework/src/resources/workflow/pretty-print-discovery.ts @@ -0,0 +1,20 @@ +import type { DiscoverWorkflowOutput } from '../../types'; +import { EMOJI, log } from '../../utils'; + +export function prettyPrintDiscovery(discoveredWorkflow: DiscoverWorkflowOutput): void { + // eslint-disable-next-line no-console + console.log(`\n${log.bold(log.underline('Discovered workflowId:'))} '${discoveredWorkflow.workflowId}'`); + discoveredWorkflow.steps.forEach((step, i) => { + const isLastStep = i === discoveredWorkflow.steps.length - 1; + const prefix = isLastStep ? '└' : '├'; + // eslint-disable-next-line no-console + console.log(`${prefix} ${EMOJI.STEP} Discovered stepId: '${step.stepId}'\tType: '${step.type}'`); + step.providers.forEach((provider, providerIndex) => { + const isLastProvider = providerIndex === step.providers.length - 1; + const stepPrefix = isLastStep ? ' ' : '│'; + const providerPrefix = isLastProvider ? '└' : '├'; + // eslint-disable-next-line no-console + console.log(`${stepPrefix} ${providerPrefix} ${EMOJI.PROVIDER} Discovered provider: '${provider.type}'`); + }); + }); +} diff --git a/packages/framework/src/resources/workflow.test.ts b/packages/framework/src/resources/workflow/workflow.test.ts similarity index 92% rename from packages/framework/src/resources/workflow.test.ts rename to packages/framework/src/resources/workflow/workflow.test.ts index 1d2aa056181..35a3bc7a1cf 100644 --- a/packages/framework/src/resources/workflow.test.ts +++ b/packages/framework/src/resources/workflow/workflow.test.ts @@ -1,6 +1,6 @@ import { it, describe, beforeEach, expect, vi, afterEach } from 'vitest'; -import { MissingSecretKeyError } from '../errors'; -import { workflow } from './workflow'; +import { MissingSecretKeyError } from '../../errors'; +import { workflow } from '.'; describe('workflow function', () => { describe('Type tests', () => { @@ -99,6 +99,36 @@ describe('workflow function', () => { }); }); + it('should include preferences', async () => { + const { definition } = workflow( + 'setup-workflow', + async ({ step }) => { + await step.email('send-email', async () => ({ + subject: 'Test Subject', + body: 'Test Body', + })); + }, + { + preferences: { + channels: { + email: { defaultValue: true, readOnly: true }, + }, + }, + } + ); + + expect(definition.preferences).to.deep.equal({ + workflow: { defaultValue: true, readOnly: false }, + channels: { + email: { defaultValue: true, readOnly: true }, + sms: { defaultValue: true, readOnly: false }, + push: { defaultValue: true, readOnly: false }, + in_app: { defaultValue: true, readOnly: false }, + chat: { defaultValue: true, readOnly: false }, + }, + }); + }); + describe('trigger', () => { beforeEach(() => { process.env.NOVU_SECRET_KEY = 'test'; diff --git a/packages/framework/src/types/discover.types.ts b/packages/framework/src/types/discover.types.ts index faf064867c0..fe1bf7ff0d7 100644 --- a/packages/framework/src/types/discover.types.ts +++ b/packages/framework/src/types/discover.types.ts @@ -5,6 +5,7 @@ import type { Execute, WorkflowOptions } from './workflow.types'; import type { Awaitable, Prettify } from './util.types'; import type { EventTriggerParams, EventTriggerResult } from './event.types'; import type { WithPassthrough } from './provider.types'; +import { ChannelTypeEnum } from '@novu/shared'; export type StepType = `${ChannelStepEnum | ActionStepEnum}`; @@ -49,6 +50,18 @@ export type DiscoverStepOutput = { options: StepOptions; }; +export type ChannelPreference = { + defaultValue: boolean; + readOnly: boolean; +}; + +export type DiscoverWorkflowOutputPreferences = { + workflow: ChannelPreference; + channels: { + [key in (typeof ChannelTypeEnum)[keyof typeof ChannelTypeEnum]]: ChannelPreference; + }; +}; + export type DiscoverWorkflowOutput = { workflowId: string; execute: Execute, Record>; @@ -73,6 +86,7 @@ export type DiscoverWorkflowOutput = { schema: JsonSchema; unknownSchema: Schema; }; + preferences: DiscoverWorkflowOutputPreferences; tags: string[]; }; diff --git a/packages/framework/src/types/workflow.types.ts b/packages/framework/src/types/workflow.types.ts index aa8c5d28e01..bbad736ccc6 100644 --- a/packages/framework/src/types/workflow.types.ts +++ b/packages/framework/src/types/workflow.types.ts @@ -2,6 +2,7 @@ import type { Step } from './step.types'; import type { Subscriber } from './subscriber.types'; import type { Prettify } from './util.types'; import type { Schema } from './schema.types'; +import { ChannelTypeEnum } from '@novu/shared'; /** * The parameters for the workflow function. @@ -32,6 +33,18 @@ export type Execute, T_Controls extend event: ExecuteInput ) => Promise; +export type WorkflowOptionChannelPreference = { + defaultValue?: boolean; + readOnly?: boolean; +}; + +export type WorkflowOptionsPreferences = { + workflow?: WorkflowOptionChannelPreference; + channels?: { + [key in (typeof ChannelTypeEnum)[keyof typeof ChannelTypeEnum]]?: WorkflowOptionChannelPreference; + }; +}; + /** * The options for the workflow. */ @@ -45,5 +58,6 @@ export type WorkflowOptions