diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..2f09b14
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,33 @@
+import { Routes, Route, Navigate } from 'react-router-dom';
+import { useAuthStore } from './store/auth';
+
+// Placeholder components - will be replaced
+const LoginPage = () =>
Login Page
;
+const DashboardPage = () => Dashboard
;
+const MainLayout = ({ children }: { children: React.ReactNode }) => {children}
;
+
+function PrivateRoute({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+ return isAuthenticated ? <>{children}> : ;
+}
+
+export default function App() {
+ return (
+
+ } />
+
+
+
+ } />
+ } />
+
+
+
+ }
+ />
+
+ );
+}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..54d330f
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,79 @@
+const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
+
+interface RequestOptions extends RequestInit {
+ params?: Record;
+}
+
+class ApiClient {
+ private baseUrl: string;
+
+ constructor(baseUrl: string) {
+ this.baseUrl = baseUrl;
+ }
+
+ private getAuthHeaders(): Record {
+ const token = localStorage.getItem('access_token');
+ return token ? { Authorization: `Bearer ${token}` } : {};
+ }
+
+ private async request(endpoint: string, options: RequestOptions = {}): Promise {
+ const { params, ...fetchOptions } = options;
+
+ let url = `${this.baseUrl}${endpoint}`;
+ if (params) {
+ const searchParams = new URLSearchParams(params);
+ url += `?${searchParams.toString()}`;
+ }
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...this.getAuthHeaders(),
+ ...(options.headers as Record),
+ };
+
+ const response = await fetch(url, {
+ ...fetchOptions,
+ headers,
+ });
+
+ if (response.status === 401) {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ window.location.href = '/login';
+ throw new Error('Unauthorized');
+ }
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Request failed' }));
+ throw new Error(error.detail || 'Request failed');
+ }
+
+ return response.json();
+ }
+
+ get(endpoint: string, options?: RequestOptions): Promise {
+ return this.request(endpoint, { ...options, method: 'GET' });
+ }
+
+ post(endpoint: string, data?: unknown, options?: RequestOptions): Promise {
+ return this.request(endpoint, {
+ ...options,
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ }
+
+ put(endpoint: string, data?: unknown, options?: RequestOptions): Promise {
+ return this.request(endpoint, {
+ ...options,
+ method: 'PUT',
+ body: JSON.stringify(data),
+ });
+ }
+
+ delete(endpoint: string, options?: RequestOptions): Promise {
+ return this.request(endpoint, { ...options, method: 'DELETE' });
+ }
+}
+
+export const apiClient = new ApiClient(API_BASE_URL);
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..805aa9d
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,15 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+ min-height: 100vh;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..5617f14
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+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: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
new file mode 100644
index 0000000..2e27552
--- /dev/null
+++ b/frontend/src/store/auth.ts
@@ -0,0 +1,54 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface User {
+ id: string;
+ email: string;
+ name: string;
+ role: string;
+ status: string;
+}
+
+interface AuthState {
+ user: User | null;
+ accessToken: string | null;
+ refreshToken: string | null;
+ isAuthenticated: boolean;
+ setAuth: (user: User, accessToken: string, refreshToken: string) => void;
+ logout: () => void;
+ updateUser: (user: Partial) => void;
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ isAuthenticated: false,
+ setAuth: (user, accessToken, refreshToken) => {
+ localStorage.setItem('access_token', accessToken);
+ localStorage.setItem('refresh_token', refreshToken);
+ set({ user, accessToken, refreshToken, isAuthenticated: true });
+ },
+ logout: () => {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false });
+ },
+ updateUser: (userData) =>
+ set((state) => ({
+ user: state.user ? { ...state.user, ...userData } : null,
+ })),
+ }),
+ {
+ name: 'auth-storage',
+ partialize: (state) => ({
+ user: state.user,
+ accessToken: state.accessToken,
+ refreshToken: state.refreshToken,
+ isAuthenticated: state.isAuthenticated,
+ }),
+ }
+ )
+);
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..4ac6adf
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL: string;
+ readonly VITE_WS_URL: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/services/api-gateway/app/core/__init__.py b/services/api-gateway/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/api-gateway/app/core/config.py b/services/api-gateway/app/core/config.py
new file mode 100644
index 0000000..ebac1ea
--- /dev/null
+++ b/services/api-gateway/app/core/config.py
@@ -0,0 +1,30 @@
+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()
diff --git a/services/api-gateway/app/core/database.py b/services/api-gateway/app/core/database.py
new file mode 100644
index 0000000..28cf6ea
--- /dev/null
+++ b/services/api-gateway/app/core/database.py
@@ -0,0 +1,18 @@
+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()
diff --git a/services/api-gateway/app/core/security.py b/services/api-gateway/app/core/security.py
new file mode 100644
index 0000000..f2d04f1
--- /dev/null
+++ b/services/api-gateway/app/core/security.py
@@ -0,0 +1,77 @@
+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
diff --git a/services/api-gateway/app/models/__init__.py b/services/api-gateway/app/models/__init__.py
new file mode 100644
index 0000000..c8224b7
--- /dev/null
+++ b/services/api-gateway/app/models/__init__.py
@@ -0,0 +1,4 @@
+from app.models.user import User
+from app.models.whatsapp import WhatsAppAccount, Contact, Conversation, Message
+
+__all__ = ["User", "WhatsAppAccount", "Contact", "Conversation", "Message"]
diff --git a/services/api-gateway/app/models/user.py b/services/api-gateway/app/models/user.py
new file mode 100644
index 0000000..887faa8
--- /dev/null
+++ b/services/api-gateway/app/models/user.py
@@ -0,0 +1,33 @@
+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)
diff --git a/services/api-gateway/app/models/whatsapp.py b/services/api-gateway/app/models/whatsapp.py
new file mode 100644
index 0000000..86274bc
--- /dev/null
+++ b/services/api-gateway/app/models/whatsapp.py
@@ -0,0 +1,111 @@
+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")
diff --git a/services/api-gateway/app/routers/__init__.py b/services/api-gateway/app/routers/__init__.py
new file mode 100644
index 0000000..4f04b08
--- /dev/null
+++ b/services/api-gateway/app/routers/__init__.py
@@ -0,0 +1,3 @@
+from app.routers.auth import router as auth_router
+
+__all__ = ["auth_router"]
diff --git a/services/api-gateway/app/routers/auth.py b/services/api-gateway/app/routers/auth.py
new file mode 100644
index 0000000..d8e0444
--- /dev/null
+++ b/services/api-gateway/app/routers/auth.py
@@ -0,0 +1,96 @@
+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)
diff --git a/services/api-gateway/app/schemas/__init__.py b/services/api-gateway/app/schemas/__init__.py
new file mode 100644
index 0000000..9fa7c0e
--- /dev/null
+++ b/services/api-gateway/app/schemas/__init__.py
@@ -0,0 +1,17 @@
+from app.schemas.auth import (
+ LoginRequest,
+ LoginResponse,
+ TokenResponse,
+ RefreshRequest,
+ UserResponse,
+ CreateUserRequest,
+)
+
+__all__ = [
+ "LoginRequest",
+ "LoginResponse",
+ "TokenResponse",
+ "RefreshRequest",
+ "UserResponse",
+ "CreateUserRequest",
+]
diff --git a/services/api-gateway/app/schemas/auth.py b/services/api-gateway/app/schemas/auth.py
new file mode 100644
index 0000000..2c448eb
--- /dev/null
+++ b/services/api-gateway/app/schemas/auth.py
@@ -0,0 +1,45 @@
+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
diff --git a/services/whatsapp-core/src/api/routes.ts b/services/whatsapp-core/src/api/routes.ts
new file mode 100644
index 0000000..fe3a480
--- /dev/null
+++ b/services/whatsapp-core/src/api/routes.ts
@@ -0,0 +1,99 @@
+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;
+}
diff --git a/services/whatsapp-core/src/index.ts b/services/whatsapp-core/src/index.ts
new file mode 100644
index 0000000..6c37973
--- /dev/null
+++ b/services/whatsapp-core/src/index.ts
@@ -0,0 +1,74 @@
+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);
+});