diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f09b14..a9c54e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuthStore } from './store/auth'; - -// Placeholder components - will be replaced -const LoginPage = () =>
Login Page
; -const DashboardPage = () =>
Dashboard
; -const MainLayout = ({ children }: { children: React.ReactNode }) =>
{children}
; +import Login from './pages/Login'; +import MainLayout from './layouts/MainLayout'; function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); @@ -14,17 +11,12 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { export default function App() { return ( - } /> + } /> - - - } /> - } /> - - + } /> diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..15ced8b --- /dev/null +++ b/frontend/src/layouts/MainLayout.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'; +import { Layout, Menu, Button, Avatar, Dropdown, Typography } from 'antd'; +import { + DashboardOutlined, + MessageOutlined, + WhatsAppOutlined, + SettingOutlined, + LogoutOutlined, + UserOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, +} from '@ant-design/icons'; +import { useAuthStore } from '../store/auth'; +import Dashboard from '../pages/Dashboard'; +import WhatsAppAccounts from '../pages/WhatsAppAccounts'; +import Inbox from '../pages/Inbox'; + +const { Header, Sider, Content } = Layout; +const { Text } = Typography; + +export default function MainLayout() { + const [collapsed, setCollapsed] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuthStore(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const menuItems = [ + { + key: '/', + icon: , + label: 'Dashboard', + }, + { + key: '/inbox', + icon: , + label: 'Inbox', + }, + { + key: '/whatsapp', + icon: , + label: 'WhatsApp', + }, + { + key: '/settings', + icon: , + label: 'Configuración', + }, + ]; + + const userMenu = [ + { + key: 'profile', + icon: , + label: 'Mi perfil', + }, + { + type: 'divider' as const, + }, + { + key: 'logout', + icon: , + label: 'Cerrar sesión', + onClick: handleLogout, + }, + ]; + + return ( + + +
+ + {!collapsed && ( + + WA Central + + )} +
+ navigate(key)} + style={{ border: 'none' }} + /> + + + +
+
+ + + + } /> + } /> + } /> + Configuración (próximamente)} /> + + +
+ + ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..6359b78 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,81 @@ +import { Card, Row, Col, Statistic, Typography } from 'antd'; +import { + MessageOutlined, + UserOutlined, + WhatsAppOutlined, + CheckCircleOutlined, +} from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +const { Title } = Typography; + +export default function Dashboard() { + const { data: accounts } = useQuery({ + queryKey: ['whatsapp-accounts'], + queryFn: async () => { + return apiClient.get('/api/whatsapp/accounts'); + }, + }); + + const { data: conversations } = useQuery({ + queryKey: ['conversations'], + queryFn: async () => { + return apiClient.get('/api/whatsapp/conversations'); + }, + }); + + const connectedAccounts = accounts?.filter((a: any) => a.status === 'connected').length || 0; + const totalAccounts = accounts?.length || 0; + const activeConversations = conversations?.filter((c: any) => c.status !== 'resolved').length || 0; + const resolvedToday = conversations?.filter((c: any) => c.status === 'resolved').length || 0; + + return ( +
+ Dashboard + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + +
+ ); +} diff --git a/frontend/src/pages/Inbox.tsx b/frontend/src/pages/Inbox.tsx new file mode 100644 index 0000000..a30c826 --- /dev/null +++ b/frontend/src/pages/Inbox.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react'; +import { + Card, + List, + Avatar, + Typography, + Input, + Button, + Tag, + Empty, + Spin, + Space, + Badge, +} from 'antd'; +import { SendOutlined, UserOutlined } from '@ant-design/icons'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/es'; + +dayjs.extend(relativeTime); +dayjs.locale('es'); + +const { Text } = Typography; + +interface Contact { + id: string; + phone_number: string; + name: string | null; +} + +interface Message { + id: string; + direction: 'inbound' | 'outbound'; + type: string; + content: string | null; + created_at: string; +} + +interface Conversation { + id: string; + contact: Contact; + status: string; + last_message_at: string | null; + messages?: Message[]; +} + +export default function Inbox() { + const [selectedId, setSelectedId] = useState(null); + const [messageText, setMessageText] = useState(''); + const queryClient = useQueryClient(); + + const { data: conversations, isLoading } = useQuery({ + queryKey: ['conversations'], + queryFn: async () => { + return apiClient.get('/api/whatsapp/conversations'); + }, + refetchInterval: 3000, + }); + + const { data: selectedConversation } = useQuery({ + queryKey: ['conversation', selectedId], + queryFn: async () => { + if (!selectedId) return null; + return apiClient.get(`/api/whatsapp/conversations/${selectedId}`); + }, + enabled: !!selectedId, + refetchInterval: 2000, + }); + + const sendMutation = useMutation({ + mutationFn: async (data: { conversationId: string; content: string }) => { + await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/messages`, { + type: 'text', + content: data.content, + }); + }, + onSuccess: () => { + setMessageText(''); + queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] }); + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + }, + }); + + const handleSend = () => { + if (!messageText.trim() || !selectedId) return; + sendMutation.mutate({ conversationId: selectedId, content: messageText }); + }; + + const statusColors: Record = { + bot: 'blue', + waiting: 'orange', + active: 'green', + resolved: 'default', + }; + + return ( +
+ {/* Lista de conversaciones */} + + {isLoading ? ( +
+ +
+ ) : conversations?.length === 0 ? ( + + ) : ( + ( + setSelectedId(conv.id)} + style={{ + padding: '12px 16px', + cursor: 'pointer', + background: selectedId === conv.id ? '#f5f5f5' : 'transparent', + }} + > + + } /> + + } + title={ + + {conv.contact.name || conv.contact.phone_number} + + {conv.status} + + + } + description={ + + {conv.last_message_at + ? dayjs(conv.last_message_at).fromNow() + : 'Sin mensajes'} + + } + /> + + )} + /> + )} +
+ + {/* Chat */} + + {selectedConversation ? ( + <> + {/* Header */} +
+ + {selectedConversation.contact.name || selectedConversation.contact.phone_number} + +
+ {selectedConversation.contact.phone_number} +
+ + {/* Messages */} +
+ {selectedConversation.messages?.map((msg) => ( +
+
+ {msg.content} +
+ + {dayjs(msg.created_at).format('HH:mm')} + +
+ ))} +
+ + {/* Input */} +
+ setMessageText(e.target.value)} + onPressEnter={handleSend} + /> +
+ + ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..0376d5d --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Form, Input, Button, Card, Typography, message, Space } from 'antd'; +import { UserOutlined, LockOutlined, WhatsAppOutlined } from '@ant-design/icons'; +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; +import { useAuthStore } from '../store/auth'; + +const { Title, Text } = Typography; + +interface LoginForm { + email: string; + password: string; +} + +interface RegisterForm { + email: string; + password: string; + name: string; +} + +export default function Login() { + const navigate = useNavigate(); + const setAuth = useAuthStore((state) => state.setAuth); + const [isRegister, setIsRegister] = useState(false); + + const loginMutation = useMutation({ + mutationFn: async (data: LoginForm) => { + return apiClient.post('/auth/login', data); + }, + onSuccess: (data) => { + setAuth(data.user, data.access_token, data.refresh_token); + message.success('Bienvenido'); + navigate('/'); + }, + onError: () => { + message.error('Credenciales incorrectas'); + }, + }); + + const registerMutation = useMutation({ + mutationFn: async (data: RegisterForm) => { + await apiClient.post('/auth/register', data); + return apiClient.post('/auth/login', { email: data.email, password: data.password }); + }, + onSuccess: (data) => { + setAuth(data.user, data.access_token, data.refresh_token); + message.success('Cuenta creada exitosamente'); + navigate('/'); + }, + onError: (error: any) => { + message.error(error.message || 'Error al registrar'); + }, + }); + + const onFinish = (values: LoginForm | RegisterForm) => { + if (isRegister) { + registerMutation.mutate(values as RegisterForm); + } else { + loginMutation.mutate(values as LoginForm); + } + }; + + const isLoading = loginMutation.isPending || registerMutation.isPending; + + return ( +
+ + +
+ + + WhatsApp Centralizado + + + {isRegister ? 'Crear cuenta de administrador' : 'Iniciar sesión'} + +
+ +
+ {isRegister && ( + + } + placeholder="Nombre completo" + /> + + )} + + + } + placeholder="Email" + /> + + + + } + placeholder="Contraseña" + /> + + + + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/pages/WhatsAppAccounts.tsx b/frontend/src/pages/WhatsAppAccounts.tsx new file mode 100644 index 0000000..cd1f25e --- /dev/null +++ b/frontend/src/pages/WhatsAppAccounts.tsx @@ -0,0 +1,250 @@ +import { useState } from 'react'; +import { + Card, + Button, + Table, + Tag, + Modal, + Form, + Input, + message, + Space, + Typography, + Image, + Spin, +} from 'antd'; +import { + PlusOutlined, + ReloadOutlined, + DeleteOutlined, + QrcodeOutlined, +} from '@ant-design/icons'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +const { Title, Text } = Typography; + +interface WhatsAppAccount { + id: string; + name: string; + phone_number: string | null; + status: 'connecting' | 'connected' | 'disconnected'; + qr_code: string | null; + created_at: string; +} + +export default function WhatsAppAccounts() { + const [isModalOpen, setIsModalOpen] = useState(false); + const [qrModal, setQrModal] = useState(null); + const [form] = Form.useForm(); + const queryClient = useQueryClient(); + + const { data: accounts, isLoading, refetch } = useQuery({ + queryKey: ['whatsapp-accounts'], + queryFn: async () => { + return apiClient.get('/api/whatsapp/accounts'); + }, + refetchInterval: 5000, + }); + + const createMutation = useMutation({ + mutationFn: async (data: { name: string }) => { + return apiClient.post('/api/whatsapp/accounts', data); + }, + onSuccess: (data) => { + message.success('Cuenta creada. Escanea el QR para conectar.'); + setIsModalOpen(false); + form.resetFields(); + queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] }); + setQrModal(data); + }, + onError: () => { + message.error('Error al crear cuenta'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/api/whatsapp/accounts/${id}`); + }, + onSuccess: () => { + message.success('Cuenta eliminada'); + queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] }); + }, + onError: () => { + message.error('Error al eliminar'); + }, + }); + + const handleShowQR = async (account: WhatsAppAccount) => { + const data = await apiClient.get(`/api/whatsapp/accounts/${account.id}`); + setQrModal(data); + }; + + const columns = [ + { + title: 'Nombre', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Número', + dataIndex: 'phone_number', + key: 'phone_number', + render: (phone: string | null) => phone || '-', + }, + { + title: 'Estado', + dataIndex: 'status', + key: 'status', + render: (status: string) => { + const colors: Record = { + connected: 'green', + connecting: 'orange', + disconnected: 'red', + }; + const labels: Record = { + connected: 'Conectado', + connecting: 'Conectando', + disconnected: 'Desconectado', + }; + return {labels[status]}; + }, + }, + { + title: 'Acciones', + key: 'actions', + render: (_: any, record: WhatsAppAccount) => ( + + {record.status !== 'connected' && ( + + )} + + + + + + + + + + { + setIsModalOpen(false); + form.resetFields(); + }} + footer={null} + > +
createMutation.mutate(values)} + > + + + + + + + + +
+ + setQrModal(null)} + footer={null} + width={400} + > +
+ {qrModal?.status === 'connected' ? ( +
+ + ✓ Conectado + + + Número: {qrModal.phone_number} + +
+ ) : qrModal?.qr_code ? ( +
+ + + Escanea con WhatsApp en tu teléfono + +
+ ) : ( +
+ + + Generando código QR... + +
+ )} +
+
+ + ); +} diff --git a/services/api-gateway/app/main.py b/services/api-gateway/app/main.py new file mode 100644 index 0000000..196fb72 --- /dev/null +++ b/services/api-gateway/app/main.py @@ -0,0 +1,40 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.config import get_settings +from app.core.database import engine, Base +from app.routers import auth, whatsapp + +settings = get_settings() + +# Create tables +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="WhatsApp Centralizado API", + description="API Gateway for WhatsApp Centralizado platform", + version="1.0.0", +) + +# CORS +origins = settings.CORS_ORIGINS.split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Routers +app.include_router(auth.router) +app.include_router(whatsapp.router) + + +@app.get("/health") +def health_check(): + return {"status": "ok"} + + +@app.get("/api/health") +def api_health_check(): + return {"status": "ok", "service": "api-gateway"} diff --git a/services/api-gateway/app/routers/__init__.py b/services/api-gateway/app/routers/__init__.py index 4f04b08..1f2148e 100644 --- a/services/api-gateway/app/routers/__init__.py +++ b/services/api-gateway/app/routers/__init__.py @@ -1,3 +1,4 @@ from app.routers.auth import router as auth_router +from app.routers.whatsapp import router as whatsapp_router -__all__ = ["auth_router"] +__all__ = ["auth_router", "whatsapp_router"] diff --git a/services/api-gateway/app/routers/whatsapp.py b/services/api-gateway/app/routers/whatsapp.py new file mode 100644 index 0000000..6f48efc --- /dev/null +++ b/services/api-gateway/app/routers/whatsapp.py @@ -0,0 +1,275 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from uuid import UUID +import httpx +from app.core.database import get_db +from app.core.config import get_settings +from app.core.security import get_current_user +from app.models.user import User, UserRole +from app.models.whatsapp import ( + WhatsAppAccount, Contact, Conversation, Message, + AccountStatus, MessageDirection, MessageType, MessageStatus, ConversationStatus +) +from app.schemas.whatsapp import ( + WhatsAppAccountCreate, WhatsAppAccountResponse, + ConversationResponse, ConversationDetailResponse, + SendMessageRequest, MessageResponse, InternalEventRequest +) + +router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"]) +settings = get_settings() + + +def require_admin(current_user: User = Depends(get_current_user)): + if current_user.role != UserRole.ADMIN: + raise HTTPException(status_code=403, detail="Admin required") + return current_user + + +@router.post("/accounts", response_model=WhatsAppAccountResponse) +async def create_account( + request: WhatsAppAccountCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + account = WhatsAppAccount(name=request.name) + db.add(account) + db.commit() + db.refresh(account) + + # Start session in WhatsApp Core + async with httpx.AsyncClient() as client: + try: + response = await client.post( + f"{settings.WHATSAPP_CORE_URL}/api/sessions", + json={"accountId": str(account.id), "name": account.name}, + timeout=30, + ) + if response.status_code == 200: + data = response.json() + account.qr_code = data.get("qrCode") + account.status = AccountStatus(data.get("status", "connecting")) + db.commit() + except Exception: + pass + + return account + + +@router.get("/accounts", response_model=List[WhatsAppAccountResponse]) +def list_accounts( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + accounts = db.query(WhatsAppAccount).all() + return accounts + + +@router.get("/accounts/{account_id}", response_model=WhatsAppAccountResponse) +async def get_account( + account_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="Account not found") + + async with httpx.AsyncClient() as client: + try: + response = await client.get( + f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}", + timeout=10, + ) + if response.status_code == 200: + data = response.json() + account.qr_code = data.get("qrCode") + account.status = AccountStatus(data.get("status", "disconnected")) + account.phone_number = data.get("phoneNumber") + db.commit() + except Exception: + pass + + return account + + +@router.delete("/accounts/{account_id}") +async def delete_account( + account_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="Account not found") + + async with httpx.AsyncClient() as client: + try: + await client.delete( + f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}", + timeout=10, + ) + except Exception: + pass + + db.delete(account) + db.commit() + return {"success": True} + + +@router.get("/conversations", response_model=List[ConversationResponse]) +def list_conversations( + status: ConversationStatus = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = db.query(Conversation) + if status: + query = query.filter(Conversation.status == status) + conversations = query.order_by(Conversation.last_message_at.desc()).limit(50).all() + return conversations + + +@router.get("/conversations/{conversation_id}", response_model=ConversationDetailResponse) +def get_conversation( + conversation_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + conversation = db.query(Conversation).filter(Conversation.id == conversation_id).first() + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + return conversation + + +@router.post("/conversations/{conversation_id}/messages", response_model=MessageResponse) +async def send_message( + conversation_id: UUID, + request: SendMessageRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + conversation = db.query(Conversation).filter(Conversation.id == conversation_id).first() + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + message = Message( + conversation_id=conversation.id, + direction=MessageDirection.OUTBOUND, + type=request.type, + content=request.content, + media_url=request.media_url, + sent_by=current_user.id, + status=MessageStatus.PENDING, + ) + db.add(message) + db.commit() + db.refresh(message) + + 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": request.type.value, + "content": {"text": request.content} if request.type == MessageType.TEXT else { + "url": request.media_url, + "caption": 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() + db.refresh(message) + return message + + +# Internal endpoint for WhatsApp Core events +@router.post("/internal/whatsapp/event") +async def handle_whatsapp_event( + event: InternalEventRequest, + db: Session = Depends(get_db), +): + account = db.query(WhatsAppAccount).filter( + WhatsAppAccount.id == event.accountId + ).first() + + if not account: + return {"status": "ignored", "reason": "account not found"} + + if event.type == "qr": + account.qr_code = event.data.get("qrCode") + account.status = AccountStatus.CONNECTING + + elif event.type == "connected": + account.status = AccountStatus.CONNECTED + account.phone_number = event.data.get("phoneNumber") + account.qr_code = None + + elif event.type == "disconnected": + account.status = AccountStatus.DISCONNECTED + account.qr_code = None + + elif event.type == "message": + msg_data = event.data + phone = msg_data.get("from", "").split("@")[0] + + contact = db.query(Contact).filter(Contact.phone_number == phone).first() + if not contact: + contact = Contact( + phone_number=phone, + name=msg_data.get("pushName"), + ) + db.add(contact) + db.commit() + db.refresh(contact) + + conversation = db.query(Conversation).filter( + Conversation.whatsapp_account_id == account.id, + Conversation.contact_id == contact.id, + Conversation.status != ConversationStatus.RESOLVED, + ).first() + + if not conversation: + conversation = Conversation( + whatsapp_account_id=account.id, + contact_id=contact.id, + status=ConversationStatus.BOT, + ) + db.add(conversation) + db.commit() + db.refresh(conversation) + + wa_message = msg_data.get("message", {}) + content = ( + wa_message.get("conversation") or + wa_message.get("extendedTextMessage", {}).get("text") or + "[Media]" + ) + + message = Message( + conversation_id=conversation.id, + whatsapp_message_id=msg_data.get("id"), + direction=MessageDirection.INBOUND, + type=MessageType.TEXT, + content=content, + status=MessageStatus.DELIVERED, + ) + db.add(message) + + from datetime import datetime + conversation.last_message_at = datetime.utcnow() + + db.commit() + return {"status": "ok"} diff --git a/services/api-gateway/app/schemas/whatsapp.py b/services/api-gateway/app/schemas/whatsapp.py new file mode 100644 index 0000000..2d20509 --- /dev/null +++ b/services/api-gateway/app/schemas/whatsapp.py @@ -0,0 +1,76 @@ +from pydantic import BaseModel +from typing import Optional, List +from uuid import UUID +from datetime import datetime +from app.models.whatsapp import AccountStatus, ConversationStatus, MessageType, MessageDirection, MessageStatus + + +class WhatsAppAccountCreate(BaseModel): + name: str + + +class WhatsAppAccountResponse(BaseModel): + id: UUID + phone_number: Optional[str] + name: str + status: AccountStatus + qr_code: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class ContactResponse(BaseModel): + id: UUID + phone_number: str + name: Optional[str] + email: Optional[str] + company: Optional[str] + tags: List[str] + created_at: datetime + + class Config: + from_attributes = True + + +class MessageResponse(BaseModel): + id: UUID + direction: MessageDirection + type: MessageType + content: Optional[str] + media_url: Optional[str] + status: MessageStatus + is_internal_note: bool + created_at: datetime + + class Config: + from_attributes = True + + +class ConversationResponse(BaseModel): + id: UUID + contact: ContactResponse + status: ConversationStatus + last_message_at: Optional[datetime] + created_at: datetime + + class Config: + from_attributes = True + + +class ConversationDetailResponse(ConversationResponse): + messages: List[MessageResponse] + whatsapp_account: WhatsAppAccountResponse + + +class SendMessageRequest(BaseModel): + type: MessageType = MessageType.TEXT + content: str + media_url: Optional[str] = None + + +class InternalEventRequest(BaseModel): + type: str + accountId: str + data: dict