# 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 ```