import { z } from 'zod'; import { WorkflowNode, WorkflowConnection, Workflow } from '../types/n8n-api'; // Zod schemas for n8n API validation export const workflowNodeSchema = z.object({ id: z.string(), name: z.string(), type: z.string(), typeVersion: z.number(), position: z.tuple([z.number(), z.number()]), parameters: z.record(z.unknown()), credentials: z.record(z.unknown()).optional(), disabled: z.boolean().optional(), notes: z.string().optional(), notesInFlow: z.boolean().optional(), continueOnFail: z.boolean().optional(), retryOnFail: z.boolean().optional(), maxTries: z.number().optional(), waitBetweenTries: z.number().optional(), alwaysOutputData: z.boolean().optional(), executeOnce: z.boolean().optional(), }); export const workflowConnectionSchema = z.record( z.object({ main: z.array( z.array( z.object({ node: z.string(), type: z.string(), index: z.number(), }) ) ), }) ); export const workflowSettingsSchema = z.object({ executionOrder: z.enum(['v0', 'v1']).default('v1'), timezone: z.string().optional(), saveDataErrorExecution: z.enum(['all', 'none']).default('all'), saveDataSuccessExecution: z.enum(['all', 'none']).default('all'), saveManualExecutions: z.boolean().default(true), saveExecutionProgress: z.boolean().default(true), executionTimeout: z.number().optional(), errorWorkflow: z.string().optional(), callerPolicy: z.enum(['any', 'workflowsFromSameOwner', 'workflowsFromAList']).optional(), }); // Default settings for workflow creation export const defaultWorkflowSettings = { executionOrder: 'v1' as const, saveDataErrorExecution: 'all' as const, saveDataSuccessExecution: 'all' as const, saveManualExecutions: true, saveExecutionProgress: true, }; // Validation functions export function validateWorkflowNode(node: unknown): WorkflowNode { return workflowNodeSchema.parse(node); } export function validateWorkflowConnections(connections: unknown): WorkflowConnection { return workflowConnectionSchema.parse(connections); } export function validateWorkflowSettings(settings: unknown): z.infer { return workflowSettingsSchema.parse(settings); } // Clean workflow data for API operations export function cleanWorkflowForCreate(workflow: Partial): Partial { const { // Remove read-only fields id, createdAt, updatedAt, versionId, meta, // Remove fields that cause API errors during creation active, tags, // Keep everything else ...cleanedWorkflow } = workflow; // Ensure settings are present with defaults if (!cleanedWorkflow.settings) { cleanedWorkflow.settings = defaultWorkflowSettings; } return cleanedWorkflow; } /** * Clean workflow data for update operations. * * This function removes read-only and computed fields that should not be sent * in API update requests. It does NOT add any default values or new fields. * * Note: Unlike cleanWorkflowForCreate, this function does not add default settings. * The n8n API will reject update requests that include properties not present in * the original workflow ("settings must NOT have additional properties" error). * * Settings are filtered to only include whitelisted properties to prevent API * errors when workflows from n8n contain UI-only or deprecated properties. * * @param workflow - The workflow object to clean * @returns A cleaned partial workflow suitable for API updates */ export function cleanWorkflowForUpdate(workflow: Workflow): Partial { const { // Remove read-only/computed fields id, createdAt, updatedAt, versionId, meta, staticData, // Remove fields that cause API errors pinData, tags, // Remove additional fields that n8n API doesn't accept isArchived, usedCredentials, sharedWithProjects, triggerCount, shared, active, // Keep everything else ...cleanedWorkflow } = workflow as any; // CRITICAL FIX for Issue #248: // The n8n API has version-specific behavior for settings in workflow updates: // // PROBLEM: // - Some versions reject updates with settings properties (community forum reports) // - Cloud versions REQUIRE settings property to be present (n8n.estyl.team) // - Properties like callerPolicy cause "additional properties" errors // // SOLUTION: // - Filter settings to only include whitelisted properties (OpenAPI spec) // - If no settings provided, use empty object {} for safety // - Empty object satisfies "required property" validation (cloud API) // - Whitelisted properties prevent "additional properties" errors // // References: // - https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916 // - OpenAPI spec: workflowSettings schema // - Tested on n8n.estyl.team (cloud) and localhost (self-hosted) // Whitelisted settings properties from n8n OpenAPI spec const safeSettingsProperties = [ 'saveExecutionProgress', 'saveManualExecutions', 'saveDataErrorExecution', 'saveDataSuccessExecution', 'executionTimeout', 'errorWorkflow', 'timezone', 'executionOrder' ]; if (cleanedWorkflow.settings && typeof cleanedWorkflow.settings === 'object') { // Filter to only safe properties const filteredSettings: any = {}; for (const key of safeSettingsProperties) { if (key in cleanedWorkflow.settings) { filteredSettings[key] = (cleanedWorkflow.settings as any)[key]; } } cleanedWorkflow.settings = filteredSettings; } else { // No settings provided - use empty object for safety cleanedWorkflow.settings = {}; } return cleanedWorkflow; } // Validate workflow structure export function validateWorkflowStructure(workflow: Partial): string[] { const errors: string[] = []; // Check required fields if (!workflow.name) { errors.push('Workflow name is required'); } if (!workflow.nodes || workflow.nodes.length === 0) { errors.push('Workflow must have at least one node'); } if (!workflow.connections) { errors.push('Workflow connections are required'); } // Check for minimum viable workflow if (workflow.nodes && workflow.nodes.length === 1) { const singleNode = workflow.nodes[0]; const isWebhookOnly = singleNode.type === 'n8n-nodes-base.webhook' || singleNode.type === 'n8n-nodes-base.webhookTrigger'; if (!isWebhookOnly) { errors.push('Single-node workflows are only valid for webhooks. Add at least one more node and connect them. Example: Manual Trigger → Set node'); } } // Check for empty connections in multi-node workflows if (workflow.nodes && workflow.nodes.length > 1 && workflow.connections) { const connectionCount = Object.keys(workflow.connections).length; if (connectionCount === 0) { errors.push('Multi-node workflow has empty connections. Connect nodes like this: connections: { "Node1 Name": { "main": [[{ "node": "Node2 Name", "type": "main", "index": 0 }]] } }'); } } // Validate nodes if (workflow.nodes) { workflow.nodes.forEach((node, index) => { try { validateWorkflowNode(node); // Additional check for common node type mistakes if (node.type.startsWith('nodes-base.')) { errors.push(`Invalid node type "${node.type}" at index ${index}. Use "n8n-nodes-base.${node.type.substring(11)}" instead.`); } else if (!node.type.includes('.')) { errors.push(`Invalid node type "${node.type}" at index ${index}. Node types must include package prefix (e.g., "n8n-nodes-base.webhook").`); } } catch (error) { errors.push(`Invalid node at index ${index}: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); } // Validate connections if (workflow.connections) { try { validateWorkflowConnections(workflow.connections); } catch (error) { errors.push(`Invalid connections: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Validate that all connection references exist and use node NAMES (not IDs) if (workflow.nodes && workflow.connections) { const nodeNames = new Set(workflow.nodes.map(node => node.name)); const nodeIds = new Set(workflow.nodes.map(node => node.id)); const nodeIdToName = new Map(workflow.nodes.map(node => [node.id, node.name])); Object.entries(workflow.connections).forEach(([sourceName, connection]) => { // Check if source exists by name (correct) if (!nodeNames.has(sourceName)) { // Check if they're using an ID instead of name if (nodeIds.has(sourceName)) { const correctName = nodeIdToName.get(sourceName); errors.push(`Connection uses node ID '${sourceName}' but must use node name '${correctName}'. Change connections.${sourceName} to connections['${correctName}']`); } else { errors.push(`Connection references non-existent node: ${sourceName}`); } } if (connection.main && Array.isArray(connection.main)) { connection.main.forEach((outputs, outputIndex) => { if (Array.isArray(outputs)) { outputs.forEach((target, targetIndex) => { // Check if target exists by name (correct) if (!nodeNames.has(target.node)) { // Check if they're using an ID instead of name if (nodeIds.has(target.node)) { const correctName = nodeIdToName.get(target.node); errors.push(`Connection target uses node ID '${target.node}' but must use node name '${correctName}' (from ${sourceName}[${outputIndex}][${targetIndex}])`); } else { errors.push(`Connection references non-existent target node: ${target.node} (from ${sourceName}[${outputIndex}][${targetIndex}])`); } } }); } }); } }); } return errors; } // Check if workflow has webhook trigger export function hasWebhookTrigger(workflow: Workflow): boolean { return workflow.nodes.some(node => node.type === 'n8n-nodes-base.webhook' || node.type === 'n8n-nodes-base.webhookTrigger' ); } // Get webhook URL from workflow export function getWebhookUrl(workflow: Workflow): string | null { const webhookNode = workflow.nodes.find(node => node.type === 'n8n-nodes-base.webhook' || node.type === 'n8n-nodes-base.webhookTrigger' ); if (!webhookNode || !webhookNode.parameters) { return null; } // Check for path parameter const path = webhookNode.parameters.path as string | undefined; if (!path) { return null; } // Note: We can't construct the full URL without knowing the n8n instance URL // The caller will need to prepend the base URL return path; } // Helper function to generate proper workflow structure examples export function getWorkflowStructureExample(): string { return ` Minimal Workflow Example: { "name": "My Workflow", "nodes": [ { "id": "manual-trigger-1", "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [250, 300], "parameters": {} }, { "id": "set-1", "name": "Set Data", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [450, 300], "parameters": { "mode": "manual", "assignments": { "assignments": [{ "id": "1", "name": "message", "value": "Hello World", "type": "string" }] } } } ], "connections": { "Manual Trigger": { "main": [[{ "node": "Set Data", "type": "main", "index": 0 }]] } } } IMPORTANT: In connections, use the node NAME (e.g., "Manual Trigger"), NOT the node ID or type!`; } // Helper function to fix common workflow issues export function getWorkflowFixSuggestions(errors: string[]): string[] { const suggestions: string[] = []; if (errors.some(e => e.includes('empty connections'))) { suggestions.push('Add connections between your nodes. Each node (except endpoints) should connect to another node.'); suggestions.push('Connection format: connections: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }'); } if (errors.some(e => e.includes('Single-node workflows'))) { suggestions.push('Add at least one more node to process data. Common patterns: Trigger → Process → Output'); suggestions.push('Examples: Manual Trigger → Set, Webhook → HTTP Request, Schedule Trigger → Database Query'); } if (errors.some(e => e.includes('node ID') && e.includes('instead of node name'))) { suggestions.push('Replace node IDs with node names in connections. The name is what appears in the node header.'); suggestions.push('Wrong: connections: { "set-1": {...} }, Right: connections: { "Set Data": {...} }'); } return suggestions; }