Backend (FastAPI + Python 3.12): - Multi-tenant auth with JWT: login, register, refresh, Meta OAuth - Business & BusinessConfig management - WhatsApp webhook with HMAC signature verification - Bot engine powered by Claude AI - Calendar availability with Redis caching - Reservations CRUD with status management - Dashboard analytics (stats, agenda, peak hours) - Billing & plan management - Admin panel with platform-wide stats - Async bcrypt via asyncio.to_thread - IntegrityError handling for concurrent registration race conditions Frontend (React 18 + Vite + Tailwind CSS): - Multi-step guided registration form with helper text on every field - Login page with show/hide password toggle - Protected routes with AuthContext - Dashboard with stats cards, bar chart, and daily agenda - Reservations list with search, filters, and inline status actions - Calendar with weekly view, slot availability, and date blocking - Config page: business info, schedules, bot personality - Billing page with plan comparison and usage bar Design system: - Bricolage Grotesque + DM Sans typography - Emerald primary palette with semantic color tokens - scale(0.97) button press feedback, ease-out animations - Skeleton loaders, stagger animations, prefers-reduced-motion support - Accessible: aria-labels, visible focus rings, 4.5:1 contrast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
import hashlib
|
|
import hmac
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.modules.business.models import Business
|
|
from app.modules.whatsapp.schemas import ConnectRequest, WebhookPayload
|
|
|
|
|
|
def verify_signature(payload_bytes: bytes, signature_header: str) -> None:
|
|
"""Valida X-Hub-Signature-256 enviado por Meta."""
|
|
if not signature_header or not signature_header.startswith("sha256="):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Firma ausente")
|
|
|
|
expected = hmac.new(
|
|
settings.META_APP_SECRET.encode(),
|
|
payload_bytes,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
if not hmac.compare_digest(expected, signature_header[len("sha256="):]):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Firma inválida")
|
|
|
|
|
|
async def get_business_by_phone_number_id(
|
|
db: AsyncSession, phone_number_id: str
|
|
) -> Business | None:
|
|
result = await db.execute(
|
|
select(Business).where(Business.whatsapp_phone_number_id == phone_number_id)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def connect_whatsapp(
|
|
db: AsyncSession, business_id: int, data: ConnectRequest
|
|
) -> Business:
|
|
result = await db.execute(select(Business).where(Business.id == business_id))
|
|
business = result.scalar_one_or_none()
|
|
if not business:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
|
|
|
business.whatsapp_access_token = data.access_token
|
|
business.whatsapp_phone_number_id = data.phone_number_id
|
|
business.meta_business_id = data.meta_business_id
|
|
await db.commit()
|
|
await db.refresh(business)
|
|
return business
|
|
|
|
|
|
async def disconnect_whatsapp(db: AsyncSession, business_id: int) -> None:
|
|
result = await db.execute(select(Business).where(Business.id == business_id))
|
|
business = result.scalar_one_or_none()
|
|
if not business:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
|
|
|
business.whatsapp_access_token = None
|
|
business.whatsapp_phone_number_id = None
|
|
business.meta_business_id = None
|
|
await db.commit()
|
|
|
|
|
|
async def dispatch_webhook(db: AsyncSession, payload: WebhookPayload) -> None:
|
|
"""Procesa cada mensaje entrante y lo envía al bot engine."""
|
|
from app.modules.bot_engine.service import process_message
|
|
|
|
for entry in payload.entry:
|
|
for change in entry.changes:
|
|
if change.field != "messages" or not change.value.messages:
|
|
continue
|
|
|
|
phone_number_id = change.value.metadata.get("phone_number_id")
|
|
business = await get_business_by_phone_number_id(db, phone_number_id)
|
|
if not business:
|
|
continue
|
|
|
|
for message in change.value.messages:
|
|
if message.type != "text" or not message.text:
|
|
continue
|
|
await process_message(
|
|
db=db,
|
|
phone=message.from_,
|
|
text=message.text.body,
|
|
business=business,
|
|
)
|