assets/js/controllers/workflow-validation-service.js

/**
 * WorkflowValidationService
 *
 * Validates workflow graph structure: triggers, end nodes,
 * connectivity, loop config, and disconnected subgraphs.
 */
export default class WorkflowValidationService {
    /**
     * @param {Function} getNodePorts - Returns {inputs, outputs} for a node type
     * @param {object} registryData - Registry data with actions/conditions and their inputSchema
     */
    constructor(getNodePorts, registryData) {
        this._getNodePorts = getNodePorts
        this._registryData = registryData || {}
    }

    /**
     * Validate the exported Drawflow data.
     * @param {object} drawflowExport - Result of editor.export()
     * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
     */
    validate(drawflowExport) {
        const errors = []
        const warnings = []
        const moduleData = drawflowExport.drawflow?.Home?.data || {}
        const nodes = Object.entries(moduleData)

        if (nodes.length === 0) {
            errors.push('Workflow is empty — add at least one node.')
            return { valid: false, errors, warnings }
        }

        this._validateTriggerCount(nodes, errors)
        this._validateEndNodes(nodes, errors)
        this._validateLoopNodes(nodes, errors)
        this._validateForEachNodes(nodes, errors)
        this._validateConnections(nodes, errors, warnings)
        this._validateReachability(nodes, errors)
        this._validateRequiredParams(nodes, errors)

        return { valid: errors.length === 0, errors, warnings }
    }

    _validateTriggerCount(nodes, errors) {
        const triggers = nodes.filter(([, n]) => n.data?.type === 'trigger')
        if (triggers.length === 0) {
            errors.push('A workflow must have exactly one trigger node.')
        } else if (triggers.length > 1) {
            errors.push(`Found ${triggers.length} trigger nodes — only one is allowed.`)
        }
    }

    _validateEndNodes(nodes, errors) {
        const endNodes = nodes.filter(([, n]) => n.data?.type === 'end')
        if (endNodes.length === 0) {
            errors.push('At least one End node is required.')
        }
    }

    _validateLoopNodes(nodes, errors) {
        nodes.filter(([, n]) => n.data?.type === 'loop').forEach(([id, n]) => {
            const max = n.data?.config?.maxIterations
            if (!max || parseInt(max, 10) <= 0) {
                errors.push(`Loop node #${id} must have maxIterations set to a positive number.`)
            }
        })
    }

    _validateForEachNodes(nodes, errors) {
        nodes.filter(([, n]) => n.data?.type === 'forEach').forEach(([id, n]) => {
            const collection = n.data?.config?.collection
            if (!collection) {
                errors.push(`ForEach node #${id} must have a collection path configured.`)
            }
        })
    }

    _validateConnections(nodes, errors, warnings) {
        nodes.forEach(([id, node]) => {
            const type = node.data?.type || 'unknown'
            const { inputs, outputs } = this._getNodePorts(type)

            if (inputs > 0) {
                let hasIncoming = false
                for (const inp of Object.values(node.inputs || {})) {
                    if (inp.connections && inp.connections.length > 0) hasIncoming = true
                }
                if (!hasIncoming) {
                    errors.push(`Node "${node.name}" (#${id}) has no incoming connection.`)
                }
            }

            if (outputs > 0) {
                let hasOutgoing = false
                for (const out of Object.values(node.outputs || {})) {
                    if (out.connections && out.connections.length > 0) hasOutgoing = true
                }
                if (!hasOutgoing) {
                    warnings.push(`Node "${node.name}" (#${id}) has no outgoing connection.`)
                }
            }
        })
    }

    _validateReachability(nodes, errors) {
        const triggers = nodes.filter(([, n]) => n.data?.type === 'trigger')
        if (triggers.length !== 1) return

        const reachable = new Set()
        const adjList = {}

        nodes.forEach(([id]) => { adjList[id] = new Set() })
        nodes.forEach(([id, node]) => {
            for (const out of Object.values(node.outputs || {})) {
                for (const conn of out.connections || []) {
                    adjList[id]?.add(conn.node.toString())
                    adjList[conn.node.toString()]?.add(id)
                }
            }
        })

        const queue = [triggers[0][0]]
        reachable.add(triggers[0][0])
        while (queue.length > 0) {
            const current = queue.shift()
            for (const neighbor of adjList[current] || []) {
                if (!reachable.has(neighbor)) {
                    reachable.add(neighbor)
                    queue.push(neighbor)
                }
            }
        }

        const unreachable = nodes.filter(([id]) => !reachable.has(id))
        if (unreachable.length > 0) {
            errors.push(
                `${unreachable.length} node(s) are not connected to the trigger: ` +
                unreachable.map(([id, n]) => `"${n.name}" (#${id})`).join(', ')
            )
        }
    }

    _validateRequiredParams(nodes, errors) {
        const actions = this._registryData.actions || []
        const conditions = this._registryData.conditions || []

        nodes.forEach(([id, node]) => {
            const type = node.data?.type
            const config = node.data?.config || {}
            const params = config.params || {}

            if (type === 'action' && config.action) {
                const actionDef = actions.find(a => a.action === config.action)
                if (!actionDef) {
                    errors.push(`Action node "${node.name}" (#${id}): references unknown action '${config.action}'.`)
                    return
                }
                const schema = actionDef.inputSchema || {}
                for (const [key, meta] of Object.entries(schema)) {
                    if (this._isSchemaFieldHidden(meta)) continue

                    if (meta.required && !params[key] && !(key in config)) {
                        errors.push(`Action node "${node.name}" (#${id}): required parameter '${key}' is not configured.`)
                    }
                }
            }

            if (type === 'condition' && config.condition && !config.condition.startsWith('Core.')) {
                const condDef = conditions.find(c => c.condition === config.condition)
                if (!condDef) {
                    errors.push(`Condition node "${node.name}" (#${id}): references unknown condition '${config.condition}'.`)
                    return
                }
                const schema = condDef.inputSchema || {}
                for (const [key, meta] of Object.entries(schema)) {
                    if (this._isSchemaFieldHidden(meta)) continue

                    if (meta.required && !params[key] && !(key in config)) {
                        errors.push(`Condition node "${node.name}" (#${id}): required parameter '${key}' is not configured.`)
                    }
                }
            }
        })
    }

    _isSchemaFieldHidden(meta) {
        return meta?.hidden === true || meta?.visible === false
    }
}