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

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

83 KiB

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

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

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

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

{
  "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

{
  "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

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

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

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

import makeWASocket, {
  DisconnectReason,
  useMultiFileAuthState,
  WASocket,
  proto,
  WAMessageKey,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as QRCode from 'qrcode';
import { EventEmitter } from 'events';
import * as path from 'path';
import * as fs from 'fs';
import pino from 'pino';
import { SessionStore, SessionInfo, SessionEvent } from './types';

const logger = pino({ level: 'info' });

export class SessionManager extends EventEmitter {
  private sessions: Map<string, SessionStore> = new Map();
  private sessionsPath: string;

  constructor(sessionsPath: string = './sessions') {
    super();
    this.sessionsPath = sessionsPath;
    if (!fs.existsSync(sessionsPath)) {
      fs.mkdirSync(sessionsPath, { recursive: true });
    }
  }

  async createSession(accountId: string, name: string): Promise<SessionInfo> {
    if (this.sessions.has(accountId)) {
      const existing = this.sessions.get(accountId)!;
      return existing.info;
    }

    const sessionPath = path.join(this.sessionsPath, accountId);
    const { state, saveCreds } = await useMultiFileAuthState(sessionPath);

    const info: SessionInfo = {
      accountId,
      phoneNumber: null,
      name,
      status: 'connecting',
      qrCode: null,
      createdAt: new Date(),
    };

    const store: SessionStore = { socket: null, info };
    this.sessions.set(accountId, store);

    const socket = makeWASocket({
      auth: state,
      printQRInTerminal: false,
      logger: pino({ level: 'silent' }),
    });

    store.socket = socket;

    socket.ev.on('creds.update', saveCreds);

    socket.ev.on('connection.update', async (update) => {
      const { connection, lastDisconnect, qr } = update;

      if (qr) {
        const qrDataUrl = await QRCode.toDataURL(qr);
        info.qrCode = qrDataUrl;
        info.status = 'connecting';
        this.emitEvent({ type: 'qr', accountId, data: { qrCode: qrDataUrl } });
      }

      if (connection === 'close') {
        const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
        info.status = 'disconnected';
        info.qrCode = null;

        if (reason !== DisconnectReason.loggedOut) {
          logger.info(`Reconnecting session ${accountId}...`);
          setTimeout(() => this.reconnectSession(accountId), 3000);
        } else {
          logger.info(`Session ${accountId} logged out`);
          this.emitEvent({ type: 'disconnected', accountId, data: { reason: 'logged_out' } });
        }
      }

      if (connection === 'open') {
        info.status = 'connected';
        info.qrCode = null;
        info.phoneNumber = socket.user?.id?.split(':')[0] || null;
        logger.info(`Session ${accountId} connected: ${info.phoneNumber}`);
        this.emitEvent({ type: 'connected', accountId, data: { phoneNumber: info.phoneNumber } });
      }
    });

    socket.ev.on('messages.upsert', async ({ messages, type }) => {
      if (type !== 'notify') return;

      for (const msg of messages) {
        if (msg.key.fromMe) continue;

        const messageData = {
          id: msg.key.id,
          from: msg.key.remoteJid,
          pushName: msg.pushName,
          message: msg.message,
          timestamp: msg.messageTimestamp,
        };

        this.emitEvent({ type: 'message', accountId, data: messageData });
      }
    });

    socket.ev.on('messages.update', (updates) => {
      for (const update of updates) {
        if (update.update.status) {
          this.emitEvent({
            type: 'message_status',
            accountId,
            data: {
              id: update.key.id,
              remoteJid: update.key.remoteJid,
              status: update.update.status,
            },
          });
        }
      }
    });

    return info;
  }

  private async reconnectSession(accountId: string): Promise<void> {
    const store = this.sessions.get(accountId);
    if (!store) return;

    await this.createSession(accountId, store.info.name);
  }

  async disconnectSession(accountId: string): Promise<void> {
    const store = this.sessions.get(accountId);
    if (!store || !store.socket) return;

    await store.socket.logout();
    store.socket = null;
    store.info.status = 'disconnected';
    store.info.qrCode = null;
  }

  async deleteSession(accountId: string): Promise<void> {
    await this.disconnectSession(accountId);
    this.sessions.delete(accountId);

    const sessionPath = path.join(this.sessionsPath, accountId);
    if (fs.existsSync(sessionPath)) {
      fs.rmSync(sessionPath, { recursive: true });
    }
  }

  getSession(accountId: string): SessionInfo | null {
    const store = this.sessions.get(accountId);
    return store?.info || null;
  }

  getAllSessions(): SessionInfo[] {
    return Array.from(this.sessions.values()).map((s) => s.info);
  }

  async sendMessage(
    accountId: string,
    to: string,
    content: proto.IMessage
  ): Promise<proto.WebMessageInfo | null> {
    const store = this.sessions.get(accountId);
    if (!store?.socket || store.info.status !== 'connected') {
      throw new Error(`Session ${accountId} not connected`);
    }

    const jid = to.includes('@') ? to : `${to}@s.whatsapp.net`;
    const result = await store.socket.sendMessage(jid, content);
    return result;
  }

  private emitEvent(event: SessionEvent): void {
    this.emit('session_event', event);
  }
}

Step 3: Commit

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

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

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

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

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

# WhatsApp Centralizado - API Gateway

Step 4: Commit

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

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

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

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

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

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

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

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

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

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

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

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

from app.routers import auth

__all__ = ["auth"]

Step 3: Create schemas/init.py

from app.schemas import auth

__all__ = ["auth"]

Step 4: Create core/init.py

from app.core import config, database, security

__all__ = ["config", "database", "security"]

Step 5: Commit

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

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

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:

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

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

{
  "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

{
  "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

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

<!DOCTYPE html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WhatsApp Centralizado</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Step 5: Create Dockerfile

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

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

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

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from 'antd';
import esES from 'antd/locale/es_ES';
import App from './App';
import './index.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      retry: 1,
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <ConfigProvider locale={esES}>
        <App />
      </ConfigProvider>
    </QueryClientProvider>
  </React.StrictMode>
);

Step 2: Create App.tsx

import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/auth';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import MainLayout from './layouts/MainLayout';

function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated } = useAuthStore();
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  return <>{children}</>;
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<Login />} />
        <Route
          path="/*"
          element={
            <ProtectedRoute>
              <MainLayout />
            </ProtectedRoute>
          }
        />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

Step 3: Create api/client.ts

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

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  role: string;
}

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  setAuth: (user: User, accessToken: string, refreshToken: string) => void;
  setTokens: (accessToken: string, refreshToken: string) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,
      setAuth: (user, accessToken, refreshToken) =>
        set({
          user,
          accessToken,
          refreshToken,
          isAuthenticated: true,
        }),
      setTokens: (accessToken, refreshToken) =>
        set({ accessToken, refreshToken }),
      logout: () =>
        set({
          user: null,
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false,
        }),
    }),
    {
      name: 'auth-storage',
    }
  )
);

