Files
WhatsAppCentralizado/docs/plans/2026-01-29-fase-1-fundacion.md
Claude AI b4355fbc96 docs: add Fase 1 implementation plan with 16 tasks
- Docker Compose configuration
- WhatsApp Core (Node.js + Baileys)
- API Gateway (FastAPI + JWT)
- Frontend (React + Vite + Ant Design)
- Complete code for each task

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:41:06 +00:00

3250 lines
83 KiB
Markdown

# Fase 1: Fundación - Plan de Implementación
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Crear la infraestructura base del proyecto: Docker Compose, servicio WhatsApp Core con Baileys, API Gateway con autenticación JWT, y Frontend básico con login y gestión de números WhatsApp.
**Architecture:** Microservicios con Docker Compose. WhatsApp Core (Node.js/Baileys) maneja conexiones WhatsApp. API Gateway (Python/FastAPI) centraliza autenticación y lógica. Frontend (React/TypeScript) para UI. PostgreSQL para datos, Redis para cache/sesiones.
**Tech Stack:** Node.js 20, TypeScript, Baileys, Python 3.11, FastAPI, SQLAlchemy, React 18, Vite, Ant Design, Docker, PostgreSQL 16, Redis 7.
---
## Task 1: Docker Compose Base
**Files:**
- Create: `docker-compose.yml`
- Create: `nginx/nginx.conf`
**Step 1: Create docker-compose.yml**
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: wac_postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-whatsapp_central}
POSTGRES_USER: ${DB_USER:-whatsapp_admin}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD required}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-whatsapp_admin}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wac_network
redis:
image: redis:7-alpine
container_name: wac_redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wac_network
whatsapp-core:
build:
context: ./services/whatsapp-core
dockerfile: Dockerfile
container_name: wac_whatsapp
restart: unless-stopped
environment:
NODE_ENV: ${NODE_ENV:-production}
REDIS_URL: redis://redis:6379
API_GATEWAY_URL: http://api-gateway:8000
WS_PORT: 3001
volumes:
- whatsapp_sessions:/app/sessions
ports:
- "3001:3001"
depends_on:
redis:
condition: service_healthy
networks:
- wac_network
api-gateway:
build:
context: ./services/api-gateway
dockerfile: Dockerfile
container_name: wac_api
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${DB_USER:-whatsapp_admin}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-whatsapp_central}
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
WHATSAPP_CORE_URL: http://whatsapp-core:3001
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- wac_network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: wac_frontend
restart: unless-stopped
ports:
- "3000:80"
depends_on:
- api-gateway
networks:
- wac_network
volumes:
postgres_data:
redis_data:
whatsapp_sessions:
networks:
wac_network:
driver: bridge
```
**Step 2: Create nginx.conf**
```nginx
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream api {
server api-gateway:8000;
}
upstream whatsapp {
server whatsapp-core:3001;
}
server {
listen 80;
server_name localhost;
# Frontend
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API
location /api {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300;
}
# Auth
location /auth {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket
location /ws {
proxy_pass http://whatsapp;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
}
```
**Step 3: Commit**
```bash
git add docker-compose.yml nginx/nginx.conf
git commit -m "feat: add Docker Compose configuration with all services"
```
---
## Task 2: WhatsApp Core - Setup Project
**Files:**
- Create: `services/whatsapp-core/package.json`
- Create: `services/whatsapp-core/tsconfig.json`
- Create: `services/whatsapp-core/Dockerfile`
**Step 1: Create package.json**
```json
{
"name": "whatsapp-core",
"version": "1.0.0",
"description": "WhatsApp Core service using Baileys",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn src/index.ts",
"lint": "eslint src/**/*.ts"
},
"dependencies": {
"@whiskeysockets/baileys": "^6.7.16",
"express": "^4.21.2",
"socket.io": "^4.8.1",
"ioredis": "^5.4.1",
"pino": "^9.6.0",
"qrcode": "^1.5.4",
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.7",
"@types/uuid": "^10.0.0",
"typescript": "^5.7.3",
"ts-node-dev": "^2.0.0"
}
}
```
**Step 2: Create tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
```
**Step 3: Create Dockerfile**
```dockerfile
FROM node:20-alpine
WORKDIR /app
# Install dependencies for Baileys
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm ci --only=production
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Create sessions directory
RUN mkdir -p /app/sessions
EXPOSE 3001
CMD ["npm", "start"]
```
**Step 4: Commit**
```bash
git add services/whatsapp-core/
git commit -m "feat(whatsapp-core): setup Node.js project with Baileys"
```
---
## Task 3: WhatsApp Core - Session Manager
**Files:**
- Create: `services/whatsapp-core/src/sessions/SessionManager.ts`
- Create: `services/whatsapp-core/src/sessions/types.ts`
**Step 1: Create types.ts**
```typescript
import { WASocket } from '@whiskeysockets/baileys';
export interface SessionInfo {
accountId: string;
phoneNumber: string | null;
name: string;
status: 'connecting' | 'connected' | 'disconnected';
qrCode: string | null;
createdAt: Date;
}
export interface SessionStore {
socket: WASocket | null;
info: SessionInfo;
}
export type SessionEventType =
| 'qr'
| 'connected'
| 'disconnected'
| 'message'
| 'message_status';
export interface SessionEvent {
type: SessionEventType;
accountId: string;
data: unknown;
}
```
**Step 2: Create SessionManager.ts**
```typescript
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WASocket,
proto,
WAMessageKey,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as QRCode from 'qrcode';
import { EventEmitter } from 'events';
import * as path from 'path';
import * as fs from 'fs';
import pino from 'pino';
import { SessionStore, SessionInfo, SessionEvent } from './types';
const logger = pino({ level: 'info' });
export class SessionManager extends EventEmitter {
private sessions: Map<string, SessionStore> = new Map();
private sessionsPath: string;
constructor(sessionsPath: string = './sessions') {
super();
this.sessionsPath = sessionsPath;
if (!fs.existsSync(sessionsPath)) {
fs.mkdirSync(sessionsPath, { recursive: true });
}
}
async createSession(accountId: string, name: string): Promise<SessionInfo> {
if (this.sessions.has(accountId)) {
const existing = this.sessions.get(accountId)!;
return existing.info;
}
const sessionPath = path.join(this.sessionsPath, accountId);
const { state, saveCreds } = await useMultiFileAuthState(sessionPath);
const info: SessionInfo = {
accountId,
phoneNumber: null,
name,
status: 'connecting',
qrCode: null,
createdAt: new Date(),
};
const store: SessionStore = { socket: null, info };
this.sessions.set(accountId, store);
const socket = makeWASocket({
auth: state,
printQRInTerminal: false,
logger: pino({ level: 'silent' }),
});
store.socket = socket;
socket.ev.on('creds.update', saveCreds);
socket.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
const qrDataUrl = await QRCode.toDataURL(qr);
info.qrCode = qrDataUrl;
info.status = 'connecting';
this.emitEvent({ type: 'qr', accountId, data: { qrCode: qrDataUrl } });
}
if (connection === 'close') {
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
info.status = 'disconnected';
info.qrCode = null;
if (reason !== DisconnectReason.loggedOut) {
logger.info(`Reconnecting session ${accountId}...`);
setTimeout(() => this.reconnectSession(accountId), 3000);
} else {
logger.info(`Session ${accountId} logged out`);
this.emitEvent({ type: 'disconnected', accountId, data: { reason: 'logged_out' } });
}
}
if (connection === 'open') {
info.status = 'connected';
info.qrCode = null;
info.phoneNumber = socket.user?.id?.split(':')[0] || null;
logger.info(`Session ${accountId} connected: ${info.phoneNumber}`);
this.emitEvent({ type: 'connected', accountId, data: { phoneNumber: info.phoneNumber } });
}
});
socket.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
const messageData = {
id: msg.key.id,
from: msg.key.remoteJid,
pushName: msg.pushName,
message: msg.message,
timestamp: msg.messageTimestamp,
};
this.emitEvent({ type: 'message', accountId, data: messageData });
}
});
socket.ev.on('messages.update', (updates) => {
for (const update of updates) {
if (update.update.status) {
this.emitEvent({
type: 'message_status',
accountId,
data: {
id: update.key.id,
remoteJid: update.key.remoteJid,
status: update.update.status,
},
});
}
}
});
return info;
}
private async reconnectSession(accountId: string): Promise<void> {
const store = this.sessions.get(accountId);
if (!store) return;
await this.createSession(accountId, store.info.name);
}
async disconnectSession(accountId: string): Promise<void> {
const store = this.sessions.get(accountId);
if (!store || !store.socket) return;
await store.socket.logout();
store.socket = null;
store.info.status = 'disconnected';
store.info.qrCode = null;
}
async deleteSession(accountId: string): Promise<void> {
await this.disconnectSession(accountId);
this.sessions.delete(accountId);
const sessionPath = path.join(this.sessionsPath, accountId);
if (fs.existsSync(sessionPath)) {
fs.rmSync(sessionPath, { recursive: true });
}
}
getSession(accountId: string): SessionInfo | null {
const store = this.sessions.get(accountId);
return store?.info || null;
}
getAllSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map((s) => s.info);
}
async sendMessage(
accountId: string,
to: string,
content: proto.IMessage
): Promise<proto.WebMessageInfo | null> {
const store = this.sessions.get(accountId);
if (!store?.socket || store.info.status !== 'connected') {
throw new Error(`Session ${accountId} not connected`);
}
const jid = to.includes('@') ? to : `${to}@s.whatsapp.net`;
const result = await store.socket.sendMessage(jid, content);
return result;
}
private emitEvent(event: SessionEvent): void {
this.emit('session_event', event);
}
}
```
**Step 3: Commit**
```bash
git add services/whatsapp-core/src/sessions/
git commit -m "feat(whatsapp-core): add SessionManager for multi-account WhatsApp"
```
---
## Task 4: WhatsApp Core - Express Server & Socket.IO
**Files:**
- Create: `services/whatsapp-core/src/index.ts`
- Create: `services/whatsapp-core/src/api/routes.ts`
**Step 1: Create routes.ts**
```typescript
import { Router, Request, Response } from 'express';
import { SessionManager } from '../sessions/SessionManager';
export function createRouter(sessionManager: SessionManager): Router {
const router = Router();
// Health check
router.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Create new session
router.post('/sessions', async (req: Request, res: Response) => {
try {
const { accountId, name } = req.body;
if (!accountId || !name) {
return res.status(400).json({ error: 'accountId and name required' });
}
const session = await sessionManager.createSession(accountId, name);
res.json(session);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Get session info
router.get('/sessions/:accountId', (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.accountId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
res.json(session);
});
// Get all sessions
router.get('/sessions', (req: Request, res: Response) => {
const sessions = sessionManager.getAllSessions();
res.json(sessions);
});
// Disconnect session
router.post('/sessions/:accountId/disconnect', async (req: Request, res: Response) => {
try {
await sessionManager.disconnectSession(req.params.accountId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Delete session
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
try {
await sessionManager.deleteSession(req.params.accountId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Send message
router.post('/sessions/:accountId/messages', async (req: Request, res: Response) => {
try {
const { to, type, content } = req.body;
if (!to || !content) {
return res.status(400).json({ error: 'to and content required' });
}
let messageContent: any;
switch (type) {
case 'image':
messageContent = { image: { url: content.url }, caption: content.caption };
break;
case 'document':
messageContent = { document: { url: content.url }, fileName: content.filename };
break;
case 'audio':
messageContent = { audio: { url: content.url } };
break;
case 'video':
messageContent = { video: { url: content.url }, caption: content.caption };
break;
default:
messageContent = { text: content.text || content };
}
const result = await sessionManager.sendMessage(
req.params.accountId,
to,
messageContent
);
res.json({ success: true, messageId: result?.key.id });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
return router;
}
```
**Step 2: Create index.ts**
```typescript
import express from 'express';
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { SessionManager } from './sessions/SessionManager';
import { createRouter } from './api/routes';
import pino from 'pino';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
const PORT = parseInt(process.env.WS_PORT || '3001', 10);
const API_GATEWAY_URL = process.env.API_GATEWAY_URL || 'http://localhost:8000';
async function main() {
const app = express();
const httpServer = createServer(app);
const io = new SocketIOServer(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
path: '/ws',
});
app.use(express.json());
const sessionManager = new SessionManager('./sessions');
const router = createRouter(sessionManager);
app.use('/api', router);
// Forward events to API Gateway and connected clients
sessionManager.on('session_event', async (event) => {
logger.info({ event }, 'Session event');
// Emit to Socket.IO clients
io.emit(event.type, event);
// Forward to API Gateway
try {
await fetch(`${API_GATEWAY_URL}/api/internal/whatsapp/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
} catch (error) {
logger.error({ error }, 'Failed to forward event to API Gateway');
}
});
io.on('connection', (socket) => {
logger.info({ socketId: socket.id }, 'Client connected');
socket.on('subscribe', (accountId: string) => {
socket.join(`account:${accountId}`);
logger.info({ socketId: socket.id, accountId }, 'Client subscribed');
});
socket.on('unsubscribe', (accountId: string) => {
socket.leave(`account:${accountId}`);
});
socket.on('disconnect', () => {
logger.info({ socketId: socket.id }, 'Client disconnected');
});
});
httpServer.listen(PORT, () => {
logger.info({ port: PORT }, 'WhatsApp Core server started');
});
}
main().catch((error) => {
logger.error({ error }, 'Failed to start server');
process.exit(1);
});
```
**Step 3: Commit**
```bash
git add services/whatsapp-core/src/
git commit -m "feat(whatsapp-core): add Express server with Socket.IO"
```
---
## Task 5: API Gateway - Setup Project
**Files:**
- Create: `services/api-gateway/requirements.txt`
- Create: `services/api-gateway/Dockerfile`
- Create: `services/api-gateway/app/__init__.py`
**Step 1: Create requirements.txt**
```
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg2-binary==2.9.10
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
pydantic==2.10.4
pydantic-settings==2.7.1
redis==5.2.1
httpx==0.28.1
```
**Step 2: Create Dockerfile**
```dockerfile
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY alembic.ini .
COPY alembic ./alembic
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
**Step 3: Create app/__init__.py**
```python
# WhatsApp Centralizado - API Gateway
```
**Step 4: Commit**
```bash
git add services/api-gateway/
git commit -m "feat(api-gateway): setup FastAPI project structure"
```
---
## Task 6: API Gateway - Database Models
**Files:**
- Create: `services/api-gateway/app/core/config.py`
- Create: `services/api-gateway/app/core/database.py`
- Create: `services/api-gateway/app/models/__init__.py`
- Create: `services/api-gateway/app/models/user.py`
- Create: `services/api-gateway/app/models/whatsapp.py`
**Step 1: Create config.py**
```python
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
DATABASE_URL: str = "postgresql://whatsapp_admin:password@localhost:5432/whatsapp_central"
# Redis
REDIS_URL: str = "redis://localhost:6379"
# JWT
JWT_SECRET: str = "change-me-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# WhatsApp Core
WHATSAPP_CORE_URL: str = "http://localhost:3001"
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
```
**Step 2: Create database.py**
```python
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
```
**Step 3: Create models/__init__.py**
```python
from app.models.user import User
from app.models.whatsapp import WhatsAppAccount, Contact, Conversation, Message
__all__ = ["User", "WhatsAppAccount", "Contact", "Conversation", "Message"]
```
**Step 4: Create models/user.py**
```python
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID
import enum
from app.core.database import Base
class UserRole(str, enum.Enum):
ADMIN = "admin"
SUPERVISOR = "supervisor"
AGENT = "agent"
class UserStatus(str, enum.Enum):
ONLINE = "online"
OFFLINE = "offline"
AWAY = "away"
BUSY = "busy"
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
name = Column(String(100), nullable=False)
role = Column(SQLEnum(UserRole), default=UserRole.AGENT, nullable=False)
status = Column(SQLEnum(UserStatus), default=UserStatus.OFFLINE, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
```
**Step 5: Create models/whatsapp.py**
```python
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from sqlalchemy.orm import relationship
import enum
from app.core.database import Base
class AccountStatus(str, enum.Enum):
CONNECTING = "connecting"
CONNECTED = "connected"
DISCONNECTED = "disconnected"
class ConversationStatus(str, enum.Enum):
BOT = "bot"
WAITING = "waiting"
ACTIVE = "active"
RESOLVED = "resolved"
class MessageDirection(str, enum.Enum):
INBOUND = "inbound"
OUTBOUND = "outbound"
class MessageType(str, enum.Enum):
TEXT = "text"
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
DOCUMENT = "document"
LOCATION = "location"
CONTACT = "contact"
STICKER = "sticker"
BUTTONS = "buttons"
LIST = "list"
class MessageStatus(str, enum.Enum):
PENDING = "pending"
SENT = "sent"
DELIVERED = "delivered"
READ = "read"
FAILED = "failed"
class WhatsAppAccount(Base):
__tablename__ = "whatsapp_accounts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
phone_number = Column(String(20), nullable=True)
name = Column(String(100), nullable=False)
status = Column(SQLEnum(AccountStatus), default=AccountStatus.DISCONNECTED, nullable=False)
session_data = Column(JSONB, nullable=True)
qr_code = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
conversations = relationship("Conversation", back_populates="whatsapp_account")
class Contact(Base):
__tablename__ = "contacts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
phone_number = Column(String(20), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=True)
email = Column(String(255), nullable=True)
company = Column(String(100), nullable=True)
metadata = Column(JSONB, default=dict)
tags = Column(ARRAY(String), default=list)
odoo_partner_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
conversations = relationship("Conversation", back_populates="contact")
class Conversation(Base):
__tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
whatsapp_account_id = Column(UUID(as_uuid=True), ForeignKey("whatsapp_accounts.id"), nullable=False)
contact_id = Column(UUID(as_uuid=True), ForeignKey("contacts.id"), nullable=False)
assigned_to = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status = Column(SQLEnum(ConversationStatus), default=ConversationStatus.BOT, nullable=False)
last_message_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
whatsapp_account = relationship("WhatsAppAccount", back_populates="conversations")
contact = relationship("Contact", back_populates="conversations")
messages = relationship("Message", back_populates="conversation", order_by="Message.created_at")
class Message(Base):
__tablename__ = "messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False)
whatsapp_message_id = Column(String(100), nullable=True)
direction = Column(SQLEnum(MessageDirection), nullable=False)
type = Column(SQLEnum(MessageType), default=MessageType.TEXT, nullable=False)
content = Column(Text, nullable=True)
media_url = Column(String(500), nullable=True)
metadata = Column(JSONB, default=dict)
sent_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
is_internal_note = Column(Boolean, default=False, nullable=False)
status = Column(SQLEnum(MessageStatus), default=MessageStatus.PENDING, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
conversation = relationship("Conversation", back_populates="messages")
```
**Step 6: Commit**
```bash
git add services/api-gateway/app/
git commit -m "feat(api-gateway): add database models for users and WhatsApp"
```
---
## Task 7: API Gateway - Authentication
**Files:**
- Create: `services/api-gateway/app/core/security.py`
- Create: `services/api-gateway/app/schemas/auth.py`
- Create: `services/api-gateway/app/routers/auth.py`
**Step 1: Create security.py**
```python
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.models.user import User
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
payload = decode_token(token)
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
return user
def require_role(*roles):
async def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user
return role_checker
```
**Step 2: Create schemas/auth.py**
```python
from pydantic import BaseModel, EmailStr
from typing import Optional
from uuid import UUID
from app.models.user import UserRole, UserStatus
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: UUID
email: str
name: str
role: UserRole
status: UserStatus
is_active: bool
class Config:
from_attributes = True
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserResponse
class CreateUserRequest(BaseModel):
email: EmailStr
password: str
name: str
role: UserRole = UserRole.AGENT
```
**Step 3: Create routers/auth.py**
```python
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import (
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
)
from app.models.user import User, UserRole
from app.schemas.auth import (
LoginRequest,
LoginResponse,
RefreshRequest,
TokenResponse,
UserResponse,
CreateUserRequest,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == request.email).first()
if not user or not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User account is disabled",
)
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
user=UserResponse.model_validate(user),
)
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(request: RefreshRequest, db: Session = Depends(get_db)):
payload = decode_token(request.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = payload.get("sub")
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found")
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
return TokenResponse(access_token=access_token, refresh_token=new_refresh_token)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return UserResponse.model_validate(current_user)
@router.post("/register", response_model=UserResponse)
def register_first_admin(request: CreateUserRequest, db: Session = Depends(get_db)):
# Only allow if no users exist (first admin)
user_count = db.query(User).count()
if user_count > 0:
raise HTTPException(status_code=403, detail="Registration disabled")
existing = db.query(User).filter(User.email == request.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=request.email,
password_hash=get_password_hash(request.password),
name=request.name,
role=UserRole.ADMIN,
)
db.add(user)
db.commit()
db.refresh(user)
return UserResponse.model_validate(user)
```
**Step 4: Commit**
```bash
git add services/api-gateway/app/
git commit -m "feat(api-gateway): add JWT authentication"
```
---
## Task 8: API Gateway - Main Application
**Files:**
- Create: `services/api-gateway/app/main.py`
- Create: `services/api-gateway/app/routers/__init__.py`
- Create: `services/api-gateway/app/schemas/__init__.py`
- Create: `services/api-gateway/app/core/__init__.py`
**Step 1: Create main.py**
```python
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
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.get("/health")
def health_check():
return {"status": "ok"}
@app.get("/api/health")
def api_health_check():
return {"status": "ok", "service": "api-gateway"}
```
**Step 2: Create routers/__init__.py**
```python
from app.routers import auth
__all__ = ["auth"]
```
**Step 3: Create schemas/__init__.py**
```python
from app.schemas import auth
__all__ = ["auth"]
```
**Step 4: Create core/__init__.py**
```python
from app.core import config, database, security
__all__ = ["config", "database", "security"]
```
**Step 5: Commit**
```bash
git add services/api-gateway/app/
git commit -m "feat(api-gateway): add FastAPI main application"
```
---
## Task 9: API Gateway - WhatsApp Routes
**Files:**
- Create: `services/api-gateway/app/schemas/whatsapp.py`
- Create: `services/api-gateway/app/routers/whatsapp.py`
- Modify: `services/api-gateway/app/main.py`
**Step 1: Create schemas/whatsapp.py**
```python
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
```
**Step 2: Create routers/whatsapp.py**
```python
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, require_role
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()
@router.post("/accounts", response_model=WhatsAppAccountResponse)
async def create_account(
request: WhatsAppAccountCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role(UserRole.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 as e:
pass # Session will be created later
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")
# Get latest status from WhatsApp Core
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_role(UserRole.ADMIN)),
):
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Delete session in WhatsApp Core
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")
# Create message in DB
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)
# 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": 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 as e:
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":
# Handle incoming message
msg_data = event.data
phone = msg_data.get("from", "").split("@")[0]
# Find or create contact
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)
# Find or create conversation
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)
# Extract message content
wa_message = msg_data.get("message", {})
content = (
wa_message.get("conversation") or
wa_message.get("extendedTextMessage", {}).get("text") or
"[Media]"
)
# Create message
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)
# Update conversation
from datetime import datetime
conversation.last_message_at = datetime.utcnow()
db.commit()
return {"status": "ok"}
```
**Step 3: Update main.py**
Add import and router:
```python
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()
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="WhatsApp Centralizado API",
description="API Gateway for WhatsApp Centralizado platform",
version="1.0.0",
)
origins = settings.CORS_ORIGINS.split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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"}
```
**Step 4: Commit**
```bash
git add services/api-gateway/app/
git commit -m "feat(api-gateway): add WhatsApp accounts and conversations routes"
```
---
## Task 10: Frontend - Setup Project
**Files:**
- Create: `frontend/package.json`
- Create: `frontend/tsconfig.json`
- Create: `frontend/vite.config.ts`
- Create: `frontend/index.html`
- Create: `frontend/Dockerfile`
**Step 1: Create package.json**
```json
{
"name": "whatsapp-centralizado-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1",
"antd": "^5.23.1",
"@ant-design/icons": "^5.6.1",
"@tanstack/react-query": "^5.64.1",
"axios": "^1.7.9",
"zustand": "^5.0.3",
"socket.io-client": "^4.8.1",
"dayjs": "^1.11.13"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
```
**Step 2: Create tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
```
**Step 3: Create vite.config.ts**
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'http://localhost:3001',
ws: true,
},
},
},
});
```
**Step 4: Create index.html**
```html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsApp Centralizado</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
**Step 5: Create Dockerfile**
```dockerfile
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
**Step 6: Create frontend/nginx.conf**
```nginx
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://api-gateway:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /auth {
proxy_pass http://api-gateway:8000;
proxy_set_header Host $host;
}
location /ws {
proxy_pass http://whatsapp-core:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
**Step 7: Commit**
```bash
git add frontend/
git commit -m "feat(frontend): setup React project with Vite"
```
---
## Task 11: Frontend - Core Files
**Files:**
- Create: `frontend/src/main.tsx`
- Create: `frontend/src/App.tsx`
- Create: `frontend/src/api/client.ts`
- Create: `frontend/src/store/auth.ts`
**Step 1: Create main.tsx**
```tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from 'antd';
import esES from 'antd/locale/es_ES';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={esES}>
<App />
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>
);
```
**Step 2: Create App.tsx**
```tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/auth';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import MainLayout from './layouts/MainLayout';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
export default App;
```
**Step 3: Create api/client.ts**
```typescript
import axios from 'axios';
import { useAuthStore } from '../store/auth';
const api = axios.create({
baseURL: '',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const refreshToken = useAuthStore.getState().refreshToken;
if (refreshToken) {
try {
const response = await axios.post('/auth/refresh', {
refresh_token: refreshToken,
});
const { access_token, refresh_token } = response.data;
useAuthStore.getState().setTokens(access_token, refresh_token);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return api(originalRequest);
} catch {
useAuthStore.getState().logout();
}
}
}
return Promise.reject(error);
}
);
export default api;
```
**Step 4: Create store/auth.ts**
```typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
setTokens: (accessToken: string, refreshToken: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({
user,
accessToken,
refreshToken,
isAuthenticated: true,
}),
setTokens: (accessToken, refreshToken) =>
set({ accessToken, refreshToken }),
logout: () =>
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
}),
}),
{
name: 'auth-storage',
}
)
);
```
**Step 5: Create index.css**
```css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
```
**Step 6: Commit**
```bash
git add frontend/src/
git commit -m "feat(frontend): add core files, auth store, and API client"
```
---
## Task 12: Frontend - Login Page
**Files:**
- Create: `frontend/src/pages/Login.tsx`
**Step 1: Create Login.tsx**
```tsx
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 api 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) => {
const response = await api.post('/auth/login', data);
return response.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 api.post('/auth/register', data);
return api.post('/auth/login', { email: data.email, password: data.password });
},
onSuccess: (response) => {
const data = response.data;
setAuth(data.user, data.access_token, data.refresh_token);
message.success('Cuenta creada exitosamente');
navigate('/');
},
onError: (error: any) => {
message.error(error.response?.data?.detail || '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>
);
}
```
**Step 2: Commit**
```bash
git add frontend/src/pages/
git commit -m "feat(frontend): add Login page with register option"
```
---
## Task 13: Frontend - Main Layout & Dashboard
**Files:**
- Create: `frontend/src/layouts/MainLayout.tsx`
- Create: `frontend/src/pages/Dashboard.tsx`
**Step 1: Create MainLayout.tsx**
```tsx
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>
);
}
```
**Step 2: Create Dashboard.tsx**
```tsx
import { Card, Row, Col, Statistic, Typography } from 'antd';
import {
MessageOutlined,
UserOutlined,
WhatsAppOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';
const { Title } = Typography;
export default function Dashboard() {
const { data: accounts } = useQuery({
queryKey: ['whatsapp-accounts'],
queryFn: async () => {
const response = await api.get('/api/whatsapp/accounts');
return response.data;
},
});
const { data: conversations } = useQuery({
queryKey: ['conversations'],
queryFn: async () => {
const response = await api.get('/api/whatsapp/conversations');
return response.data;
},
});
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>
);
}
```
**Step 3: Commit**
```bash
git add frontend/src/layouts/ frontend/src/pages/
git commit -m "feat(frontend): add MainLayout and Dashboard page"
```
---
## Task 14: Frontend - WhatsApp Accounts Page
**Files:**
- Create: `frontend/src/pages/WhatsAppAccounts.tsx`
**Step 1: Create WhatsAppAccounts.tsx**
```tsx
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 api 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 () => {
const response = await api.get('/api/whatsapp/accounts');
return response.data as WhatsAppAccount[];
},
refetchInterval: 5000, // Refresh every 5 seconds for QR updates
});
const createMutation = useMutation({
mutationFn: async (data: { name: string }) => {
const response = await api.post('/api/whatsapp/accounts', data);
return response.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 api.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 response = await api.get(`/api/whatsapp/accounts/${account.id}`);
setQrModal(response.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 para crear cuenta */}
<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 para QR */}
<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>
);
}
```
**Step 2: Commit**
```bash
git add frontend/src/pages/WhatsAppAccounts.tsx
git commit -m "feat(frontend): add WhatsApp accounts management page"
```
---
## Task 15: Frontend - Inbox Page
**Files:**
- Create: `frontend/src/pages/Inbox.tsx`
**Step 1: Create Inbox.tsx**
```tsx
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 api 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, Title } = 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 () => {
const response = await api.get('/api/whatsapp/conversations');
return response.data as Conversation[];
},
refetchInterval: 3000,
});
const { data: selectedConversation } = useQuery({
queryKey: ['conversation', selectedId],
queryFn: async () => {
if (!selectedId) return null;
const response = await api.get(`/api/whatsapp/conversations/${selectedId}`);
return response.data as Conversation;
},
enabled: !!selectedId,
refetchInterval: 2000,
});
const sendMutation = useMutation({
mutationFn: async (data: { conversationId: string; content: string }) => {
await api.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' }}
bodyStyle={{ 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' }}
bodyStyle={{ 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>
);
}
```
**Step 2: Commit**
```bash
git add frontend/src/pages/Inbox.tsx
git commit -m "feat(frontend): add Inbox page for conversations"
```
---
## Task 16: Final Integration & Testing
**Files:**
- Create: `frontend/tsconfig.node.json`
- Update: `.env.example`
**Step 1: Create tsconfig.node.json**
```json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
```
**Step 2: Update .env.example with test values**
Ensure all variables are documented.
**Step 3: Final commit**
```bash
git add .
git commit -m "feat: complete Fase 1 - Foundation implementation"
```
---
## Summary
Fase 1 creates:
1. **Docker Compose** with PostgreSQL, Redis, all services
2. **WhatsApp Core** (Node.js) with Baileys for multi-account sessions
3. **API Gateway** (FastAPI) with JWT auth, user management, WhatsApp routes
4. **Frontend** (React) with login, dashboard, WhatsApp accounts, basic inbox
To test:
```bash
# Start services
cp .env.example .env
# Edit .env with your values
docker-compose up -d
# Open browser
http://localhost:3000
# Register first admin, connect WhatsApp via QR
```