feat: add Layer 2 - WhatsApp Core logic, API Gateway models/auth, Frontend core
WhatsApp Core: - SessionManager with Baileys integration for multi-account support - Express server with REST API and Socket.IO for real-time events - Session lifecycle management (create, disconnect, delete) - Message sending with support for text, image, document, audio, video API Gateway: - Database models: User, WhatsAppAccount, Contact, Conversation, Message - JWT authentication with access/refresh tokens - Auth endpoints: login, refresh, register, me - Pydantic schemas for request/response validation Frontend: - React 18 app structure with routing - Zustand auth store with persistence - API client with automatic token handling - Base CSS and TypeScript declarations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
33
frontend/src/App.tsx
Normal file
33
frontend/src/App.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from './store/auth';
|
||||||
|
|
||||||
|
// Placeholder components - will be replaced
|
||||||
|
const LoginPage = () => <div>Login Page</div>;
|
||||||
|
const DashboardPage = () => <div>Dashboard</div>;
|
||||||
|
const MainLayout = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||||
|
|
||||||
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<MainLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MainLayout>
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/src/api/client.ts
Normal file
79
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
interface RequestOptions extends RequestInit {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthHeaders(): Record<string, string> {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const { params, ...fetchOptions } = options;
|
||||||
|
|
||||||
|
let url = `${this.baseUrl}${endpoint}`;
|
||||||
|
if (params) {
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
url += `?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...this.getAuthHeaders(),
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<T>(endpoint: string, options?: RequestOptions): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, { ...options, method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
put<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T>(endpoint: string, options?: RequestOptions): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient(API_BASE_URL);
|
||||||
15
frontend/src/index.css
Normal file
15
frontend/src/index.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
29
frontend/src/main.tsx
Normal file
29
frontend/src/main.tsx
Normal file
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConfigProvider locale={esES}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ConfigProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
54
frontend/src/store/auth.ts
Normal file
54
frontend/src/store/auth.ts
Normal file
@@ -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<User>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
readonly VITE_WS_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
0
services/api-gateway/app/core/__init__.py
Normal file
0
services/api-gateway/app/core/__init__.py
Normal file
30
services/api-gateway/app/core/config.py
Normal file
30
services/api-gateway/app/core/config.py
Normal file
@@ -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()
|
||||||
18
services/api-gateway/app/core/database.py
Normal file
18
services/api-gateway/app/core/database.py
Normal file
@@ -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()
|
||||||
77
services/api-gateway/app/core/security.py
Normal file
77
services/api-gateway/app/core/security.py
Normal file
@@ -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
|
||||||
4
services/api-gateway/app/models/__init__.py
Normal file
4
services/api-gateway/app/models/__init__.py
Normal file
@@ -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"]
|
||||||
33
services/api-gateway/app/models/user.py
Normal file
33
services/api-gateway/app/models/user.py
Normal file
@@ -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)
|
||||||
111
services/api-gateway/app/models/whatsapp.py
Normal file
111
services/api-gateway/app/models/whatsapp.py
Normal file
@@ -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")
|
||||||
3
services/api-gateway/app/routers/__init__.py
Normal file
3
services/api-gateway/app/routers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.routers.auth import router as auth_router
|
||||||
|
|
||||||
|
__all__ = ["auth_router"]
|
||||||
96
services/api-gateway/app/routers/auth.py
Normal file
96
services/api-gateway/app/routers/auth.py
Normal file
@@ -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)
|
||||||
17
services/api-gateway/app/schemas/__init__.py
Normal file
17
services/api-gateway/app/schemas/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from app.schemas.auth import (
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
TokenResponse,
|
||||||
|
RefreshRequest,
|
||||||
|
UserResponse,
|
||||||
|
CreateUserRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LoginRequest",
|
||||||
|
"LoginResponse",
|
||||||
|
"TokenResponse",
|
||||||
|
"RefreshRequest",
|
||||||
|
"UserResponse",
|
||||||
|
"CreateUserRequest",
|
||||||
|
]
|
||||||
45
services/api-gateway/app/schemas/auth.py
Normal file
45
services/api-gateway/app/schemas/auth.py
Normal file
@@ -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
|
||||||
99
services/whatsapp-core/src/api/routes.ts
Normal file
99
services/whatsapp-core/src/api/routes.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
74
services/whatsapp-core/src/index.ts
Normal file
74
services/whatsapp-core/src/index.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user