diff --git a/docs/plans/2026-01-29-fase-1-fundacion.md b/docs/plans/2026-01-29-fase-1-fundacion.md new file mode 100644 index 0000000..2921726 --- /dev/null +++ b/docs/plans/2026-01-29-fase-1-fundacion.md @@ -0,0 +1,3249 @@ +# 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 = 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 { + 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 { + const store = this.sessions.get(accountId); + if (!store) return; + + await this.createSession(accountId, store.info.name); + } + + async disconnectSession(accountId: string): Promise { + 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 { + 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 { + 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 + + + + + + + WhatsApp Centralizado + + +
+ + + +``` + +**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( + + + + + + + +); +``` + +**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 ; + } + return <>{children}; +} + +function App() { + return ( + + + } /> + + + + } + /> + + + ); +} + +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()( + 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 ( +
+ + +
+ + + WhatsApp Centralizado + + + {isRegister ? 'Crear cuenta de administrador' : 'Iniciar sesión'} + +
+ +
+ {isRegister && ( + + } + placeholder="Nombre completo" + /> + + )} + + + } + placeholder="Email" + /> + + + + } + placeholder="Contraseña" + /> + + + + + +
+ +
+ +
+
+
+
+ ); +} +``` + +**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: , + label: 'Dashboard', + }, + { + key: '/inbox', + icon: , + label: 'Inbox', + }, + { + key: '/whatsapp', + icon: , + label: 'WhatsApp', + }, + { + key: '/settings', + icon: , + label: 'Configuración', + }, + ]; + + const userMenu = [ + { + key: 'profile', + icon: , + label: 'Mi perfil', + }, + { + type: 'divider' as const, + }, + { + key: 'logout', + icon: , + label: 'Cerrar sesión', + onClick: handleLogout, + }, + ]; + + return ( + + +
+ + {!collapsed && ( + + WA Central + + )} +
+ navigate(key)} + style={{ border: 'none' }} + /> + + + +
+
+ + + + } /> + } /> + } /> + Configuración (próximamente)} /> + + +
+ + ); +} +``` + +**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 ( +
+ Dashboard + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + +
+ ); +} +``` + +**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(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 = { + connected: 'green', + connecting: 'orange', + disconnected: 'red', + }; + const labels: Record = { + connected: 'Conectado', + connecting: 'Conectando', + disconnected: 'Desconectado', + }; + return {labels[status]}; + }, + }, + { + title: 'Acciones', + key: 'actions', + render: (_: any, record: WhatsAppAccount) => ( + + {record.status !== 'connected' && ( + + )} + + + + + + + + + + {/* Modal para crear cuenta */} + { + setIsModalOpen(false); + form.resetFields(); + }} + footer={null} + > +
createMutation.mutate(values)} + > + + + + + + + + +
+ + {/* Modal para QR */} + setQrModal(null)} + footer={null} + width={400} + > +
+ {qrModal?.status === 'connected' ? ( +
+ + ✓ Conectado + + + Número: {qrModal.phone_number} + +
+ ) : qrModal?.qr_code ? ( +
+ + + Escanea con WhatsApp en tu teléfono + +
+ ) : ( +
+ + + Generando código QR... + +
+ )} +
+
+ + ); +} +``` + +**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(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 = { + bot: 'blue', + waiting: 'orange', + active: 'green', + resolved: 'default', + }; + + return ( +
+ {/* Lista de conversaciones */} + + {isLoading ? ( +
+ +
+ ) : conversations?.length === 0 ? ( + + ) : ( + ( + setSelectedId(conv.id)} + style={{ + padding: '12px 16px', + cursor: 'pointer', + background: selectedId === conv.id ? '#f5f5f5' : 'transparent', + }} + > + + } /> + + } + title={ + + {conv.contact.name || conv.contact.phone_number} + + {conv.status} + + + } + description={ + + {conv.last_message_at + ? dayjs(conv.last_message_at).fromNow() + : 'Sin mensajes'} + + } + /> + + )} + /> + )} +
+ + {/* Chat */} + + {selectedConversation ? ( + <> + {/* Header */} +
+ + {selectedConversation.contact.name || selectedConversation.contact.phone_number} + +
+ {selectedConversation.contact.phone_number} +
+ + {/* Messages */} +
+ {selectedConversation.messages?.map((msg) => ( +
+
+ {msg.content} +
+ + {dayjs(msg.created_at).format('HH:mm')} + +
+ ))} +
+ + {/* Input */} +
+ setMessageText(e.target.value)} + onPressEnter={handleSend} + /> +
+ + ) : ( +
+ +
+ )} +
+
+ ); +} +``` + +**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 +```