feat: add Layer 3 - API Gateway main app, WhatsApp routes, Frontend pages
API Gateway: - main.py with FastAPI app, CORS, health endpoints - WhatsApp routes: accounts CRUD, conversations, messages, internal events - WhatsApp schemas for request/response validation Frontend: - Login page with register option for first admin - MainLayout with sidebar navigation and user dropdown - Dashboard with statistics cards (accounts, conversations) - WhatsApp Accounts page with QR modal for connection - Inbox page with conversation list and real-time chat Full feature set for Fase 1 Foundation complete. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/auth';
|
||||
|
||||
// Placeholder components - will be replaced
|
||||
const LoginPage = () => <div>Login Page</div>;
|
||||
const DashboardPage = () => <div>Dashboard</div>;
|
||||
const MainLayout = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||
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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
<MainLayout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
160
frontend/src/layouts/MainLayout.tsx
Normal file
160
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -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: <DashboardOutlined />,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
key: '/inbox',
|
||||
icon: <MessageOutlined />,
|
||||
label: 'Inbox',
|
||||
},
|
||||
{
|
||||
key: '/whatsapp',
|
||||
icon: <WhatsAppOutlined />,
|
||||
label: 'WhatsApp',
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Configuración',
|
||||
},
|
||||
];
|
||||
|
||||
const userMenu = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: 'Mi perfil',
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Cerrar sesión',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<WhatsAppOutlined
|
||||
style={{
|
||||
fontSize: collapsed ? 24 : 32,
|
||||
color: '#25D366',
|
||||
}}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<Text strong style={{ marginLeft: 8, fontSize: 16 }}>
|
||||
WA Central
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
|
||||
<Dropdown menu={{ items: userMenu }} placement="bottomRight">
|
||||
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar style={{ backgroundColor: '#25D366' }}>
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text style={{ marginLeft: 8 }}>{user?.name}</Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
|
||||
<Content
|
||||
style={{
|
||||
margin: 24,
|
||||
padding: 24,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
minHeight: 280,
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/inbox" element={<Inbox />} />
|
||||
<Route path="/whatsapp" element={<WhatsAppAccounts />} />
|
||||
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/Dashboard.tsx
Normal file
81
frontend/src/pages/Dashboard.tsx
Normal file
@@ -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<any[]>('/api/whatsapp/accounts');
|
||||
},
|
||||
});
|
||||
|
||||
const { data: conversations } = useQuery({
|
||||
queryKey: ['conversations'],
|
||||
queryFn: async () => {
|
||||
return apiClient.get<any[]>('/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 (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 24 }}>Dashboard</Title>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Números conectados"
|
||||
value={connectedAccounts}
|
||||
suffix={`/ ${totalAccounts}`}
|
||||
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Conversaciones activas"
|
||||
value={activeConversations}
|
||||
prefix={<MessageOutlined style={{ color: '#1890ff' }} />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Resueltas hoy"
|
||||
value={resolvedToday}
|
||||
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Contactos"
|
||||
value={0}
|
||||
prefix={<UserOutlined style={{ color: '#722ed1' }} />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
frontend/src/pages/Inbox.tsx
Normal file
259
frontend/src/pages/Inbox.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: conversations, isLoading } = useQuery({
|
||||
queryKey: ['conversations'],
|
||||
queryFn: async () => {
|
||||
return apiClient.get<Conversation[]>('/api/whatsapp/conversations');
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: selectedConversation } = useQuery({
|
||||
queryKey: ['conversation', selectedId],
|
||||
queryFn: async () => {
|
||||
if (!selectedId) return null;
|
||||
return apiClient.get<Conversation>(`/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<string, string> = {
|
||||
bot: 'blue',
|
||||
waiting: 'orange',
|
||||
active: 'green',
|
||||
resolved: 'default',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', gap: 16 }}>
|
||||
{/* Lista de conversaciones */}
|
||||
<Card
|
||||
style={{ width: 350, overflow: 'auto' }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
title="Conversaciones"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : conversations?.length === 0 ? (
|
||||
<Empty
|
||||
description="Sin conversaciones"
|
||||
style={{ padding: 40 }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={conversations}
|
||||
renderItem={(conv) => (
|
||||
<List.Item
|
||||
onClick={() => setSelectedId(conv.id)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
background: selectedId === conv.id ? '#f5f5f5' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Badge dot={conv.status !== 'resolved'} color={statusColors[conv.status]}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
</Badge>
|
||||
}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>{conv.contact.name || conv.contact.phone_number}</Text>
|
||||
<Tag color={statusColors[conv.status]} style={{ fontSize: 10 }}>
|
||||
{conv.status}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{conv.last_message_at
|
||||
? dayjs(conv.last_message_at).fromNow()
|
||||
: 'Sin mensajes'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Chat */}
|
||||
<Card
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }}
|
||||
>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{selectedConversation.contact.name || selectedConversation.contact.phone_number}
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary">{selectedConversation.contact.phone_number}</Text>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{selectedConversation.messages?.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
alignSelf: msg.direction === 'outbound' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '70%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
background: msg.direction === 'outbound' ? '#25D366' : '#f0f0f0',
|
||||
color: msg.direction === 'outbound' ? 'white' : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
|
||||
</div>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
display: 'block',
|
||||
textAlign: msg.direction === 'outbound' ? 'right' : 'left',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{dayjs(msg.created_at).format('HH:mm')}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Escribe un mensaje..."
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
onPressEnter={handleSend}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
loading={sendMutation.isPending}
|
||||
style={{ background: '#25D366', borderColor: '#25D366' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Empty description="Selecciona una conversación" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
frontend/src/pages/Login.tsx
Normal file
158
frontend/src/pages/Login.tsx
Normal file
@@ -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<any>('/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<any>('/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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #25D366 0%, #128C7E 100%)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: 400,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<WhatsAppOutlined style={{ fontSize: 48, color: '#25D366' }} />
|
||||
<Title level={3} style={{ marginTop: 16, marginBottom: 0 }}>
|
||||
WhatsApp Centralizado
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{isRegister ? 'Crear cuenta de administrador' : 'Iniciar sesión'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
>
|
||||
{isRegister && (
|
||||
<Form.Item
|
||||
name="name"
|
||||
rules={[{ required: true, message: 'Ingresa tu nombre' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="Nombre completo"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: 'Ingresa tu email' },
|
||||
{ type: 'email', message: 'Email inválido' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="Email"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: 'Ingresa tu contraseña' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="Contraseña"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
block
|
||||
style={{ background: '#25D366', borderColor: '#25D366' }}
|
||||
>
|
||||
{isRegister ? 'Crear cuenta' : 'Iniciar sesión'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button type="link" onClick={() => setIsRegister(!isRegister)}>
|
||||
{isRegister
|
||||
? '¿Ya tienes cuenta? Inicia sesión'
|
||||
: '¿Primera vez? Crear cuenta admin'}
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
frontend/src/pages/WhatsAppAccounts.tsx
Normal file
250
frontend/src/pages/WhatsAppAccounts.tsx
Normal file
@@ -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<WhatsAppAccount | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: accounts, isLoading, refetch } = useQuery({
|
||||
queryKey: ['whatsapp-accounts'],
|
||||
queryFn: async () => {
|
||||
return apiClient.get<WhatsAppAccount[]>('/api/whatsapp/accounts');
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { name: string }) => {
|
||||
return apiClient.post<WhatsAppAccount>('/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<WhatsAppAccount>(`/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<string, string> = {
|
||||
connected: 'green',
|
||||
connecting: 'orange',
|
||||
disconnected: 'red',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
connected: 'Conectado',
|
||||
connecting: 'Conectando',
|
||||
disconnected: 'Desconectado',
|
||||
};
|
||||
return <Tag color={colors[status]}>{labels[status]}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Acciones',
|
||||
key: 'actions',
|
||||
render: (_: any, record: WhatsAppAccount) => (
|
||||
<Space>
|
||||
{record.status !== 'connected' && (
|
||||
<Button
|
||||
icon={<QrcodeOutlined />}
|
||||
onClick={() => handleShowQR(record)}
|
||||
>
|
||||
Ver QR
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '¿Eliminar cuenta?',
|
||||
content: 'Esta acción no se puede deshacer.',
|
||||
okText: 'Eliminar',
|
||||
okType: 'danger',
|
||||
onOk: () => deleteMutation.mutate(record.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>Números de WhatsApp</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
style={{ background: '#25D366', borderColor: '#25D366' }}
|
||||
>
|
||||
Agregar número
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={accounts}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="Agregar número de WhatsApp"
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Nombre"
|
||||
rules={[{ required: true, message: 'Ingresa un nombre' }]}
|
||||
>
|
||||
<Input placeholder="Ej: Ventas Principal" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={createMutation.isPending}
|
||||
block
|
||||
>
|
||||
Crear y obtener QR
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`Conectar: ${qrModal?.name}`}
|
||||
open={!!qrModal}
|
||||
onCancel={() => setQrModal(null)}
|
||||
footer={null}
|
||||
width={400}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
{qrModal?.status === 'connected' ? (
|
||||
<div>
|
||||
<Tag color="green" style={{ fontSize: 16, padding: '8px 16px' }}>
|
||||
✓ Conectado
|
||||
</Tag>
|
||||
<Text style={{ display: 'block', marginTop: 16 }}>
|
||||
Número: {qrModal.phone_number}
|
||||
</Text>
|
||||
</div>
|
||||
) : qrModal?.qr_code ? (
|
||||
<div>
|
||||
<Image
|
||||
src={qrModal.qr_code}
|
||||
alt="QR Code"
|
||||
width={280}
|
||||
preview={false}
|
||||
/>
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
||||
Escanea con WhatsApp en tu teléfono
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Spin size="large" />
|
||||
<Text style={{ display: 'block', marginTop: 16 }}>
|
||||
Generando código QR...
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
services/api-gateway/app/main.py
Normal file
40
services/api-gateway/app/main.py
Normal file
@@ -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"}
|
||||
@@ -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"]
|
||||
|
||||
275
services/api-gateway/app/routers/whatsapp.py
Normal file
275
services/api-gateway/app/routers/whatsapp.py
Normal file
@@ -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"}
|
||||
76
services/api-gateway/app/schemas/whatsapp.py
Normal file
76
services/api-gateway/app/schemas/whatsapp.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user