Step 5: Create index.css

* {
  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

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

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, Typography, message, Space } from 'antd';
import { UserOutlined, LockOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { useMutation } from '@tanstack/react-query';
import api from '../api/client';
import { useAuthStore } from '../store/auth';

const { Title, Text } = Typography;

interface LoginForm {
  email: string;
  password: string;
}

interface RegisterForm {
  email: string;
  password: string;
  name: string;
}

export default function Login() {
  const navigate = useNavigate();
  const setAuth = useAuthStore((state) => state.setAuth);
  const [isRegister, setIsRegister] = useState(false);

  const loginMutation = useMutation({
    mutationFn: async (data: LoginForm) => {
      const response = await api.post('/auth/login', data);
      return response.data;
    },
    onSuccess: (data) => {
      setAuth(data.user, data.access_token, data.refresh_token);
      message.success('Bienvenido');
      navigate('/');
    },
    onError: () => {
      message.error('Credenciales incorrectas');
    },
  });

  const registerMutation = useMutation({
    mutationFn: async (data: RegisterForm) => {
      await api.post('/auth/register', data);
      return api.post('/auth/login', { email: data.email, password: data.password });
    },
    onSuccess: (response) => {
      const data = response.data;
      setAuth(data.user, data.access_token, data.refresh_token);
      message.success('Cuenta creada exitosamente');
      navigate('/');
    },
    onError: (error: any) => {
      message.error(error.response?.data?.detail || 'Error al registrar');
    },
  });

  const onFinish = (values: LoginForm | RegisterForm) => {
    if (isRegister) {
      registerMutation.mutate(values as RegisterForm);
    } else {
      loginMutation.mutate(values as LoginForm);
    }
  };

  const isLoading = loginMutation.isPending || registerMutation.isPending;

  return (
    <div
      style={{
        minHeight: '100vh',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        background: 'linear-gradient(135deg, #25D366 0%, #128C7E 100%)',
      }}
    >
      <Card
        style={{
          width: 400,
          boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
        }}
      >
        <Space direction="vertical" size="large" style={{ width: '100%' }}>
          <div style={{ textAlign: 'center' }}>
            <WhatsAppOutlined style={{ fontSize: 48, color: '#25D366' }} />
            <Title level={3} style={{ marginTop: 16, marginBottom: 0 }}>
              WhatsApp Centralizado
            </Title>
            <Text type="secondary">
              {isRegister ? 'Crear cuenta de administrador' : 'Iniciar sesión'}
            </Text>
          </div>

          <Form
            name="login"
            onFinish={onFinish}
            layout="vertical"
            size="large"
          >
            {isRegister && (
              <Form.Item
                name="name"
                rules={[{ required: true, message: 'Ingresa tu nombre' }]}
              >
                <Input
                  prefix={<UserOutlined />}
                  placeholder="Nombre completo"
                />
              </Form.Item>
            )}

            <Form.Item
              name="email"
              rules={[
                { required: true, message: 'Ingresa tu email' },
                { type: 'email', message: 'Email inválido' },
              ]}
            >
              <Input
                prefix={<UserOutlined />}
                placeholder="Email"
              />
            </Form.Item>

            <Form.Item
              name="password"
              rules={[{ required: true, message: 'Ingresa tu contraseña' }]}
            >
              <Input.Password
                prefix={<LockOutlined />}
                placeholder="Contraseña"
              />
            </Form.Item>

            <Form.Item>
              <Button
                type="primary"
                htmlType="submit"
                loading={isLoading}
                block
                style={{ background: '#25D366', borderColor: '#25D366' }}
              >
                {isRegister ? 'Crear cuenta' : 'Iniciar sesión'}
              </Button>
            </Form.Item>
          </Form>

          <div style={{ textAlign: 'center' }}>
            <Button type="link" onClick={() => setIsRegister(!isRegister)}>
              {isRegister
                ? '¿Ya tienes cuenta? Inicia sesión'
                : '¿Primera vez? Crear cuenta admin'}
            </Button>
          </div>
        </Space>
      </Card>
    </div>
  );
}

Step 2: Commit

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

import { useState } from 'react';
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Button, Avatar, Dropdown, Typography } from 'antd';
import {
  DashboardOutlined,
  MessageOutlined,
  WhatsAppOutlined,
  SettingOutlined,
  LogoutOutlined,
  UserOutlined,
  MenuFoldOutlined,
  MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '../store/auth';
import Dashboard from '../pages/Dashboard';
import WhatsAppAccounts from '../pages/WhatsAppAccounts';
import Inbox from '../pages/Inbox';

const { Header, Sider, Content } = Layout;
const { Text } = Typography;

export default function MainLayout() {
  const [collapsed, setCollapsed] = useState(false);
  const navigate = useNavigate();
  const location = useLocation();
  const { user, logout } = useAuthStore();

  const handleLogout = () => {
    logout();
    navigate('/login');
  };

  const menuItems = [
    {
      key: '/',
      icon: <DashboardOutlined />,
      label: 'Dashboard',
    },
    {
      key: '/inbox',
      icon: <MessageOutlined />,
      label: 'Inbox',
    },
    {
      key: '/whatsapp',
      icon: <WhatsAppOutlined />,
      label: 'WhatsApp',
    },
    {
      key: '/settings',
      icon: <SettingOutlined />,
      label: 'Configuración',
    },
  ];

  const userMenu = [
    {
      key: 'profile',
      icon: <UserOutlined />,
      label: 'Mi perfil',
    },
    {
      type: 'divider' as const,
    },
    {
      key: 'logout',
      icon: <LogoutOutlined />,
      label: 'Cerrar sesión',
      onClick: handleLogout,
    },
  ];

  return (
    <Layout style={{ minHeight: '100vh' }}>
      <Sider
        trigger={null}
        collapsible
        collapsed={collapsed}
        style={{
          background: '#fff',
          borderRight: '1px solid #f0f0f0',
        }}
      >
        <div
          style={{
            height: 64,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            borderBottom: '1px solid #f0f0f0',
          }}
        >
          <WhatsAppOutlined
            style={{
              fontSize: collapsed ? 24 : 32,
              color: '#25D366',
            }}
          />
          {!collapsed && (
            <Text strong style={{ marginLeft: 8, fontSize: 16 }}>
              WA Central
            </Text>
          )}
        </div>
        <Menu
          mode="inline"
          selectedKeys={[location.pathname]}
          items={menuItems}
          onClick={({ key }) => navigate(key)}
          style={{ border: 'none' }}
        />
      </Sider>

      <Layout>
        <Header
          style={{
            padding: '0 24px',
            background: '#fff',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
            borderBottom: '1px solid #f0f0f0',
          }}
        >
          <Button
            type="text"
            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
            onClick={() => setCollapsed(!collapsed)}
          />

          <Dropdown menu={{ items: userMenu }} placement="bottomRight">
            <div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
              <Avatar style={{ backgroundColor: '#25D366' }}>
                {user?.name?.charAt(0).toUpperCase()}
              </Avatar>
              <Text style={{ marginLeft: 8 }}>{user?.name}</Text>
            </div>
          </Dropdown>
        </Header>

        <Content
          style={{
            margin: 24,
            padding: 24,
            background: '#fff',
            borderRadius: 8,
            minHeight: 280,
          }}
        >
          <Routes>
            <Route path="/" element={<Dashboard />} />
            <Route path="/inbox" element={<Inbox />} />
            <Route path="/whatsapp" element={<WhatsAppAccounts />} />
            <Route path="/settings" element={<div>Configuración (próximamente)</div>} />
          </Routes>
        </Content>
      </Layout>
    </Layout>
  );
}

Step 2: Create Dashboard.tsx

import { Card, Row, Col, Statistic, Typography } from 'antd';
import {
  MessageOutlined,
  UserOutlined,
  WhatsAppOutlined,
  CheckCircleOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';

const { Title } = Typography;

export default function Dashboard() {
  const { data: accounts } = useQuery({
    queryKey: ['whatsapp-accounts'],
    queryFn: async () => {
      const response = await api.get('/api/whatsapp/accounts');
      return response.data;
    },
  });

  const { data: conversations } = useQuery({
    queryKey: ['conversations'],
    queryFn: async () => {
      const response = await api.get('/api/whatsapp/conversations');
      return response.data;
    },
  });

  const connectedAccounts = accounts?.filter((a: any) => a.status === 'connected').length || 0;
  const totalAccounts = accounts?.length || 0;
  const activeConversations = conversations?.filter((c: any) => c.status !== 'resolved').length || 0;
  const resolvedToday = conversations?.filter((c: any) => c.status === 'resolved').length || 0;

  return (
    <div>
      <Title level={4} style={{ marginBottom: 24 }}>Dashboard</Title>

      <Row gutter={[16, 16]}>
        <Col xs={24} sm={12} lg={6}>
          <Card>
            <Statistic
              title="Números conectados"
              value={connectedAccounts}
              suffix={`/ ${totalAccounts}`}
              prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
            />
          </Card>
        </Col>

        <Col xs={24} sm={12} lg={6}>
          <Card>
            <Statistic
              title="Conversaciones activas"
              value={activeConversations}
              prefix={<MessageOutlined style={{ color: '#1890ff' }} />}
            />
          </Card>
        </Col>

        <Col xs={24} sm={12} lg={6}>
          <Card>
            <Statistic
              title="Resueltas hoy"
              value={resolvedToday}
              prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
            />
          </Card>
        </Col>

        <Col xs={24} sm={12} lg={6}>
          <Card>
            <Statistic
              title="Contactos"
              value={0}
              prefix={<UserOutlined style={{ color: '#722ed1' }} />}
            />
          </Card>
        </Col>
      </Row>
    </div>
  );
}

Step 3: Commit

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

import { useState } from 'react';
import {
  Card,
  Button,
  Table,
  Tag,
  Modal,
  Form,
  Input,
  message,
  Space,
  Typography,
  Image,
  Spin,
} from 'antd';
import {
  PlusOutlined,
  ReloadOutlined,
  DeleteOutlined,
  QrcodeOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';

const { Title, Text } = Typography;

interface WhatsAppAccount {
  id: string;
  name: string;
  phone_number: string | null;
  status: 'connecting' | 'connected' | 'disconnected';
  qr_code: string | null;
  created_at: string;
}

export default function WhatsAppAccounts() {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [qrModal, setQrModal] = useState<WhatsAppAccount | null>(null);
  const [form] = Form.useForm();
  const queryClient = useQueryClient();

  const { data: accounts, isLoading, refetch } = useQuery({
    queryKey: ['whatsapp-accounts'],
    queryFn: async () => {
      const response = await api.get('/api/whatsapp/accounts');
      return response.data as WhatsAppAccount[];
    },
    refetchInterval: 5000, // Refresh every 5 seconds for QR updates
  });

  const createMutation = useMutation({
    mutationFn: async (data: { name: string }) => {
      const response = await api.post('/api/whatsapp/accounts', data);
      return response.data;
    },
    onSuccess: (data) => {
      message.success('Cuenta creada. Escanea el QR para conectar.');
      setIsModalOpen(false);
      form.resetFields();
      queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
      setQrModal(data);
    },
    onError: () => {
      message.error('Error al crear cuenta');
    },
  });

  const deleteMutation = useMutation({
    mutationFn: async (id: string) => {
      await api.delete(`/api/whatsapp/accounts/${id}`);
    },
    onSuccess: () => {
      message.success('Cuenta eliminada');
      queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
    },
    onError: () => {
      message.error('Error al eliminar');
    },
  });

  const handleShowQR = async (account: WhatsAppAccount) => {
    const response = await api.get(`/api/whatsapp/accounts/${account.id}`);
    setQrModal(response.data);
  };

  const columns = [
    {
      title: 'Nombre',
      dataIndex: 'name',
      key: 'name',
    },
    {
      title: 'Número',
      dataIndex: 'phone_number',
      key: 'phone_number',
      render: (phone: string | null) => phone || '-',
    },
    {
      title: 'Estado',
      dataIndex: 'status',
      key: 'status',
      render: (status: string) => {
        const colors: Record<string, string> = {
          connected: 'green',
          connecting: 'orange',
          disconnected: 'red',
        };
        const labels: Record<string, string> = {
          connected: 'Conectado',
          connecting: 'Conectando',
          disconnected: 'Desconectado',
        };
        return <Tag color={colors[status]}>{labels[status]}</Tag>;
      },
    },
    {
      title: 'Acciones',
      key: 'actions',
      render: (_: any, record: WhatsAppAccount) => (
        <Space>
          {record.status !== 'connected' && (
            <Button
              icon={<QrcodeOutlined />}
              onClick={() => handleShowQR(record)}
            >
              Ver QR
            </Button>
          )}
          <Button
            danger
            icon={<DeleteOutlined />}
            onClick={() => {
              Modal.confirm({
                title: '¿Eliminar cuenta?',
                content: 'Esta acción no se puede deshacer.',
                okText: 'Eliminar',
                okType: 'danger',
                onOk: () => deleteMutation.mutate(record.id),
              });
            }}
          />
        </Space>
      ),
    },
  ];

  return (
    <div>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
        <Title level={4} style={{ margin: 0 }}>Números de WhatsApp</Title>
        <Space>
          <Button icon={<ReloadOutlined />} onClick={() => refetch()}>
            Actualizar
          </Button>
          <Button
            type="primary"
            icon={<PlusOutlined />}
            onClick={() => setIsModalOpen(true)}
            style={{ background: '#25D366', borderColor: '#25D366' }}
          >
            Agregar número
          </Button>
        </Space>
      </div>

      <Card>
        <Table
          dataSource={accounts}
          columns={columns}
          rowKey="id"
          loading={isLoading}
          pagination={false}
        />
      </Card>

      {/* Modal para crear cuenta */}
      <Modal
        title="Agregar número de WhatsApp"
        open={isModalOpen}
        onCancel={() => {
          setIsModalOpen(false);
          form.resetFields();
        }}
        footer={null}
      >
        <Form
          form={form}
          layout="vertical"
          onFinish={(values) => createMutation.mutate(values)}
        >
          <Form.Item
            name="name"
            label="Nombre"
            rules={[{ required: true, message: 'Ingresa un nombre' }]}
          >
            <Input placeholder="Ej: Ventas Principal" />
          </Form.Item>

          <Form.Item>
            <Button
              type="primary"
              htmlType="submit"
              loading={createMutation.isPending}
              block
            >
              Crear y obtener QR
            </Button>
          </Form.Item>
        </Form>
      </Modal>

      {/* Modal para QR */}
      <Modal
        title={`Conectar: ${qrModal?.name}`}
        open={!!qrModal}
        onCancel={() => setQrModal(null)}
        footer={null}
        width={400}
      >
        <div style={{ textAlign: 'center', padding: 20 }}>
          {qrModal?.status === 'connected' ? (
            <div>
              <Tag color="green" style={{ fontSize: 16, padding: '8px 16px' }}>
                 Conectado
              </Tag>
              <Text style={{ display: 'block', marginTop: 16 }}>
                Número: {qrModal.phone_number}
              </Text>
            </div>
          ) : qrModal?.qr_code ? (
            <div>
              <Image
                src={qrModal.qr_code}
                alt="QR Code"
                width={280}
                preview={false}
              />
              <Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
                Escanea con WhatsApp en tu teléfono
              </Text>
            </div>
          ) : (
            <div>
              <Spin size="large" />
              <Text style={{ display: 'block', marginTop: 16 }}>
                Generando código QR...
              </Text>
            </div>
          )}
        </div>
      </Modal>
    </div>
  );
}

Step 2: Commit

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

import { useState } from 'react';
import {
  Card,
  List,
  Avatar,
  Typography,
  Input,
  Button,
  Tag,
  Empty,
  Spin,
  Space,
  Badge,
} from 'antd';
import { SendOutlined, UserOutlined } from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/es';

dayjs.extend(relativeTime);
dayjs.locale('es');

const { Text, Title } = Typography;

interface Contact {
  id: string;
  phone_number: string;
  name: string | null;
}

interface Message {
  id: string;
  direction: 'inbound' | 'outbound';
  type: string;
  content: string | null;
  created_at: string;
}

interface Conversation {
  id: string;
  contact: Contact;
  status: string;
  last_message_at: string | null;
  messages?: Message[];
}

export default function Inbox() {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [messageText, setMessageText] = useState('');
  const queryClient = useQueryClient();

  const { data: conversations, isLoading } = useQuery({
    queryKey: ['conversations'],
    queryFn: async () => {
      const response = await api.get('/api/whatsapp/conversations');
      return response.data as Conversation[];
    },
    refetchInterval: 3000,
  });

  const { data: selectedConversation } = useQuery({
    queryKey: ['conversation', selectedId],
    queryFn: async () => {
      if (!selectedId) return null;
      const response = await api.get(`/api/whatsapp/conversations/${selectedId}`);
      return response.data as Conversation;
    },
    enabled: !!selectedId,
    refetchInterval: 2000,
  });

  const sendMutation = useMutation({
    mutationFn: async (data: { conversationId: string; content: string }) => {
      await api.post(`/api/whatsapp/conversations/${data.conversationId}/messages`, {
        type: 'text',
        content: data.content,
      });
    },
    onSuccess: () => {
      setMessageText('');
      queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] });
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
    },
  });

  const handleSend = () => {
    if (!messageText.trim() || !selectedId) return;
    sendMutation.mutate({ conversationId: selectedId, content: messageText });
  };

  const statusColors: Record<string, string> = {
    bot: 'blue',
    waiting: 'orange',
    active: 'green',
    resolved: 'default',
  };

  return (
    <div style={{ display: 'flex', height: 'calc(100vh - 180px)', gap: 16 }}>
      {/* Lista de conversaciones */}
      <Card
        style={{ width: 350, overflow: 'auto' }}
        bodyStyle={{ padding: 0 }}
        title="Conversaciones"
      >
        {isLoading ? (
          <div style={{ padding: 40, textAlign: 'center' }}>
            <Spin />
          </div>
        ) : conversations?.length === 0 ? (
          <Empty
            description="Sin conversaciones"
            style={{ padding: 40 }}
          />
        ) : (
          <List
            dataSource={conversations}
            renderItem={(conv) => (
              <List.Item
                onClick={() => setSelectedId(conv.id)}
                style={{
                  padding: '12px 16px',
                  cursor: 'pointer',
                  background: selectedId === conv.id ? '#f5f5f5' : 'transparent',
                }}
              >
                <List.Item.Meta
                  avatar={
                    <Badge dot={conv.status !== 'resolved'} color={statusColors[conv.status]}>
                      <Avatar icon={<UserOutlined />} />
                    </Badge>
                  }
                  title={
                    <Space>
                      <Text strong>{conv.contact.name || conv.contact.phone_number}</Text>
                      <Tag color={statusColors[conv.status]} style={{ fontSize: 10 }}>
                        {conv.status}
                      </Tag>
                    </Space>
                  }
                  description={
                    <Text type="secondary" style={{ fontSize: 12 }}>
                      {conv.last_message_at
                        ? dayjs(conv.last_message_at).fromNow()
                        : 'Sin mensajes'}
                    </Text>
                  }
                />
              </List.Item>
            )}
          />
        )}
      </Card>

      {/* Chat */}
      <Card
        style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
        bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: 0 }}
      >
        {selectedConversation ? (
          <>
            {/* Header */}
            <div
              style={{
                padding: 16,
                borderBottom: '1px solid #f0f0f0',
              }}
            >
              <Text strong style={{ fontSize: 16 }}>
                {selectedConversation.contact.name || selectedConversation.contact.phone_number}
              </Text>
              <br />
              <Text type="secondary">{selectedConversation.contact.phone_number}</Text>
            </div>

            {/* Messages */}
            <div
              style={{
                flex: 1,
                overflow: 'auto',
                padding: 16,
                display: 'flex',
                flexDirection: 'column',
                gap: 8,
              }}
            >
              {selectedConversation.messages?.map((msg) => (
                <div
                  key={msg.id}
                  style={{
                    alignSelf: msg.direction === 'outbound' ? 'flex-end' : 'flex-start',
                    maxWidth: '70%',
                  }}
                >
                  <div
                    style={{
                      padding: '8px 12px',
                      borderRadius: 8,
                      background: msg.direction === 'outbound' ? '#25D366' : '#f0f0f0',
                      color: msg.direction === 'outbound' ? 'white' : 'inherit',
                    }}
                  >
                    <Text style={{ color: 'inherit' }}>{msg.content}</Text>
                  </div>
                  <Text
                    type="secondary"
                    style={{
                      fontSize: 10,
                      display: 'block',
                      textAlign: msg.direction === 'outbound' ? 'right' : 'left',
                      marginTop: 4,
                    }}
                  >
                    {dayjs(msg.created_at).format('HH:mm')}
                  </Text>
                </div>
              ))}
            </div>

            {/* Input */}
            <div
              style={{
                padding: 16,
                borderTop: '1px solid #f0f0f0',
                display: 'flex',
                gap: 8,
              }}
            >
              <Input
                placeholder="Escribe un mensaje..."
                value={messageText}
                onChange={(e) => setMessageText(e.target.value)}
                onPressEnter={handleSend}
              />
              <Button
                type="primary"
                icon={<SendOutlined />}
                onClick={handleSend}
                loading={sendMutation.isPending}
                style={{ background: '#25D366', borderColor: '#25D366' }}
              />
            </div>
          </>
        ) : (
          <div
            style={{
              flex: 1,
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
            }}
          >
            <Empty description="Selecciona una conversación" />
          </div>
        )}
      </Card>
    </div>
  );
}

Step 2: Commit

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

{
  "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

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:

# 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