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