feat: initial commit — HermesMessages SaaS platform

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>
This commit is contained in:
2026-04-28 09:49:41 -05:00
commit 798bd14312
95 changed files with 5836 additions and 0 deletions

View File

View File

@ -0,0 +1,21 @@
import httpx
GRAPH_API_VERSION = "v20.0"
GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
async def send_text_message(phone_number_id: str, access_token: str, to: str, text: str) -> None:
url = f"{GRAPH_API_BASE}/{phone_number_id}/messages"
payload = {
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": text},
}
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()

View File

@ -0,0 +1,76 @@
import asyncio
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.dependencies import get_current_business
from app.modules.whatsapp import schemas, service
router = APIRouter()
@router.post("/connect", response_model=dict)
async def connect(
body: schemas.ConnectRequest,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
await service.connect_whatsapp(db, business_id, body)
return {"detail": "WhatsApp conectado correctamente"}
@router.get("/status", response_model=schemas.WhatsAppStatusRead)
async def get_status(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select
from app.modules.business.models import Business
result = await db.execute(select(Business).where(Business.id == business_id))
business = result.scalar_one_or_none()
return schemas.WhatsAppStatusRead(
connected=bool(business and business.whatsapp_phone_number_id),
phone_number_id=business.whatsapp_phone_number_id if business else None,
meta_business_id=business.meta_business_id if business else None,
)
@router.post("/disconnect", status_code=204)
async def disconnect(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
await service.disconnect_whatsapp(db, business_id)
return Response(status_code=204)
@router.get("/webhook")
async def verify_webhook(
hub_mode: str | None = Query(default=None, alias="hub.mode"),
hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
):
"""Verificación inicial del webhook por parte de Meta."""
if hub_mode == "subscribe" and hub_verify_token == settings.META_WEBHOOK_VERIFY_TOKEN:
return Response(content=hub_challenge, media_type="text/plain")
return Response(status_code=403)
@router.post("/webhook", status_code=200)
async def receive_webhook(
request: Request,
background_tasks: BackgroundTasks,
x_hub_signature_256: str | None = Header(default=None),
db: AsyncSession = Depends(get_db),
):
"""Recibe mensajes de Meta, valida firma y despacha al bot de forma asíncrona."""
body_bytes = await request.body()
service.verify_signature(body_bytes, x_hub_signature_256 or "")
payload = schemas.WebhookPayload.model_validate_json(body_bytes)
background_tasks.add_task(service.dispatch_webhook, db, payload)
return {"status": "ok"}

View File

@ -0,0 +1,49 @@
from pydantic import BaseModel
class ConnectRequest(BaseModel):
access_token: str
phone_number_id: str
meta_business_id: str
class WhatsAppStatusRead(BaseModel):
connected: bool
phone_number_id: str | None
meta_business_id: str | None
# --- Estructuras del payload de Meta ---
class WhatsAppTextMessage(BaseModel):
body: str
class WhatsAppMessage(BaseModel):
from_: str
id: str
type: str
text: WhatsAppTextMessage | None = None
model_config = {"populate_by_name": True}
class WhatsAppValue(BaseModel):
messaging_product: str
metadata: dict
messages: list[WhatsAppMessage] | None = None
class WhatsAppChange(BaseModel):
value: WhatsAppValue
field: str
class WhatsAppEntry(BaseModel):
id: str
changes: list[WhatsAppChange]
class WebhookPayload(BaseModel):
object: str
entry: list[WhatsAppEntry]

View File

@ -0,0 +1,87 @@
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,
)