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:
Claude AI
2026-01-29 09:55:10 +00:00
parent 31d68bc118
commit 7042aa2061
19 changed files with 827 additions and 0 deletions

View 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()

View 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()

View 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

View 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"]

View 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)

View 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")

View File

@@ -0,0 +1,3 @@
from app.routers.auth import router as auth_router
__all__ = ["auth_router"]

View 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)

View File

@@ -0,0 +1,17 @@
from app.schemas.auth import (
LoginRequest,
LoginResponse,
TokenResponse,
RefreshRequest,
UserResponse,
CreateUserRequest,
)
__all__ = [
"LoginRequest",
"LoginResponse",
"TokenResponse",
"RefreshRequest",
"UserResponse",
"CreateUserRequest",
]

View 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