feat: add Layer 3 - API Gateway main app, WhatsApp routes, Frontend pages
API Gateway: - main.py with FastAPI app, CORS, health endpoints - WhatsApp routes: accounts CRUD, conversations, messages, internal events - WhatsApp schemas for request/response validation Frontend: - Login page with register option for first admin - MainLayout with sidebar navigation and user dropdown - Dashboard with statistics cards (accounts, conversations) - WhatsApp Accounts page with QR modal for connection - Inbox page with conversation list and real-time chat Full feature set for Fase 1 Foundation complete. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
40
services/api-gateway/app/main.py
Normal file
40
services/api-gateway/app/main.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import engine, Base
|
||||
from app.routers import auth, whatsapp
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="WhatsApp Centralizado API",
|
||||
description="API Gateway for WhatsApp Centralizado platform",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# CORS
|
||||
origins = settings.CORS_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(whatsapp.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def api_health_check():
|
||||
return {"status": "ok", "service": "api-gateway"}
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.routers.auth import router as auth_router
|
||||
from app.routers.whatsapp import router as whatsapp_router
|
||||
|
||||
__all__ = ["auth_router"]
|
||||
__all__ = ["auth_router", "whatsapp_router"]
|
||||
|
||||
275
services/api-gateway/app/routers/whatsapp.py
Normal file
275
services/api-gateway/app/routers/whatsapp.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
import httpx
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.whatsapp import (
|
||||
WhatsAppAccount, Contact, Conversation, Message,
|
||||
AccountStatus, MessageDirection, MessageType, MessageStatus, ConversationStatus
|
||||
)
|
||||
from app.schemas.whatsapp import (
|
||||
WhatsAppAccountCreate, WhatsAppAccountResponse,
|
||||
ConversationResponse, ConversationDetailResponse,
|
||||
SendMessageRequest, MessageResponse, InternalEventRequest
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Admin required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=WhatsAppAccountResponse)
|
||||
async def create_account(
|
||||
request: WhatsAppAccountCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
account = WhatsAppAccount(name=request.name)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
# Start session in WhatsApp Core
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions",
|
||||
json={"accountId": str(account.id), "name": account.name},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
account.qr_code = data.get("qrCode")
|
||||
account.status = AccountStatus(data.get("status", "connecting"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return account
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=List[WhatsAppAccountResponse])
|
||||
def list_accounts(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
accounts = db.query(WhatsAppAccount).all()
|
||||
return accounts
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=WhatsAppAccountResponse)
|
||||
async def get_account(
|
||||
account_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}",
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
account.qr_code = data.get("qrCode")
|
||||
account.status = AccountStatus(data.get("status", "disconnected"))
|
||||
account.phone_number = data.get("phoneNumber")
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return account
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
async def delete_account(
|
||||
account_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
await client.delete(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}",
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.delete(account)
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=List[ConversationResponse])
|
||||
def list_conversations(
|
||||
status: ConversationStatus = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = db.query(Conversation)
|
||||
if status:
|
||||
query = query.filter(Conversation.status == status)
|
||||
conversations = query.order_by(Conversation.last_message_at.desc()).limit(50).all()
|
||||
return conversations
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=ConversationDetailResponse)
|
||||
def get_conversation(
|
||||
conversation_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
conversation = db.query(Conversation).filter(Conversation.id == conversation_id).first()
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
return conversation
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages", response_model=MessageResponse)
|
||||
async def send_message(
|
||||
conversation_id: UUID,
|
||||
request: SendMessageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
conversation = db.query(Conversation).filter(Conversation.id == conversation_id).first()
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
direction=MessageDirection.OUTBOUND,
|
||||
type=request.type,
|
||||
content=request.content,
|
||||
media_url=request.media_url,
|
||||
sent_by=current_user.id,
|
||||
status=MessageStatus.PENDING,
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{conversation.whatsapp_account_id}/messages",
|
||||
json={
|
||||
"to": conversation.contact.phone_number,
|
||||
"type": request.type.value,
|
||||
"content": {"text": request.content} if request.type == MessageType.TEXT else {
|
||||
"url": request.media_url,
|
||||
"caption": request.content,
|
||||
},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
message.whatsapp_message_id = data.get("messageId")
|
||||
message.status = MessageStatus.SENT
|
||||
else:
|
||||
message.status = MessageStatus.FAILED
|
||||
except Exception:
|
||||
message.status = MessageStatus.FAILED
|
||||
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
# Internal endpoint for WhatsApp Core events
|
||||
@router.post("/internal/whatsapp/event")
|
||||
async def handle_whatsapp_event(
|
||||
event: InternalEventRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = db.query(WhatsAppAccount).filter(
|
||||
WhatsAppAccount.id == event.accountId
|
||||
).first()
|
||||
|
||||
if not account:
|
||||
return {"status": "ignored", "reason": "account not found"}
|
||||
|
||||
if event.type == "qr":
|
||||
account.qr_code = event.data.get("qrCode")
|
||||
account.status = AccountStatus.CONNECTING
|
||||
|
||||
elif event.type == "connected":
|
||||
account.status = AccountStatus.CONNECTED
|
||||
account.phone_number = event.data.get("phoneNumber")
|
||||
account.qr_code = None
|
||||
|
||||
elif event.type == "disconnected":
|
||||
account.status = AccountStatus.DISCONNECTED
|
||||
account.qr_code = None
|
||||
|
||||
elif event.type == "message":
|
||||
msg_data = event.data
|
||||
phone = msg_data.get("from", "").split("@")[0]
|
||||
|
||||
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
|
||||
if not contact:
|
||||
contact = Contact(
|
||||
phone_number=phone,
|
||||
name=msg_data.get("pushName"),
|
||||
)
|
||||
db.add(contact)
|
||||
db.commit()
|
||||
db.refresh(contact)
|
||||
|
||||
conversation = db.query(Conversation).filter(
|
||||
Conversation.whatsapp_account_id == account.id,
|
||||
Conversation.contact_id == contact.id,
|
||||
Conversation.status != ConversationStatus.RESOLVED,
|
||||
).first()
|
||||
|
||||
if not conversation:
|
||||
conversation = Conversation(
|
||||
whatsapp_account_id=account.id,
|
||||
contact_id=contact.id,
|
||||
status=ConversationStatus.BOT,
|
||||
)
|
||||
db.add(conversation)
|
||||
db.commit()
|
||||
db.refresh(conversation)
|
||||
|
||||
wa_message = msg_data.get("message", {})
|
||||
content = (
|
||||
wa_message.get("conversation") or
|
||||
wa_message.get("extendedTextMessage", {}).get("text") or
|
||||
"[Media]"
|
||||
)
|
||||
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
whatsapp_message_id=msg_data.get("id"),
|
||||
direction=MessageDirection.INBOUND,
|
||||
type=MessageType.TEXT,
|
||||
content=content,
|
||||
status=MessageStatus.DELIVERED,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
from datetime import datetime
|
||||
conversation.last_message_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
76
services/api-gateway/app/schemas/whatsapp.py
Normal file
76
services/api-gateway/app/schemas/whatsapp.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from app.models.whatsapp import AccountStatus, ConversationStatus, MessageType, MessageDirection, MessageStatus
|
||||
|
||||
|
||||
class WhatsAppAccountCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class WhatsAppAccountResponse(BaseModel):
|
||||
id: UUID
|
||||
phone_number: Optional[str]
|
||||
name: str
|
||||
status: AccountStatus
|
||||
qr_code: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
id: UUID
|
||||
phone_number: str
|
||||
name: Optional[str]
|
||||
email: Optional[str]
|
||||
company: Optional[str]
|
||||
tags: List[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: UUID
|
||||
direction: MessageDirection
|
||||
type: MessageType
|
||||
content: Optional[str]
|
||||
media_url: Optional[str]
|
||||
status: MessageStatus
|
||||
is_internal_note: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: UUID
|
||||
contact: ContactResponse
|
||||
status: ConversationStatus
|
||||
last_message_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ConversationDetailResponse(ConversationResponse):
|
||||
messages: List[MessageResponse]
|
||||
whatsapp_account: WhatsAppAccountResponse
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
type: MessageType = MessageType.TEXT
|
||||
content: str
|
||||
media_url: Optional[str] = None
|
||||
|
||||
|
||||
class InternalEventRequest(BaseModel):
|
||||
type: str
|
||||
accountId: str
|
||||
data: dict
|
||||
Reference in New Issue
Block a user