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

@ -0,0 +1,73 @@
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
from app.modules.business.models import Business, BusinessConfig
from app.modules.calendar.schemas import DayAvailability
DAYS_ES = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
def _format_open_days(open_days: list[int]) -> str:
return ", ".join(DAYS_ES[d] for d in sorted(open_days))
def _format_slots(availability: DayAvailability | None) -> str:
if not availability or not availability.is_open:
return "No hay disponibilidad para esa fecha."
available = [s for s in availability.slots if s.available > 0]
if not available:
return "No quedan slots disponibles para esa fecha."
return ", ".join(s.time_start.strftime("%H:%M") for s in available)
def build_system_prompt(
business: Business,
config: BusinessConfig,
availability: DayAvailability | None,
context: ConversationContext,
) -> str:
tone_instruction = (
"Usa un tono formal y profesional."
if config.tone == "formal"
else "Usa un tono amigable y cercano."
)
collected = context.collected_data
collected_summary = "\n".join([
f"- Nombre: {collected.client_name or 'pendiente'}",
f"- Fecha: {collected.date or 'pendiente'}",
f"- Hora: {collected.time_start or 'pendiente'}",
f"- Personas: {collected.party_size or 'pendiente'}",
])
slots_info = _format_slots(availability)
return f"""Eres {config.assistant_name}, asistente virtual de {business.name}.
{tone_instruction}
Responde SIEMPRE en el idioma del cliente.
HORARIO DEL NEGOCIO:
- Días de atención: {_format_open_days(config.open_days or [])}
- Horario: {config.open_time.strftime("%H:%M")} a {config.close_time.strftime("%H:%M")}
- Duración de cada turno: {config.slot_duration} minutos
- Capacidad por turno: {config.max_per_slot} persona(s)
SLOTS DISPONIBLES PARA LA FECHA SOLICITADA: {slots_info}
DATOS YA RECOPILADOS DEL CLIENTE:
{collected_summary}
OBJETIVO: Recopilar nombre, fecha, hora y número de personas para crear la reserva.
Si ya tienes todos los datos, acción = "create_reservation".
Si el cliente quiere cancelar, acción = "cancel".
En cualquier otro caso, acción = "collect_more".
Responde ÚNICAMENTE con JSON válido siguiendo este esquema exacto (sin markdown, sin explicaciones):
{{
"message": "<texto para enviar al cliente>",
"action": "collect_more" | "create_reservation" | "cancel",
"collected_data": {{
"client_name": null | "<nombre>",
"date": null | "<YYYY-MM-DD>",
"time_start": null | "<HH:MM>",
"party_size": null | <número>
}}
}}"""

View File

@ -0,0 +1,21 @@
from pydantic import BaseModel
class CollectedData(BaseModel):
client_name: str | None = None
date: str | None = None # YYYY-MM-DD
time_start: str | None = None # HH:MM
party_size: int | None = None
class ConversationContext(BaseModel):
phone: str
business_id: int
collected_data: CollectedData = CollectedData()
messages: list[dict] = [] # historial {role, content} para Claude
class BotResponse(BaseModel):
message: str
action: str # "collect_more" | "create_reservation" | "cancel"
collected_data: CollectedData

View File

@ -0,0 +1,145 @@
import json
import logging
from datetime import date, time
import anthropic
import redis.asyncio as aioredis
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.modules.bot_engine.prompt import build_system_prompt
from app.modules.bot_engine.schemas import BotResponse, CollectedData, ConversationContext
from app.modules.business.models import Business
from app.modules.business.service import get_business_config
from app.modules.calendar.service import get_available_slots
from app.modules.reservations.schemas import ReservationCreate
from app.modules.reservations.service import create_reservation
from app.modules.whatsapp.client import send_text_message
logger = logging.getLogger(__name__)
CONTEXT_TTL = 1800 # 30 minutos
MODEL = "claude-sonnet-4-20250514"
_anthropic = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
def _context_key(business_id: int, phone: str) -> str:
return f"conv:{business_id}:{phone}"
async def _load_context(redis: aioredis.Redis, business_id: int, phone: str) -> ConversationContext:
raw = await redis.get(_context_key(business_id, phone))
if raw:
return ConversationContext.model_validate_json(raw)
return ConversationContext(phone=phone, business_id=business_id)
async def _save_context(redis: aioredis.Redis, context: ConversationContext) -> None:
await redis.setex(
_context_key(context.business_id, context.phone),
CONTEXT_TTL,
context.model_dump_json(),
)
async def _clear_context(redis: aioredis.Redis, business_id: int, phone: str) -> None:
await redis.delete(_context_key(business_id, phone))
async def _call_claude(system_prompt: str, messages: list[dict]) -> BotResponse:
response = await _anthropic.messages.create(
model=MODEL,
max_tokens=1024,
system=system_prompt,
messages=messages,
)
raw_text = response.content[0].text.strip()
try:
data = json.loads(raw_text)
return BotResponse.model_validate(data)
except Exception:
logger.warning("Claude devolvió JSON inválido: %s", raw_text)
return BotResponse(
message=raw_text,
action="collect_more",
collected_data=CollectedData(),
)
async def _handle_create_reservation(
db: AsyncSession,
redis: aioredis.Redis,
business: Business,
phone: str,
bot_response: BotResponse,
) -> None:
cd = bot_response.collected_data
if not all([cd.client_name, cd.date, cd.time_start, cd.party_size]):
return
await create_reservation(
db=db,
redis=redis,
business_id=business.id,
data=ReservationCreate(
client_name=cd.client_name,
client_phone=phone,
date=date.fromisoformat(cd.date),
time_start=time.fromisoformat(cd.time_start),
party_size=cd.party_size,
source="whatsapp",
),
)
await _clear_context(redis, business.id, phone)
async def process_message(
db: AsyncSession,
phone: str,
text: str,
business: Business,
) -> None:
redis: aioredis.Redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
try:
context = await _load_context(redis, business.id, phone)
config = await get_business_config(db, business.id)
availability = None
if context.collected_data.date:
try:
availability = await get_available_slots(
db, redis, business.id, date.fromisoformat(context.collected_data.date)
)
except Exception:
pass
system_prompt = build_system_prompt(business, config, availability, context)
context.messages.append({"role": "user", "content": text})
bot_response = await _call_claude(system_prompt, context.messages)
context.messages.append({"role": "assistant", "content": bot_response.message})
context.collected_data = bot_response.collected_data
if bot_response.action == "create_reservation":
await _handle_create_reservation(db, redis, business, phone, bot_response)
elif bot_response.action == "cancel":
await _clear_context(redis, business.id, phone)
else:
await _save_context(redis, context)
await send_text_message(
phone_number_id=business.whatsapp_phone_number_id,
access_token=business.whatsapp_access_token,
to=phone,
text=bot_response.message,
)
except Exception as exc:
logger.exception("Error procesando mensaje de %s: %s", phone, exc)
finally:
await redis.aclose()