/** * Flow Engine — executes saved Workflow flows for incoming chat messages. * * Execution model: * 1. Load all active flows from the DB * 2. For each flow, find its intent node and check if user message matches * 3. If matched, traverse the flow graph from intent → response nodes * 4. Return the response content (or null if no flow matched) */ import { getDb } from "./db"; import { workflowNodes, workflowEdges } from "../drizzle/schema"; import { eq } from "drizzle-orm"; export interface FlowResult { content: string; shouldEscalate?: boolean; flowId: string; flowName?: string; } // Intent patterns for built-in Support Flows const FLOW_INTENT_PATTERNS: Record = { "check-order-status": [ /\border\s*status\b/i, /\bcheck.*order\b/i, /\bwhere.*my order\b/i, /\border.*update\b/i, /\bmy order\b/i, ], "track-shipment": [ /\btrack.*shipment\b/i, /\btracking\b/i, /\bshipment.*status\b/i, /\bwhere.*package\b/i, /\bdelivery.*status\b/i, /\btrack.*package\b/i, ], "submit-return": [ /\breturn\b/i, /\bRMA\b/i, /\bsend.*back\b/i, /\breturn.*request\b/i, /\brefund\b/i, ], "cancel-order": [ /\bcancel.*order\b/i, /\bcancel.*my order\b/i, /\bstop.*order\b/i, ], "faq-deflection": [ /\bpayment\b/i, /\bwarranty\b/i, /\bhow.*long.*delivery\b/i, /\bshipping.*time\b/i, /\bminimum.*order\b/i, ], // ── New flows ───────────────────────────────────────────────────────────── "leads-capture": [ /\bi('d| would) like to (buy|order|purchase)\b/i, /\bget a quote\b/i, /\bbecome a (dealer|retailer|reseller)\b/i, /\bopen an? account\b/i, /\binterested in (buying|carrying|stocking)\b/i, /\bnew (customer|client)\b/i, /\bhow (do i|can i) (become|sign up|register|apply)\b/i, ], "sales-inquiry": [ /\bbulk (order|pricing|discount)\b/i, /\bwholesale\b/i, /\bminimum order quantity\b/i, /\bMOQ\b/i, /\bprice list\b/i, /\bcontact.*sales\b/i, /\bsales rep(resentative)?\b/i, /\btalk to sales\b/i, ], }; // Fallback static responses for flows that don't have DB nodes yet const FLOW_STATIC_RESPONSES: Record = { "check-order-status": "To check your order status, please provide your **Sales Order number** (e.g., SO-12345) and I'll look it up for you right away.", "track-shipment": "I can help you track your shipment! Please share your **order number** or **tracking number** and I'll pull up the latest status.", "submit-return": "I can guide you through the return process. Please provide:\n1. Your **order number**\n2. The **item(s)** you want to return\n3. The **reason** for the return\n\nOur return policy allows returns within 30 days of delivery. You can also email **returns@homelegance.com** for assistance.", "cancel-order": "I can help with order cancellation. Please provide your **order number** and I'll check if it's still within the cancellation window. Note: orders that have already shipped cannot be cancelled.", "faq-deflection": null, // handled by knowledge base "leads-capture": "We'd love to have you as a Homelegance dealer! 🎉\n\nPlease share the following so our sales team can follow up within **1 business day**:\n1. **Company name**\n2. **Your name & title**\n3. **Email address**\n4. **Phone number**\n5. **City / State** you operate in\n\nAlternatively, email us directly at **sales@homelegance.com**.", "sales-inquiry": "Our sales team handles bulk orders and wholesale pricing directly.\n\n📞 **Call us:** Contact your assigned sales rep\n📧 **Email:** sales@homelegance.com\n\nOr I can connect you with an agent right now — just say **'Talk to agent'**.", }; /** * Detect if the user message matches a flow intent. * Returns the flowId or null. */ export function detectFlowIntent(message: string): string | null { for (const [flowId, patterns] of Object.entries(FLOW_INTENT_PATTERNS)) { if (patterns.some(p => p.test(message))) { return flowId; } } return null; } /** * Execute a flow by ID using full graph traversal. * Tries DB-saved nodes first, falls back to static responses. */ export async function executeFlow(flowId: string, userMessage: string): Promise { // Try to load and traverse DB-saved flow graph try { const db = await getDb(); if (db) { const [nodes, edges] = await Promise.all([ db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, flowId)), db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, flowId)), ]); if (nodes.length > 0) { const result = traverseGraph(nodes, edges, userMessage); if (result) return { ...result, flowId }; } } } catch (err) { console.error("[FlowEngine] DB error:", err); } return fallbackToStatic(flowId); } // ── Graph traversal ─────────────────────────────────────────────────────────── type DbNode = { nodeId: string; type: string; label: string; config: unknown }; type DbEdge = { sourceNodeId: string; targetNodeId: string; label: string | null; condition: unknown }; function traverseGraph( nodes: DbNode[], edges: DbEdge[], userMessage: string ): Omit | null { // Build maps for O(1) lookup const nodeMap = new Map(nodes.map(n => [n.nodeId, n])); const adjMap = new Map(); for (const e of edges) { if (!adjMap.has(e.sourceNodeId)) adjMap.set(e.sourceNodeId, []); adjMap.get(e.sourceNodeId)!.push(e); } const intentNode = nodes.find(n => n.type === "intent"); if (!intentNode) return null; const responses: string[] = []; let shouldEscalate = false; const visited = new Set(); function traverse(nodeId: string): void { if (visited.has(nodeId)) return; visited.add(nodeId); const node = nodeMap.get(nodeId); if (!node) return; const cfg = node.config as Record | null ?? {}; switch (node.type) { case "response": if (cfg.message) responses.push(String(cfg.message)); break; case "condition": { // Route based on whether pattern matches the user message const pattern = cfg.pattern ? new RegExp(String(cfg.pattern), "i") : null; const conditionMet = pattern ? pattern.test(userMessage) : true; const outEdges = adjMap.get(nodeId) ?? []; // Edge label "true"/"false" controls routing; if no labels, follow all const labeled = outEdges.filter(e => e.label === "true" || e.label === "false"); if (labeled.length > 0) { const target = outEdges.find(e => e.label === (conditionMet ? "true" : "false")); if (target) traverse(target.targetNodeId); } else { outEdges.forEach(e => traverse(e.targetNodeId)); } return; // condition handles its own routing } case "escalation": shouldEscalate = true; if (cfg.message) responses.push(String(cfg.message)); break; case "tag": // future: tag conversation; no output break; case "delay": // future: async delay; skip for now break; } // Follow outgoing edges to next nodes for (const edge of adjMap.get(nodeId) ?? []) { traverse(edge.targetNodeId); } } traverse(intentNode.nodeId); if (!responses.length && !shouldEscalate) return null; return { content: responses.join("\n\n"), shouldEscalate }; } function fallbackToStatic(flowId: string): FlowResult | null { const staticResponse = FLOW_STATIC_RESPONSES[flowId]; if (staticResponse) return { content: staticResponse, flowId }; return null; }