diff --git a/frontend/package.json b/frontend/package.json index a5d6bab..09f60d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,8 @@ "axios": "^1.7.9", "zustand": "^5.0.3", "socket.io-client": "^4.8.1", - "dayjs": "^1.11.13" + "dayjs": "^1.11.13", + "reactflow": "^11.11.4" }, "devDependencies": { "@types/react": "^18.3.18", diff --git a/frontend/src/pages/FlowBuilder.tsx b/frontend/src/pages/FlowBuilder.tsx new file mode 100644 index 0000000..7b46efb --- /dev/null +++ b/frontend/src/pages/FlowBuilder.tsx @@ -0,0 +1,159 @@ +import { useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import ReactFlow, { + Node, + Edge, + Controls, + Background, + useNodesState, + useEdgesState, + addEdge, + Connection, + NodeTypes, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { Button, message, Space } from 'antd'; +import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +const TriggerNode = ({ data }: { data: any }) => ( +
+ 🚀 {data.label} +
+); + +const MessageNode = ({ data }: { data: any }) => ( +
+ 💬 Mensaje +
{data.config?.text?.slice(0, 50) || 'Sin texto'}
+
+); + +const ConditionNode = ({ data }: { data: any }) => ( +
+ ❓ Condición +
+); + +const WaitInputNode = ({ data }: { data: any }) => ( +
+ ⏳ Esperar Input +
+); + +const nodeTypes: NodeTypes = { + trigger: TriggerNode, + message: MessageNode, + condition: ConditionNode, + wait_input: WaitInputNode, +}; + +interface Flow { + id: string; + name: string; + nodes: Node[]; + edges: Edge[]; +} + +export default function FlowBuilder() { + const { flowId } = useParams<{ flowId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const { data: flow, isLoading } = useQuery({ + queryKey: ['flow', flowId], + queryFn: async () => { + const data = await apiClient.get(`/api/flows/${flowId}`); + setNodes(data.nodes || []); + setEdges(data.edges || []); + return data; + }, + enabled: !!flowId, + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + await apiClient.put(`/api/flows/${flowId}`, { + nodes, + edges, + }); + }, + onSuccess: () => { + message.success('Flujo guardado'); + queryClient.invalidateQueries({ queryKey: ['flow', flowId] }); + }, + onError: () => { + message.error('Error al guardar'); + }, + }); + + const onConnect = useCallback( + (params: Connection) => setEdges((eds) => addEdge(params, eds)), + [setEdges] + ); + + const addNode = (type: string) => { + const newNode: Node = { + id: `${type}-${Date.now()}`, + type, + position: { x: 250, y: nodes.length * 100 + 50 }, + data: { + label: type === 'trigger' ? 'Inicio' : type, + type, + config: type === 'message' ? { text: 'Hola {{contact.name}}!' } : {} + }, + }; + setNodes((nds) => [...nds, newNode]); + }; + + if (isLoading) { + return
Cargando...
; + } + + return ( +
+
+ + + {flow?.name} + + + + + + + + +
+ +
+ + + + +
+
+ ); +} diff --git a/frontend/src/pages/FlowList.tsx b/frontend/src/pages/FlowList.tsx new file mode 100644 index 0000000..4f6228e --- /dev/null +++ b/frontend/src/pages/FlowList.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Card, + Button, + Table, + Tag, + Modal, + Form, + Input, + Select, + message, + Space, + Typography, +} from 'antd'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, + PlayCircleOutlined, + PauseCircleOutlined, +} from '@ant-design/icons'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +const { Title } = Typography; + +interface Flow { + id: string; + name: string; + trigger_type: string; + is_active: boolean; + version: number; + updated_at: string; +} + +const triggerLabels: Record = { + welcome: 'Bienvenida', + keyword: 'Palabra clave', + fallback: 'Fallback', + event: 'Evento', + manual: 'Manual', +}; + +export default function FlowList() { + const [isModalOpen, setIsModalOpen] = useState(false); + const [form] = Form.useForm(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data: flows, isLoading } = useQuery({ + queryKey: ['flows'], + queryFn: () => apiClient.get('/api/flows'), + }); + + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/api/flows', data), + onSuccess: (data) => { + message.success('Flujo creado'); + setIsModalOpen(false); + form.resetFields(); + queryClient.invalidateQueries({ queryKey: ['flows'] }); + navigate(`/flows/${data.id}`); + }, + onError: () => { + message.error('Error al crear flujo'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/api/flows/${id}`), + onSuccess: () => { + message.success('Flujo eliminado'); + queryClient.invalidateQueries({ queryKey: ['flows'] }); + }, + }); + + const toggleActiveMutation = useMutation({ + mutationFn: async ({ id, active }: { id: string; active: boolean }) => { + const endpoint = active ? 'activate' : 'deactivate'; + await apiClient.post(`/api/flows/${id}/${endpoint}`, {}); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['flows'] }); + message.success('Estado actualizado'); + }, + }); + + const columns = [ + { + title: 'Nombre', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Trigger', + dataIndex: 'trigger_type', + key: 'trigger_type', + render: (type: string) => triggerLabels[type] || type, + }, + { + title: 'Estado', + dataIndex: 'is_active', + key: 'is_active', + render: (active: boolean) => ( + + {active ? 'Activo' : 'Inactivo'} + + ), + }, + { + title: 'Versión', + dataIndex: 'version', + key: 'version', + }, + { + title: 'Acciones', + key: 'actions', + render: (_: any, record: Flow) => ( + + + + + + + + + + + { + setIsModalOpen(false); + form.resetFields(); + }} + footer={null} + > +
createMutation.mutate(values)} + > + + + + + + + + + prev.trigger_type !== curr.trigger_type} + > + {({ getFieldValue }) => + getFieldValue('trigger_type') === 'keyword' && ( + + + + ) + } + + + + + + +
+ + ); +} diff --git a/services/api-gateway/app/routers/whatsapp.py b/services/api-gateway/app/routers/whatsapp.py index 6f48efc..8a3e395 100644 --- a/services/api-gateway/app/routers/whatsapp.py +++ b/services/api-gateway/app/routers/whatsapp.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from typing import List from uuid import UUID import httpx +from pydantic import BaseModel from app.core.database import get_db from app.core.config import get_settings from app.core.security import get_current_user @@ -273,3 +274,59 @@ async def handle_whatsapp_event( db.commit() return {"status": "ok"} + + +class FlowSendRequest(BaseModel): + conversation_id: str + content: str + type: str = "text" + + +@router.post("/internal/flow/send") +async def flow_send_message( + request: FlowSendRequest, + db: Session = Depends(get_db), +): + """Internal endpoint for flow engine to send messages""" + conversation = db.query(Conversation).filter( + Conversation.id == request.conversation_id + ).first() + + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Create message in DB + message = Message( + conversation_id=conversation.id, + direction=MessageDirection.OUTBOUND, + type=MessageType.TEXT, + content=request.content, + status=MessageStatus.PENDING, + ) + db.add(message) + db.commit() + db.refresh(message) + + # Send via WhatsApp Core + async with httpx.AsyncClient() as client: + try: + response = await client.post( + f"{settings.WHATSAPP_CORE_URL}/api/sessions/{conversation.whatsapp_account_id}/messages", + json={ + "to": conversation.contact.phone_number, + "type": "text", + "content": {"text": request.content}, + }, + timeout=30, + ) + if response.status_code == 200: + data = response.json() + message.whatsapp_message_id = data.get("messageId") + message.status = MessageStatus.SENT + else: + message.status = MessageStatus.FAILED + except Exception: + message.status = MessageStatus.FAILED + + db.commit() + return {"success": True, "message_id": str(message.id)}