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 (
+
+
+
+ } onClick={() => navigate('/flows')}>
+ Volver
+
+ {flow?.name}
+
+
+
+
+
+
+ }
+ onClick={() => saveMutation.mutate()}
+ loading={saveMutation.isPending}
+ style={{ background: '#25D366' }}
+ >
+ Guardar
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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) => (
+
+ }
+ onClick={() => navigate(`/flows/${record.id}`)}
+ >
+ Editar
+
+ : }
+ onClick={() =>
+ toggleActiveMutation.mutate({ id: record.id, active: !record.is_active })
+ }
+ >
+ {record.is_active ? 'Desactivar' : 'Activar'}
+
+ }
+ onClick={() => {
+ Modal.confirm({
+ title: '¿Eliminar flujo?',
+ onOk: () => deleteMutation.mutate(record.id),
+ });
+ }}
+ />
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
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)}