diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index e318eec74c..6c8b76486c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { inputSchemaToOutputSchema } from 'twenty-shared/logic-function'; import { FieldMetadataType, StepLogicalOperator, @@ -33,6 +34,7 @@ import { } from 'src/modules/workflow/common/exceptions/workflow-version-step.exception'; import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service'; import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; import { @@ -220,6 +222,23 @@ export class WorkflowVersionStepOperationsWorkspaceService { flatLogicFunctionMaps, }); + const declaredOutputSchema = + flatLogicFunction.workflowActionTriggerSettings?.outputSchema; + + const initialOutputSchema: OutputSchema = isDefined( + declaredOutputSchema, + ) + ? inputSchemaToOutputSchema(declaredOutputSchema) + : { + link: { + isLeaf: true, + icon: 'IconVariable', + tab: 'test', + label: 'Generate Function Output', + }, + _outputSchemaType: 'LINK', + }; + return { builtStep: { ...baseStep, @@ -229,15 +248,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { type: WorkflowActionType.LOGIC_FUNCTION, settings: { ...BASE_STEP_DEFINITION, - outputSchema: { - link: { - isLeaf: true, - icon: 'IconVariable', - tab: 'test', - label: 'Generate Function Output', - }, - _outputSchemaType: 'LINK', - }, + outputSchema: initialOutputSchema, input: { logicFunctionId, logicFunctionInput: isDefined( diff --git a/packages/twenty-shared/src/logic-function/__tests__/input-schema-to-output-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/input-schema-to-output-schema.test.ts new file mode 100644 index 0000000000..1a71a3489f --- /dev/null +++ b/packages/twenty-shared/src/logic-function/__tests__/input-schema-to-output-schema.test.ts @@ -0,0 +1,139 @@ +import { inputSchemaToOutputSchema } from '@/logic-function/input-schema-to-output-schema'; + +describe('inputSchemaToOutputSchema', () => { + it('should convert a flat object schema to a BaseOutputSchemaV2', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + success: { type: 'boolean' as const }, + message: { type: 'string' as const }, + error: { type: 'string' as const }, + messageId: { type: 'string' as const }, + channelId: { type: 'string' as const }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + success: { isLeaf: true, type: 'boolean', label: 'success', value: null }, + message: { isLeaf: true, type: 'string', label: 'message', value: null }, + error: { isLeaf: true, type: 'string', label: 'error', value: null }, + messageId: { + isLeaf: true, + type: 'string', + label: 'messageId', + value: null, + }, + channelId: { + isLeaf: true, + type: 'string', + label: 'channelId', + value: null, + }, + }); + }); + + it('should convert nested object properties into Node entries', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + user: { + type: 'object' as const, + properties: { + name: { type: 'string' as const }, + age: { type: 'number' as const }, + }, + }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + user: { + isLeaf: false, + type: 'object', + label: 'user', + value: { + name: { isLeaf: true, type: 'string', label: 'name', value: null }, + age: { isLeaf: true, type: 'number', label: 'age', value: null }, + }, + }, + }); + }); + + it('should treat arrays as leaves with type "array"', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + tags: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + tags: { isLeaf: true, type: 'array', label: 'tags', value: null }, + }); + }); + + it('should map non-leaf, non-object property types to "unknown"', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + richText: { type: 'RICH_TEXT' as const as 'string' }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + richText: { + isLeaf: true, + type: 'unknown', + label: 'richText', + value: null, + }, + }); + }); + + it('should respect declared labels when present', () => { + const inputSchema = [ + { + type: 'object' as const, + properties: { + messageId: { type: 'string' as const, label: 'Message ID' }, + }, + }, + ]; + + expect(inputSchemaToOutputSchema(inputSchema)).toEqual({ + messageId: { + isLeaf: true, + type: 'string', + label: 'Message ID', + value: null, + }, + }); + }); + + it('should return an empty schema when the input is empty', () => { + expect(inputSchemaToOutputSchema([])).toEqual({}); + }); + + it('should return an empty schema when the root is not an object', () => { + expect(inputSchemaToOutputSchema([{ type: 'string' as const }])).toEqual( + {}, + ); + }); + + it('should return an empty schema when the root object has no properties', () => { + expect(inputSchemaToOutputSchema([{ type: 'object' as const }])).toEqual( + {}, + ); + }); +}); diff --git a/packages/twenty-shared/src/logic-function/index.ts b/packages/twenty-shared/src/logic-function/index.ts index 31713ac7c8..19455750d7 100644 --- a/packages/twenty-shared/src/logic-function/index.ts +++ b/packages/twenty-shared/src/logic-function/index.ts @@ -11,4 +11,5 @@ export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema'; export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code'; export { getOutputSchemaFromValue } from './get-output-schema-from-value'; export type { InputJsonSchema } from './input-json-schema.type'; +export { inputSchemaToOutputSchema } from './input-schema-to-output-schema'; export { jsonSchemaToInputSchema } from './json-schema-to-input-schema'; diff --git a/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts new file mode 100644 index 0000000000..f350343016 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts @@ -0,0 +1,73 @@ +import { + type InputSchema, + type InputSchemaProperty, +} from '@/workflow/types/InputSchema'; +import { + type BaseOutputSchemaV2, + type Leaf, + type LeafType, + type Node, +} from '@/workflow/workflow-schema/types/base-output-schema.type'; +import { isObject } from '@sniptt/guards'; + +const LEAF_TYPES: LeafType[] = [ + 'string', + 'number', + 'boolean', + 'array', + 'unknown', +]; + +const isLeafType = (type: string): type is LeafType => { + return (LEAF_TYPES as string[]).includes(type); +}; + +const convertProperty = ( + key: string, + property: InputSchemaProperty, +): Leaf | Node => { + const label = property.label ?? key; + + if (property.type === 'object') { + return { + isLeaf: false, + type: 'object', + label, + value: isObject(property.properties) + ? convertProperties(property.properties) + : {}, + }; + } + + return { + isLeaf: true, + type: isLeafType(property.type) ? property.type : 'unknown', + label, + value: null, + }; +}; + +const convertProperties = ( + properties: Record, +): BaseOutputSchemaV2 => { + return Object.entries(properties).reduce( + (acc, [key, value]) => { + acc[key] = convertProperty(key, value); + + return acc; + }, + {}, + ); +}; + +export const inputSchemaToOutputSchema = ( + inputSchema: InputSchema, +): BaseOutputSchemaV2 => { + const root = inputSchema[0]; + + if (root?.type !== 'object' || !isObject(root.properties)) { + return {}; + } + + return convertProperties(root.properties); +};