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

8
backend/.env.example Normal file
View File

@ -0,0 +1,8 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/hermesmessages
REDIS_URL=redis://localhost:6379
SECRET_KEY=changeme-use-a-long-random-string
META_APP_ID=
META_APP_SECRET=
META_WEBHOOK_VERIFY_TOKEN=
ANTHROPIC_API_KEY=
ENVIRONMENT=development

38
backend/alembic.ini Normal file
View File

@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

51
backend/alembic/env.py Normal file
View File

@ -0,0 +1,51 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
from app.core.database import Base
# Importar todos los modelos para que Alembic los detecte
import app.modules.auth.models # noqa
import app.modules.business.models # noqa
import app.modules.reservations.models # noqa
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(
url=settings.DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
engine = create_async_engine(settings.DATABASE_URL)
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
await engine.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

View File

@ -0,0 +1,155 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-04-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- Enums ---
business_status = postgresql.ENUM("trial", "active", "suspended", name="business_status")
business_plan = postgresql.ENUM("free", "basic", "pro", name="business_plan")
user_role = postgresql.ENUM("owner", "admin", name="user_role")
assistant_tone = postgresql.ENUM("formal", "casual", name="assistant_tone")
reservation_status = postgresql.ENUM(
"pending", "confirmed", "cancelled", "no_show", name="reservation_status"
)
reservation_source = postgresql.ENUM("whatsapp", "manual", name="reservation_source")
for enum in [business_status, business_plan, user_role, assistant_tone,
reservation_status, reservation_source]:
enum.create(op.get_bind(), checkfirst=True)
# --- businesses ---
op.create_table(
"businesses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("type", sa.String(), nullable=False),
sa.Column("timezone", sa.String(), nullable=False, server_default="UTC"),
sa.Column(
"status",
sa.Enum("trial", "active", "suspended", name="business_status"),
nullable=False,
server_default="trial",
),
sa.Column(
"plan",
sa.Enum("free", "basic", "pro", name="business_plan"),
nullable=False,
server_default="free",
),
sa.Column("meta_business_id", sa.String(), nullable=True),
sa.Column("whatsapp_phone_number_id", sa.String(), nullable=True, unique=True),
sa.Column("whatsapp_access_token", sa.String(), nullable=True),
sa.Column("created_at", sa.Date(), server_default=sa.func.current_date()),
)
op.create_index("ix_businesses_id", "businesses", ["id"])
# --- users ---
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"business_id",
sa.Integer(),
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("email", sa.String(), nullable=False, unique=True),
sa.Column("hashed_password", sa.String(), nullable=True),
sa.Column("meta_user_id", sa.String(), nullable=True, unique=True),
sa.Column(
"role",
sa.Enum("owner", "admin", name="user_role"),
nullable=False,
server_default="owner",
),
)
op.create_index("ix_users_id", "users", ["id"])
op.create_index("ix_users_email", "users", ["email"])
# --- business_configs ---
op.create_table(
"business_configs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"business_id",
sa.Integer(),
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("open_days", postgresql.ARRAY(sa.Integer()), nullable=False, server_default="{}"),
sa.Column("open_time", sa.Time(), nullable=False, server_default="09:00"),
sa.Column("close_time", sa.Time(), nullable=False, server_default="18:00"),
sa.Column("slot_duration", sa.Integer(), nullable=False, server_default="60"),
sa.Column("max_per_slot", sa.Integer(), nullable=False, server_default="1"),
sa.Column("blocked_dates", postgresql.ARRAY(sa.Date()), nullable=False, server_default="{}"),
sa.Column("assistant_name", sa.String(), nullable=False, server_default="Hermes"),
sa.Column(
"tone",
sa.Enum("formal", "casual", name="assistant_tone"),
nullable=False,
server_default="formal",
),
sa.Column("welcome_message", sa.String(), nullable=True),
)
op.create_index("ix_business_configs_id", "business_configs", ["id"])
# --- reservations ---
op.create_table(
"reservations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"business_id",
sa.Integer(),
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("client_name", sa.String(), nullable=False),
sa.Column("client_phone", sa.String(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("time_start", sa.Time(), nullable=False),
sa.Column("time_end", sa.Time(), nullable=False),
sa.Column("party_size", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"status",
sa.Enum("pending", "confirmed", "cancelled", "no_show", name="reservation_status"),
nullable=False,
server_default="pending",
),
sa.Column(
"source",
sa.Enum("whatsapp", "manual", name="reservation_source"),
nullable=False,
server_default="manual",
),
sa.Column("notes", sa.String(), nullable=True),
sa.Column("created_at", sa.Date(), server_default=sa.func.current_date()),
)
op.create_index("ix_reservations_id", "reservations", ["id"])
op.create_index("ix_reservations_date", "reservations", ["date"])
def downgrade() -> None:
op.drop_table("reservations")
op.drop_table("business_configs")
op.drop_table("users")
op.drop_table("businesses")
for name in [
"reservation_source", "reservation_status", "assistant_tone",
"user_role", "business_plan", "business_status",
]:
postgresql.ENUM(name=name).drop(op.get_bind(), checkfirst=True)

0
backend/app/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
DATABASE_URL: str
REDIS_URL: str
SECRET_KEY: str
ENVIRONMENT: str = "development"
META_APP_ID: str = ""
META_APP_SECRET: str = ""
META_WEBHOOK_VERIFY_TOKEN: str = ""
ANTHROPIC_API_KEY: str = ""
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
ALGORITHM: str = "HS256"
settings = Settings()

View File

@ -0,0 +1,30 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
pass
_engine = None
_session_factory = None
def get_engine():
global _engine
if _engine is None:
_engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.ENVIRONMENT == "development",
)
return _engine
async def get_db() -> AsyncSession:
global _session_factory
if _session_factory is None:
_session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with _session_factory() as session:
yield session

View File

@ -0,0 +1,43 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_token
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
):
from app.modules.auth.service import get_user_by_id
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido o expirado",
)
try:
payload = decode_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = await get_user_by_id(db, int(user_id))
if user is None:
raise credentials_exception
return user
async def get_current_business(current_user=Depends(get_current_user)):
return current_user.business_id
def require_admin(current_user=Depends(get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Acceso denegado")
return current_user

View File

@ -0,0 +1,9 @@
from fastapi import Request
from fastapi.responses import JSONResponse
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": "Error interno del servidor"},
)

View File

@ -0,0 +1,9 @@
import redis.asyncio as aioredis
from app.core.config import settings
redis_client: aioredis.Redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
async def get_redis() -> aioredis.Redis:
return redis_client

View File

@ -0,0 +1,27 @@
from datetime import datetime, timedelta, timezone
import bcrypt
from jose import jwt
from app.core.config import settings
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])

40
backend/app/main.py Normal file
View File

@ -0,0 +1,40 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.errors import global_exception_handler
app = FastAPI(
title="HermesMessages API",
description="Plataforma SaaS de automatización de reservas via WhatsApp",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.ENVIRONMENT == "development" else [],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_exception_handler(Exception, global_exception_handler)
# Routers — se van añadiendo por módulo
from app.modules.auth.router import router as auth_router
from app.modules.business.router import router as business_router
from app.modules.whatsapp.router import router as whatsapp_router
from app.modules.calendar.router import router as calendar_router
from app.modules.reservations.router import router as reservations_router
from app.modules.dashboard.router import router as dashboard_router
from app.modules.billing.router import router as billing_router
from app.modules.admin.router import router as admin_router
app.include_router(auth_router, prefix="/auth", tags=["auth"])
app.include_router(business_router, prefix="/business", tags=["business"])
app.include_router(whatsapp_router, prefix="/whatsapp", tags=["whatsapp"])
app.include_router(calendar_router, prefix="/calendar", tags=["calendar"])
app.include_router(reservations_router, prefix="/reservations", tags=["reservations"])
app.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
app.include_router(billing_router, prefix="/billing", tags=["billing"])
app.include_router(admin_router, prefix="/admin", tags=["admin"])

View File

View File

View File

@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_admin
from app.modules.admin import schemas, service
router = APIRouter()
@router.get("/businesses", response_model=list[schemas.BusinessSummary])
async def list_businesses(
_=Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await service.list_businesses(db)
@router.get("/businesses/{business_id}", response_model=schemas.BusinessSummary)
async def get_business(
business_id: int,
_=Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await service.get_business(db, business_id)
@router.patch("/businesses/{business_id}/status", response_model=schemas.BusinessSummary)
async def update_status(
business_id: int,
body: schemas.StatusPatch,
_=Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await service.update_business_status(db, business_id, body.status)
@router.get("/stats", response_model=schemas.PlatformStats)
async def get_stats(
_=Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await service.get_platform_stats(db)

View File

@ -0,0 +1,21 @@
from pydantic import BaseModel
class BusinessSummary(BaseModel):
id: int
name: str
type: str
status: str
plan: str
model_config = {"from_attributes": True}
class StatusPatch(BaseModel):
status: str
class PlatformStats(BaseModel):
total_businesses: int
active_businesses: int
total_reservations: int

View File

@ -0,0 +1,51 @@
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.admin.schemas import BusinessSummary, PlatformStats
from app.modules.business.models import Business
from app.modules.reservations.models import Reservation
VALID_STATUSES = {"trial", "active", "suspended"}
async def list_businesses(db: AsyncSession) -> list[Business]:
result = await db.execute(select(Business).order_by(Business.created_at.desc()))
return result.scalars().all()
async def get_business(db: AsyncSession, business_id: int) -> 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")
return business
async def update_business_status(db: AsyncSession, business_id: int, new_status: str) -> Business:
if new_status not in VALID_STATUSES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Estado inválido. Opciones: {', '.join(VALID_STATUSES)}",
)
business = await get_business(db, business_id)
business.status = new_status
await db.commit()
await db.refresh(business)
return business
async def get_platform_stats(db: AsyncSession) -> PlatformStats:
total = (await db.execute(select(func.count(Business.id)))).scalar_one()
active = (
await db.execute(
select(func.count(Business.id)).where(Business.status == "active")
)
).scalar_one()
reservations = (await db.execute(select(func.count(Reservation.id)))).scalar_one()
return PlatformStats(
total_businesses=total,
active_businesses=active,
total_reservations=reservations,
)

View File

View File

@ -0,0 +1,17 @@
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
business_id = Column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False)
email = Column(String, unique=True, nullable=False, index=True)
hashed_password = Column(String, nullable=True)
meta_user_id = Column(String, nullable=True, unique=True)
role = Column(Enum("owner", "admin", name="user_role"), nullable=False, default="owner")
business = relationship("Business", back_populates="users")

View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import create_access_token, decode_token
from app.modules.auth import schemas, service
router = APIRouter()
@router.post("/register", response_model=schemas.RegisterResponse, status_code=201)
async def register(body: schemas.RegisterRequest, db: AsyncSession = Depends(get_db)):
token, business_id, user_id = await service.register_business(
db,
business_name=body.business_name,
business_type=body.business_type,
timezone=body.timezone,
email=body.email,
password=body.password,
)
return schemas.RegisterResponse(
access_token=token,
business_id=business_id,
user_id=user_id,
)
@router.post("/login", response_model=schemas.TokenResponse)
async def login(body: schemas.LoginRequest, db: AsyncSession = Depends(get_db)):
token = await service.authenticate_user(db, body.email, body.password)
return schemas.TokenResponse(access_token=token)
@router.post("/meta-callback", response_model=schemas.TokenResponse)
async def meta_callback(body: schemas.MetaCallbackRequest, db: AsyncSession = Depends(get_db)):
token = await service.meta_oauth_login(db, body.code, body.redirect_uri)
return schemas.TokenResponse(access_token=token)
@router.post("/refresh", response_model=schemas.TokenResponse)
async def refresh(body: schemas.RefreshRequest):
try:
payload = decode_token(body.access_token)
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token inválido")
new_token = create_access_token(
{"sub": payload["sub"], "business_id": payload["business_id"]}
)
return schemas.TokenResponse(access_token=new_token)
@router.post("/logout", status_code=204)
async def logout():
return Response(status_code=204)

View File

@ -0,0 +1,56 @@
from pydantic import BaseModel, EmailStr, field_validator
class RegisterRequest(BaseModel):
business_name: str
business_type: str
timezone: str = "UTC"
email: EmailStr
password: str
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("La contraseña debe tener al menos 8 caracteres")
return v
@field_validator("business_name")
@classmethod
def business_name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("El nombre del negocio no puede estar vacío")
return v.strip()
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RegisterResponse(TokenResponse):
business_id: int
user_id: int
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
access_token: str
class MetaCallbackRequest(BaseModel):
code: str
redirect_uri: str
class UserRead(BaseModel):
id: int
business_id: int
email: str
role: str
model_config = {"from_attributes": True}

View File

@ -0,0 +1,153 @@
import asyncio
import httpx
from datetime import time as dtime
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import create_access_token, hash_password, verify_password
from app.modules.auth.models import User
from app.modules.business.models import Business, BusinessConfig
def _token_for_user(user: User) -> str:
return create_access_token({"sub": str(user.id), "business_id": user.business_id})
async def register_business(
db: AsyncSession,
business_name: str,
business_type: str,
timezone: str,
email: str,
password: str,
) -> tuple[str, int, int]:
existing = await get_user_by_email(db, email)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El correo ya está registrado",
)
business = Business(
name=business_name,
type=business_type,
timezone=timezone,
status="trial",
plan="free",
)
db.add(business)
await db.flush()
db.add(
BusinessConfig(
business_id=business.id,
open_days=[0, 1, 2, 3, 4],
open_time=dtime(9, 0),
close_time=dtime(18, 0),
slot_duration=60,
max_per_slot=1,
blocked_dates=[],
assistant_name="Hermes",
tone="formal",
)
)
hashed = await asyncio.to_thread(hash_password, password)
user = User(
business_id=business.id,
email=email,
hashed_password=hashed,
role="owner",
)
db.add(user)
await db.flush()
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El correo ya está registrado",
)
return _token_for_user(user), business.id, user.id
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, email: str, password: str) -> str:
user = await get_user_by_email(db, email)
if not user or not user.hashed_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas",
)
if not await asyncio.to_thread(verify_password, password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas",
)
return _token_for_user(user)
async def exchange_meta_code(code: str, redirect_uri: str) -> dict:
"""Intercambia el código de autorización de Meta por un access token."""
async with httpx.AsyncClient() as client:
response = await client.get(
"https://graph.facebook.com/v20.0/oauth/access_token",
params={
"client_id": settings.META_APP_ID,
"client_secret": settings.META_APP_SECRET,
"code": code,
"redirect_uri": redirect_uri,
},
)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Error al intercambiar código con Meta",
)
return response.json()
async def get_meta_user_info(access_token: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://graph.facebook.com/me",
params={"fields": "id,email", "access_token": access_token},
)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Error al obtener información del usuario de Meta",
)
return response.json()
async def meta_oauth_login(db: AsyncSession, code: str, redirect_uri: str) -> str:
token_data = await exchange_meta_code(code, redirect_uri)
meta_info = await get_meta_user_info(token_data["access_token"])
result = await db.execute(select(User).where(User.meta_user_id == meta_info["id"]))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Usuario no encontrado. Completa el registro primero.",
)
return _token_for_user(user)

View File

View File

@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_business
from app.modules.billing import schemas, service
router = APIRouter()
@router.get("/plan", response_model=schemas.PlanRead)
async def get_plan(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_plan(db, business_id)
@router.post("/upgrade", response_model=schemas.PlanRead)
async def upgrade_plan(
body: schemas.UpgradeRequest,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.upgrade_plan(db, business_id, body.plan)
@router.get("/usage", response_model=schemas.UsageRead)
async def get_usage(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_usage(db, business_id)

View File

@ -0,0 +1,25 @@
from pydantic import BaseModel
PLAN_LIMITS = {
"free": 50,
"basic": 500,
"pro": -1, # ilimitado
}
class PlanRead(BaseModel):
plan: str
status: str
monthly_limit: int
is_unlimited: bool
class UpgradeRequest(BaseModel):
plan: str
class UsageRead(BaseModel):
plan: str
reservations_this_month: int
monthly_limit: int
is_unlimited: bool

View File

@ -0,0 +1,59 @@
from datetime import date
from fastapi import HTTPException, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.billing.schemas import PLAN_LIMITS, PlanRead, UsageRead
from app.modules.business.models import Business
from app.modules.business.service import get_business
from app.modules.reservations.models import Reservation
VALID_PLANS = set(PLAN_LIMITS.keys())
async def get_plan(db: AsyncSession, business_id: int) -> PlanRead:
business = await get_business(db, business_id)
limit = PLAN_LIMITS[business.plan]
return PlanRead(
plan=business.plan,
status=business.status,
monthly_limit=limit,
is_unlimited=limit == -1,
)
async def upgrade_plan(db: AsyncSession, business_id: int, new_plan: str) -> PlanRead:
if new_plan not in VALID_PLANS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Plan inválido. Opciones: {', '.join(VALID_PLANS)}",
)
business = await get_business(db, business_id)
business.plan = new_plan
await db.commit()
await db.refresh(business)
return await get_plan(db, business_id)
async def get_usage(db: AsyncSession, business_id: int) -> UsageRead:
business = await get_business(db, business_id)
month_start = date.today().replace(day=1)
result = await db.execute(
select(func.count(Reservation.id)).where(
and_(
Reservation.business_id == business_id,
Reservation.date >= month_start,
)
)
)
count = result.scalar_one()
limit = PLAN_LIMITS[business.plan]
return UsageRead(
plan=business.plan,
reservations_this_month=count,
monthly_limit=limit,
is_unlimited=limit == -1,
)

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

View File

View File

@ -0,0 +1,65 @@
from datetime import date, time
from sqlalchemy import (
Column,
Date,
Enum,
ForeignKey,
Integer,
String,
Time,
func,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import relationship
from app.core.database import Base
class Business(Base):
__tablename__ = "businesses"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
type = Column(String, nullable=False)
timezone = Column(String, nullable=False, default="UTC")
status = Column(
Enum("trial", "active", "suspended", name="business_status"),
nullable=False,
default="trial",
)
plan = Column(
Enum("free", "basic", "pro", name="business_plan"),
nullable=False,
default="free",
)
meta_business_id = Column(String, nullable=True)
whatsapp_phone_number_id = Column(String, nullable=True, unique=True)
whatsapp_access_token = Column(String, nullable=True)
created_at = Column(Date, server_default=func.current_date())
users = relationship("User", back_populates="business", cascade="all, delete-orphan")
config = relationship("BusinessConfig", back_populates="business", uselist=False)
reservations = relationship("Reservation", back_populates="business")
class BusinessConfig(Base):
__tablename__ = "business_configs"
id = Column(Integer, primary_key=True, index=True)
business_id = Column(
Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, unique=True
)
open_days = Column(ARRAY(Integer), nullable=False, default=list)
open_time = Column(Time, nullable=False, default=time(9, 0))
close_time = Column(Time, nullable=False, default=time(18, 0))
slot_duration = Column(Integer, nullable=False, default=60)
max_per_slot = Column(Integer, nullable=False, default=1)
blocked_dates = Column(ARRAY(Date), nullable=False, default=list)
assistant_name = Column(String, nullable=False, default="Hermes")
tone = Column(
Enum("formal", "casual", name="assistant_tone"), nullable=False, default="formal"
)
welcome_message = Column(String, nullable=True)
business = relationship("Business", back_populates="config")

View File

@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_business, get_current_user
from app.modules.business import schemas, service
router = APIRouter()
@router.get("/me", response_model=schemas.BusinessRead)
async def get_my_business(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_business(db, business_id)
@router.put("/me", response_model=schemas.BusinessRead)
async def update_my_business(
body: schemas.BusinessUpdate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.update_business(db, business_id, body)
@router.get("/me/config", response_model=schemas.BusinessConfigRead)
async def get_my_config(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_business_config(db, business_id)
@router.put("/me/config", response_model=schemas.BusinessConfigRead)
async def update_my_config(
body: schemas.BusinessConfigUpdate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.update_business_config(db, business_id, body)

View File

@ -0,0 +1,48 @@
from datetime import date, time
from pydantic import BaseModel
class BusinessRead(BaseModel):
id: int
name: str
type: str
timezone: str
status: str
plan: str
meta_business_id: str | None
whatsapp_phone_number_id: str | None
model_config = {"from_attributes": True}
class BusinessUpdate(BaseModel):
name: str | None = None
type: str | None = None
timezone: str | None = None
class BusinessConfigRead(BaseModel):
open_days: list[int]
open_time: time
close_time: time
slot_duration: int
max_per_slot: int
blocked_dates: list[date]
assistant_name: str
tone: str
welcome_message: str | None
model_config = {"from_attributes": True}
class BusinessConfigUpdate(BaseModel):
open_days: list[int] | None = None
open_time: time | None = None
close_time: time | None = None
slot_duration: int | None = None
max_per_slot: int | None = None
blocked_dates: list[date] | None = None
assistant_name: str | None = None
tone: str | None = None
welcome_message: str | None = None

View File

@ -0,0 +1,49 @@
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.business.models import Business, BusinessConfig
from app.modules.business.schemas import BusinessConfigUpdate, BusinessUpdate
async def get_business(db: AsyncSession, business_id: int) -> 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")
return business
async def update_business(
db: AsyncSession, business_id: int, data: BusinessUpdate
) -> Business:
business = await get_business(db, business_id)
for field, value in data.model_dump(exclude_none=True).items():
setattr(business, field, value)
await db.commit()
await db.refresh(business)
return business
async def get_business_config(db: AsyncSession, business_id: int) -> BusinessConfig:
result = await db.execute(
select(BusinessConfig).where(BusinessConfig.business_id == business_id)
)
config = result.scalar_one_or_none()
if not config:
config = BusinessConfig(business_id=business_id)
db.add(config)
await db.commit()
await db.refresh(config)
return config
async def update_business_config(
db: AsyncSession, business_id: int, data: BusinessConfigUpdate
) -> BusinessConfig:
config = await get_business_config(db, business_id)
for field, value in data.model_dump(exclude_none=True).items():
setattr(config, field, value)
await db.commit()
await db.refresh(config)
return config

View File

View File

@ -0,0 +1,57 @@
from datetime import date
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_business
from app.core.redis import get_redis
from app.modules.calendar import schemas, service
router = APIRouter()
@router.get("/availability", response_model=schemas.DayAvailability)
async def get_availability(
date: date,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
return await service.get_available_slots(db, redis, business_id, date)
@router.get("/availability/range", response_model=list[schemas.DayAvailability])
async def get_availability_range(
start: date,
end: date,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
return await service.get_availability_range(db, redis, business_id, start, end)
@router.post("/blocked-dates", status_code=201)
async def add_blocked_date(
body: schemas.BlockedDateRequest,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
await service.add_blocked_date(db, business_id, body.date)
await service.invalidate_slots_cache(redis, business_id, body.date)
return {"detail": "Fecha bloqueada"}
@router.delete("/blocked-dates/{target_date}", status_code=204)
async def remove_blocked_date(
target_date: date,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
await service.remove_blocked_date(db, business_id, target_date)
await service.invalidate_slots_cache(redis, business_id, target_date)
return Response(status_code=204)

View File

@ -0,0 +1,21 @@
from datetime import date as Date, time as Time
from typing import Optional
from pydantic import BaseModel
class SlotRead(BaseModel):
time_start: Time
time_end: Time
available: int
max_per_slot: int
class DayAvailability(BaseModel):
date: Date
is_open: bool
slots: list[SlotRead]
class BlockedDateRequest(BaseModel):
date: Date

View File

@ -0,0 +1,135 @@
import json
from datetime import date, datetime, time, timedelta
import redis.asyncio as aioredis
from fastapi import HTTPException, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.business.models import BusinessConfig
from app.modules.business.service import get_business_config
from app.modules.calendar.schemas import DayAvailability, SlotRead
from app.modules.reservations.models import Reservation
SLOTS_CACHE_TTL = 300 # 5 minutos
def _cache_key(business_id: int, target_date: date) -> str:
return f"slots:{business_id}:{target_date.isoformat()}"
def _generate_slots(open_time: time, close_time: time, slot_duration: int) -> list[tuple[time, time]]:
slots = []
base = date.today()
current = datetime.combine(base, open_time)
end = datetime.combine(base, close_time)
delta = timedelta(minutes=slot_duration)
while current + delta <= end:
slots.append((current.time(), (current + delta).time()))
current += delta
return slots
async def _count_reservations_per_slot(
db: AsyncSession,
business_id: int,
target_date: date,
slots: list[tuple[time, time]],
) -> dict[tuple[time, time], int]:
result = await db.execute(
select(Reservation.time_start, func.count(Reservation.id))
.where(
and_(
Reservation.business_id == business_id,
Reservation.date == target_date,
Reservation.status.in_(["pending", "confirmed"]),
)
)
.group_by(Reservation.time_start)
)
counts = {row[0]: row[1] for row in result.all()}
return {slot: counts.get(slot[0], 0) for slot in slots}
async def get_available_slots(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
target_date: date,
) -> DayAvailability:
cache_key = _cache_key(business_id, target_date)
cached = await redis.get(cache_key)
if cached:
return DayAvailability.model_validate_json(cached)
config = await get_business_config(db, business_id)
is_open = (
target_date.weekday() in (config.open_days or [])
and target_date not in (config.blocked_dates or [])
)
if not is_open:
result = DayAvailability(date=target_date, is_open=False, slots=[])
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
slots = [
SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, config.max_per_slot - counts[s]),
max_per_slot=config.max_per_slot,
)
for s in raw_slots
]
result = DayAvailability(date=target_date, is_open=True, slots=slots)
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
async def get_availability_range(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
start: date,
end: date,
) -> list[DayAvailability]:
if (end - start).days > 31:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El rango máximo es 31 días",
)
days = []
current = start
while current <= end:
days.append(await get_available_slots(db, redis, business_id, current))
current += timedelta(days=1)
return days
async def invalidate_slots_cache(redis: aioredis.Redis, business_id: int, target_date: date) -> None:
await redis.delete(_cache_key(business_id, target_date))
async def add_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
config = await get_business_config(db, business_id)
blocked = list(config.blocked_dates or [])
if target_date not in blocked:
blocked.append(target_date)
config.blocked_dates = blocked
await db.commit()
async def remove_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
config = await get_business_config(db, business_id)
blocked = list(config.blocked_dates or [])
if target_date not in blocked:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fecha no bloqueada")
blocked.remove(target_date)
config.blocked_dates = blocked
await db.commit()

View File

@ -0,0 +1,35 @@
from datetime import date
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_business
from app.modules.dashboard import schemas, service
router = APIRouter()
@router.get("/stats", response_model=schemas.ReservationStats)
async def get_stats(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_stats(db, business_id)
@router.get("/agenda", response_model=list[schemas.AgendaItem])
async def get_agenda(
date: date,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_agenda(db, business_id, date)
@router.get("/peak-hours", response_model=list[schemas.PeakHour])
async def get_peak_hours(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_peak_hours(db, business_id)

View File

@ -0,0 +1,26 @@
from datetime import date as Date, time as Time
from pydantic import BaseModel
class ReservationStats(BaseModel):
today: int
this_week: int
this_month: int
class AgendaItem(BaseModel):
id: int
client_name: str
client_phone: str
time_start: Time
time_end: Time
party_size: int
status: str
model_config = {"from_attributes": True}
class PeakHour(BaseModel):
hour: int
total: int

View File

@ -0,0 +1,65 @@
from datetime import date, timedelta
from sqlalchemy import and_, extract, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.dashboard.schemas import AgendaItem, PeakHour, ReservationStats
from app.modules.reservations.models import Reservation
async def get_stats(db: AsyncSession, business_id: int) -> ReservationStats:
today = date.today()
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
async def count(start: date, end: date) -> int:
result = await db.execute(
select(func.count(Reservation.id)).where(
and_(
Reservation.business_id == business_id,
Reservation.date >= start,
Reservation.date <= end,
Reservation.status.in_(["pending", "confirmed"]),
)
)
)
return result.scalar_one()
return ReservationStats(
today=await count(today, today),
this_week=await count(week_start, today),
this_month=await count(month_start, today),
)
async def get_agenda(db: AsyncSession, business_id: int, target_date: date) -> list[AgendaItem]:
result = await db.execute(
select(Reservation)
.where(
and_(
Reservation.business_id == business_id,
Reservation.date == target_date,
Reservation.status.in_(["pending", "confirmed"]),
)
)
.order_by(Reservation.time_start)
)
return result.scalars().all()
async def get_peak_hours(db: AsyncSession, business_id: int) -> list[PeakHour]:
result = await db.execute(
select(
extract("hour", Reservation.time_start).label("hour"),
func.count(Reservation.id).label("total"),
)
.where(
and_(
Reservation.business_id == business_id,
Reservation.status.in_(["confirmed", "no_show"]),
)
)
.group_by("hour")
.order_by("hour")
)
return [PeakHour(hour=int(row.hour), total=row.total) for row in result.all()]

View File

@ -0,0 +1,47 @@
import logging
from app.modules.whatsapp.client import send_text_message
logger = logging.getLogger(__name__)
async def send_reservation_confirmation(
phone_number_id: str,
access_token: str,
client_phone: str,
client_name: str,
reservation_date: str,
time_start: str,
business_name: str,
) -> None:
message = (
f"✅ Reserva confirmada, {client_name}!\n\n"
f"📅 Fecha: {reservation_date}\n"
f"🕐 Hora: {time_start}\n"
f"📍 {business_name}\n\n"
"Te esperamos. Si necesitas cancelar, contáctanos."
)
try:
await send_text_message(phone_number_id, access_token, client_phone, message)
except Exception as exc:
logger.warning("No se pudo enviar confirmación a %s: %s", client_phone, exc)
async def send_reservation_reminder(
phone_number_id: str,
access_token: str,
client_phone: str,
client_name: str,
reservation_date: str,
time_start: str,
business_name: str,
) -> None:
message = (
f"👋 Hola {client_name}, te recordamos tu reserva en {business_name}.\n\n"
f"📅 {reservation_date} a las {time_start}\n\n"
"¡Te esperamos!"
)
try:
await send_text_message(phone_number_id, access_token, client_phone, message)
except Exception as exc:
logger.warning("No se pudo enviar recordatorio a %s: %s", client_phone, exc)

View File

@ -0,0 +1,31 @@
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Time, func
from sqlalchemy.orm import relationship
from app.core.database import Base
class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True, index=True)
business_id = Column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False)
client_name = Column(String, nullable=False)
client_phone = Column(String, nullable=False)
date = Column(Date, nullable=False, index=True)
time_start = Column(Time, nullable=False)
time_end = Column(Time, nullable=False)
party_size = Column(Integer, nullable=False, default=1)
status = Column(
Enum("pending", "confirmed", "cancelled", "no_show", name="reservation_status"),
nullable=False,
default="pending",
)
source = Column(
Enum("whatsapp", "manual", name="reservation_source"),
nullable=False,
default="manual",
)
notes = Column(String, nullable=True)
created_at = Column(Date, server_default=func.current_date())
business = relationship("Business", back_populates="reservations")

View File

@ -0,0 +1,74 @@
from datetime import date
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_business
from app.core.redis import get_redis
from app.modules.reservations import schemas, service
router = APIRouter()
@router.get("/", response_model=list[schemas.ReservationRead])
async def list_reservations(
date: date | None = None,
status: str | None = None,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.list_reservations(db, business_id, date, status)
@router.post("/", response_model=schemas.ReservationRead, status_code=201)
async def create_reservation(
body: schemas.ReservationCreate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
return await service.create_reservation(db, redis, business_id, body)
@router.get("/{reservation_id}", response_model=schemas.ReservationRead)
async def get_reservation(
reservation_id: int,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.get_reservation(db, business_id, reservation_id)
@router.put("/{reservation_id}", response_model=schemas.ReservationRead)
async def update_reservation(
reservation_id: int,
body: schemas.ReservationUpdate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
return await service.update_reservation(db, redis, business_id, reservation_id, body)
@router.delete("/{reservation_id}", status_code=204)
async def delete_reservation(
reservation_id: int,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
await service.delete_reservation(db, redis, business_id, reservation_id)
return Response(status_code=204)
@router.patch("/{reservation_id}/status", response_model=schemas.ReservationRead)
async def update_status(
reservation_id: int,
body: schemas.StatusUpdate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
return await service.update_status(db, redis, business_id, reservation_id, body.status)

View File

@ -0,0 +1,44 @@
from datetime import date as Date, time as Time
from typing import Optional
from pydantic import BaseModel
class ReservationCreate(BaseModel):
client_name: str
client_phone: str
date: Date
time_start: Time
party_size: int = 1
notes: Optional[str] = None
source: str = "manual"
class ReservationUpdate(BaseModel):
client_name: Optional[str] = None
client_phone: Optional[str] = None
date: Optional[Date] = None
time_start: Optional[Time] = None
party_size: Optional[int] = None
notes: Optional[str] = None
class StatusUpdate(BaseModel):
status: str
class ReservationRead(BaseModel):
id: int
business_id: int
client_name: str
client_phone: str
date: Date
time_start: Time
time_end: Time
party_size: int
status: str
source: str
notes: Optional[str]
created_at: Date
model_config = {"from_attributes": True}

View File

@ -0,0 +1,140 @@
from datetime import date, datetime, timedelta
import redis.asyncio as aioredis
from fastapi import HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.business.service import get_business_config
from app.modules.calendar.service import invalidate_slots_cache
from app.modules.reservations.models import Reservation
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate
VALID_STATUSES = {"pending", "confirmed", "cancelled", "no_show"}
async def _get_or_404(db: AsyncSession, business_id: int, reservation_id: int) -> Reservation:
result = await db.execute(
select(Reservation).where(
and_(Reservation.id == reservation_id, Reservation.business_id == business_id)
)
)
reservation = result.scalar_one_or_none()
if not reservation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reserva no encontrada")
return reservation
def _compute_time_end(time_start, slot_duration: int):
base = datetime.combine(date.today(), time_start)
return (base + timedelta(minutes=slot_duration)).time()
async def list_reservations(
db: AsyncSession,
business_id: int,
filter_date: date | None = None,
filter_status: str | None = None,
) -> list[Reservation]:
query = select(Reservation).where(Reservation.business_id == business_id)
if filter_date:
query = query.where(Reservation.date == filter_date)
if filter_status:
query = query.where(Reservation.status == filter_status)
query = query.order_by(Reservation.date, Reservation.time_start)
result = await db.execute(query)
return result.scalars().all()
async def create_reservation(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
data: ReservationCreate,
) -> Reservation:
config = await get_business_config(db, business_id)
time_end = _compute_time_end(data.time_start, config.slot_duration)
reservation = Reservation(
business_id=business_id,
client_name=data.client_name,
client_phone=data.client_phone,
date=data.date,
time_start=data.time_start,
time_end=time_end,
party_size=data.party_size,
source=data.source,
notes=data.notes,
status="pending",
)
db.add(reservation)
await db.commit()
await db.refresh(reservation)
await invalidate_slots_cache(redis, business_id, data.date)
return reservation
async def get_reservation(
db: AsyncSession, business_id: int, reservation_id: int
) -> Reservation:
return await _get_or_404(db, business_id, reservation_id)
async def update_reservation(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
reservation_id: int,
data: ReservationUpdate,
) -> Reservation:
reservation = await _get_or_404(db, business_id, reservation_id)
old_date = reservation.date
for field, value in data.model_dump(exclude_none=True).items():
setattr(reservation, field, value)
if data.time_start or data.date:
config = await get_business_config(db, business_id)
reservation.time_end = _compute_time_end(reservation.time_start, config.slot_duration)
await db.commit()
await db.refresh(reservation)
await invalidate_slots_cache(redis, business_id, old_date)
if reservation.date != old_date:
await invalidate_slots_cache(redis, business_id, reservation.date)
return reservation
async def delete_reservation(
db: AsyncSession, redis: aioredis.Redis, business_id: int, reservation_id: int
) -> None:
reservation = await _get_or_404(db, business_id, reservation_id)
target_date = reservation.date
await db.delete(reservation)
await db.commit()
await invalidate_slots_cache(redis, business_id, target_date)
async def update_status(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
reservation_id: int,
new_status: str,
) -> Reservation:
if new_status not in VALID_STATUSES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Estado inválido. Valores permitidos: {', '.join(VALID_STATUSES)}",
)
reservation = await _get_or_404(db, business_id, reservation_id)
reservation.status = new_status
await db.commit()
await db.refresh(reservation)
if new_status in ("cancelled", "no_show"):
await invalidate_slots_cache(redis, business_id, reservation.date)
return reservation

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

View File

View File

View File

View File

3
backend/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

20
backend/requirements.txt Normal file
View File

@ -0,0 +1,20 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
asyncpg==0.29.0
alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
pydantic[email]==2.9.2
redis==5.1.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.12
httpx==0.27.2
anthropic==0.34.2
slowapi==0.1.9
python-dotenv==1.0.1
# testing
pytest==8.3.3
pytest-asyncio==0.24.0

View File

115
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,115 @@
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from unittest.mock import AsyncMock
from app.core.database import Base, get_db
from app.core.redis import get_redis
from app.main import app
# Importar todos los modelos para que Base.metadata los registre
from app.modules.auth.models import User # noqa
from app.modules.business.models import Business, BusinessConfig # noqa
from app.modules.reservations.models import Reservation # noqa
TEST_DB_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/hermesmessages_test"
@pytest_asyncio.fixture(scope="session")
async def engine():
_engine = create_async_engine(TEST_DB_URL)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield _engine
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await _engine.dispose()
@pytest_asyncio.fixture
async def db(engine) -> AsyncSession:
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
def redis_mock() -> AsyncMock:
mock = AsyncMock()
mock.get = AsyncMock(return_value=None)
mock.setex = AsyncMock()
mock.delete = AsyncMock()
mock.aclose = AsyncMock()
return mock
@pytest_asyncio.fixture
async def client(db: AsyncSession, redis_mock: AsyncMock) -> AsyncClient:
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[get_redis] = lambda: redis_mock
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def client_no_db(redis_mock: AsyncMock) -> AsyncClient:
"""Cliente HTTP sin fixture de base de datos — para endpoints que no usan DB."""
app.dependency_overrides[get_redis] = lambda: redis_mock
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
# --- Helpers para crear datos de prueba ---
async def make_business(db: AsyncSession, **kwargs):
from app.modules.business.models import Business, BusinessConfig
from datetime import time
business = Business(
name=kwargs.get("name", "Restaurante Test"),
type=kwargs.get("type", "restaurant"),
timezone="UTC",
status=kwargs.get("status", "active"),
plan=kwargs.get("plan", "basic"),
whatsapp_phone_number_id=kwargs.get("phone_number_id", "12345"),
whatsapp_access_token=kwargs.get("access_token", "test-token"),
)
db.add(business)
await db.flush()
config = BusinessConfig(
business_id=business.id,
open_days=[0, 1, 2, 3, 4], # lunes a viernes
open_time=time(9, 0),
close_time=time(18, 0),
slot_duration=60,
max_per_slot=1,
blocked_dates=[],
assistant_name="Hermes",
tone="formal",
)
db.add(config)
await db.commit()
await db.refresh(business)
return business
async def make_user(db: AsyncSession, business_id: int, **kwargs):
from app.modules.auth.models import User
from app.core.security import hash_password
user = User(
business_id=business_id,
email=kwargs.get("email", "owner@test.com"),
hashed_password=hash_password(kwargs.get("password", "secret123")),
role=kwargs.get("role", "owner"),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user

164
backend/tests/test_auth.py Normal file
View File

@ -0,0 +1,164 @@
import pytest
from httpx import AsyncClient
from tests.conftest import make_business, make_user
REGISTER_PAYLOAD = {
"business_name": "Restaurante Nuevo",
"business_type": "restaurant",
"timezone": "America/Bogota",
"email": "nuevo@negocio.com",
"password": "segura123",
}
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
async def test_register_creates_business_user_and_returns_token(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD)
assert response.status_code == 201
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert isinstance(data["business_id"], int)
assert isinstance(data["user_id"], int)
async def test_register_token_grants_access_to_own_business(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "owner2@test.com"})
token = response.json()["access_token"]
business_id = response.json()["business_id"]
me = await client.get("/business/me", headers={"Authorization": f"Bearer {token}"})
assert me.status_code == 200
assert me.json()["id"] == business_id
assert me.json()["name"] == REGISTER_PAYLOAD["business_name"]
async def test_register_duplicate_email_returns_409(client: AsyncClient):
payload = REGISTER_PAYLOAD | {"email": "dup@test.com"}
first = await client.post("/auth/register", json=payload)
assert first.status_code == 201
second = await client.post("/auth/register", json=payload)
assert second.status_code == 409
async def test_register_password_too_short_returns_422(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "short@test.com", "password": "abc"})
assert response.status_code == 422
async def test_register_empty_business_name_returns_422(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "empty@test.com", "business_name": " "})
assert response.status_code == 422
async def test_register_creates_default_business_config(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "config@test.com"})
token = response.json()["access_token"]
config = await client.get("/business/me/config", headers={"Authorization": f"Bearer {token}"})
assert config.status_code == 200
data = config.json()
assert data["open_days"] == [0, 1, 2, 3, 4]
assert data["slot_duration"] == 60
assert data["assistant_name"] == "Hermes"
async def test_two_businesses_cannot_see_each_other_reservations(client: AsyncClient):
r1 = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "biz1@test.com"})
r2 = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "biz2@test.com"})
token1 = r1.json()["access_token"]
token2 = r2.json()["access_token"]
res1 = await client.get("/reservations/", headers={"Authorization": f"Bearer {token1}"})
res2 = await client.get("/reservations/", headers={"Authorization": f"Bearer {token2}"})
assert res1.status_code == 200
assert res2.status_code == 200
# Each business sees only their own empty list
assert res1.json() == []
assert res2.json() == []
# ---------------------------------------------------------------------------
# Login
# ---------------------------------------------------------------------------
async def test_login_success(client: AsyncClient, db):
business = await make_business(db, name="Auth Test")
await make_user(db, business.id, email="test@example.com", password="mypassword")
response = await client.post("/auth/login", json={
"email": "test@example.com",
"password": "mypassword",
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(client: AsyncClient, db):
business = await make_business(db, name="Wrong Pass Test")
await make_user(db, business.id, email="wrong@example.com", password="correct")
response = await client.post("/auth/login", json={
"email": "wrong@example.com",
"password": "incorrect",
})
assert response.status_code == 401
async def test_login_unknown_email(client: AsyncClient, db):
response = await client.post("/auth/login", json={
"email": "noexiste@example.com",
"password": "cualquiera",
})
assert response.status_code == 401
async def test_protected_endpoint_without_token(client: AsyncClient):
response = await client.get("/business/me")
assert response.status_code == 403
async def test_protected_endpoint_with_token(client: AsyncClient, db):
business = await make_business(db, name="Protected Test")
await make_user(db, business.id, email="protected@example.com", password="pass123")
login = await client.post("/auth/login", json={
"email": "protected@example.com",
"password": "pass123",
})
token = login.json()["access_token"]
response = await client.get("/business/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
data = response.json()
assert data["id"] == business.id
assert data["name"] == "Protected Test"
async def test_logout_returns_204(client: AsyncClient):
response = await client.post("/auth/logout")
assert response.status_code == 204
async def test_refresh_token(client: AsyncClient, db):
business = await make_business(db, name="Refresh Test")
await make_user(db, business.id, email="refresh@example.com", password="pass")
login = await client.post("/auth/login", json={
"email": "refresh@example.com",
"password": "pass",
})
token = login.json()["access_token"]
response = await client.post("/auth/refresh", json={"access_token": token})
assert response.status_code == 200
assert "access_token" in response.json()

View File

@ -0,0 +1,140 @@
from datetime import date, time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.modules.bot_engine.prompt import build_system_prompt
from app.modules.calendar.service import _generate_slots
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
from app.modules.calendar.schemas import DayAvailability, SlotRead
def _make_config(**kwargs):
config = MagicMock()
config.tone = kwargs.get("tone", "formal")
config.assistant_name = kwargs.get("assistant_name", "Hermes")
config.open_days = kwargs.get("open_days", [0, 1, 2, 3, 4])
config.open_time = kwargs.get("open_time", time(9, 0))
config.close_time = kwargs.get("close_time", time(18, 0))
config.slot_duration = kwargs.get("slot_duration", 60)
config.max_per_slot = kwargs.get("max_per_slot", 2)
return config
def _make_business(**kwargs):
b = MagicMock()
b.name = kwargs.get("name", "Restaurante Demo")
b.id = 1
b.whatsapp_phone_number_id = "phone-id"
b.whatsapp_access_token = "token"
return b
def test_build_system_prompt_contains_business_name():
business = _make_business(name="Clínica Sol")
config = _make_config(assistant_name="Sol")
context = ConversationContext(phone="5491100000000", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "Clínica Sol" in prompt
assert "Sol" in prompt
def test_build_system_prompt_formal_tone():
business = _make_business()
config = _make_config(tone="formal")
context = ConversationContext(phone="549", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "formal" in prompt.lower()
def test_build_system_prompt_casual_tone():
business = _make_business()
config = _make_config(tone="casual")
context = ConversationContext(phone="549", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "amigable" in prompt.lower()
def test_build_system_prompt_with_slots():
business = _make_business()
config = _make_config()
context = ConversationContext(phone="549", business_id=1)
availability = DayAvailability(
date=date(2026, 5, 5),
is_open=True,
slots=[
SlotRead(time_start=time(10, 0), time_end=time(11, 0), available=2, max_per_slot=2),
SlotRead(time_start=time(11, 0), time_end=time(12, 0), available=1, max_per_slot=2),
],
)
prompt = build_system_prompt(business, config, availability, context)
assert "10:00" in prompt
assert "11:00" in prompt
def test_build_system_prompt_no_availability():
business = _make_business()
config = _make_config()
context = ConversationContext(phone="549", business_id=1)
availability = DayAvailability(date=date(2026, 5, 5), is_open=False, slots=[])
prompt = build_system_prompt(business, config, availability, context)
assert "No hay disponibilidad" in prompt
def test_build_system_prompt_collected_data_shown():
business = _make_business()
config = _make_config()
context = ConversationContext(
phone="549",
business_id=1,
collected_data=CollectedData(
client_name="Juan",
date="2026-05-05",
time_start="10:00",
party_size=3,
),
)
prompt = build_system_prompt(business, config, None, context)
assert "Juan" in prompt
assert "2026-05-05" in prompt
assert "10:00" in prompt
assert "3" in prompt
def test_build_system_prompt_requires_json_response():
business = _make_business()
config = _make_config()
context = ConversationContext(phone="549", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "create_reservation" in prompt
assert "collect_more" in prompt
assert "cancel" in prompt
@pytest.mark.skip(reason="requiere asyncpg — test de integración")
async def test_process_message_calls_claude_and_sends_reply(db, redis_mock):
from app.modules.bot_engine import service as bot_service
from app.modules.bot_engine.schemas import BotResponse, CollectedData
business = _make_business()
bot_response = BotResponse(
message="Hola, ¿cuál es tu nombre?",
action="collect_more",
collected_data=CollectedData(),
)
with (
patch.object(bot_service, "_load_context", return_value=ConversationContext(phone="549", business_id=1)),
patch("app.modules.bot_engine.service.get_business_config", return_value=_make_config()),
patch.object(bot_service, "_call_claude", return_value=bot_response),
patch.object(bot_service, "_save_context"),
patch("app.modules.bot_engine.service.send_text_message", new_callable=AsyncMock) as mock_send,
patch("app.modules.bot_engine.service.aioredis.from_url", return_value=redis_mock),
):
await bot_service.process_message(db=db, phone="549", text="Hola", business=business)
mock_send.assert_called_once_with(
phone_number_id="phone-id",
access_token="token",
to="549",
text="Hola, ¿cuál es tu nombre?",
)

View File

@ -0,0 +1,81 @@
from datetime import date, time
import pytest
from app.modules.calendar.service import _generate_slots, get_available_slots
from tests.conftest import make_business
def test_generate_slots_basic():
slots = _generate_slots(time(9, 0), time(12, 0), 60)
assert len(slots) == 3
assert slots[0] == (time(9, 0), time(10, 0))
assert slots[1] == (time(10, 0), time(11, 0))
assert slots[2] == (time(11, 0), time(12, 0))
def test_generate_slots_30min():
slots = _generate_slots(time(9, 0), time(10, 0), 30)
assert len(slots) == 2
assert slots[0] == (time(9, 0), time(9, 30))
assert slots[1] == (time(9, 30), time(10, 0))
def test_generate_slots_exact_fit():
slots = _generate_slots(time(9, 0), time(9, 30), 30)
assert len(slots) == 1
def test_generate_slots_no_fit():
# slot_duration mayor que el rango → lista vacía
slots = _generate_slots(time(9, 0), time(9, 20), 30)
assert len(slots) == 0
async def test_availability_closed_day(db, redis_mock):
business = await make_business(db, name="Test Cerrado")
# open_days = [0,1,2,3,4] → domingo (6) está cerrado
sunday = date(2026, 4, 26) # domingo
result = await get_available_slots(db, redis_mock, business.id, sunday)
assert result.is_open is False
assert result.slots == []
async def test_availability_open_day_no_reservations(db, redis_mock):
business = await make_business(db, name="Test Abierto")
monday = date(2026, 4, 27) # lunes
result = await get_available_slots(db, redis_mock, business.id, monday)
assert result.is_open is True
assert len(result.slots) == 9 # 09:0018:00 en slots de 60 min
assert all(s.available == 2 for s in result.slots) # max_per_slot=2, sin reservas
async def test_availability_blocked_date(db, redis_mock):
from datetime import date as d
business = await make_business(db, name="Test Bloqueado")
# Bloquear el lunes
monday = date(2026, 4, 27)
from app.modules.business.service import get_business_config
config = await get_business_config(db, business.id)
config.blocked_dates = [monday]
await db.commit()
result = await get_available_slots(db, redis_mock, business.id, monday)
assert result.is_open is False
async def test_availability_cached(db, redis_mock):
import json
from app.modules.calendar.schemas import DayAvailability
business = await make_business(db, name="Test Cache")
monday = date(2026, 4, 27)
cached_data = DayAvailability(date=monday, is_open=False, slots=[])
redis_mock.get = pytest.AsyncMock(return_value=cached_data.model_dump_json())
result = await get_available_slots(db, redis_mock, business.id, monday)
assert result.is_open is False
# Redis.get fue llamado → no consulta DB
redis_mock.get.assert_called_once()

View File

@ -0,0 +1,118 @@
from datetime import date, time
import pytest
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate, StatusUpdate
from app.modules.reservations.service import (
create_reservation,
delete_reservation,
get_reservation,
list_reservations,
update_status,
)
from tests.conftest import make_business, make_user
async def _create_test_reservation(db, redis_mock, business_id):
return await create_reservation(
db=db,
redis=redis_mock,
business_id=business_id,
data=ReservationCreate(
client_name="Ana García",
client_phone="5491112345678",
date=date(2026, 5, 5),
time_start=time(10, 0),
party_size=2,
source="manual",
),
)
async def test_create_reservation(db, redis_mock):
business = await make_business(db, name="Res Test")
r = await _create_test_reservation(db, redis_mock, business.id)
assert r.id is not None
assert r.client_name == "Ana García"
assert r.time_end == time(11, 0) # 10:00 + 60min slot
assert r.status == "pending"
assert r.source == "manual"
redis_mock.delete.assert_called_once() # caché invalidada
async def test_list_reservations_filter_by_date(db, redis_mock):
business = await make_business(db, name="List Test")
await _create_test_reservation(db, redis_mock, business.id)
results = await list_reservations(db, business.id, filter_date=date(2026, 5, 5))
assert len(results) >= 1
assert all(r.date == date(2026, 5, 5) for r in results)
async def test_list_reservations_filter_by_status(db, redis_mock):
business = await make_business(db, name="Status Test")
await _create_test_reservation(db, redis_mock, business.id)
results = await list_reservations(db, business.id, filter_status="pending")
assert all(r.status == "pending" for r in results)
async def test_get_reservation_not_found(db):
business = await make_business(db, name="404 Test")
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
await get_reservation(db, business.id, 999999)
assert exc.value.status_code == 404
async def test_get_reservation_wrong_business(db, redis_mock):
b1 = await make_business(db, name="Business 1")
b2 = await make_business(db, name="Business 2")
r = await _create_test_reservation(db, redis_mock, b1.id)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
await get_reservation(db, b2.id, r.id)
assert exc.value.status_code == 404
async def test_update_status_to_confirmed(db, redis_mock):
business = await make_business(db, name="Confirm Test")
r = await _create_test_reservation(db, redis_mock, business.id)
updated = await update_status(db, redis_mock, business.id, r.id, "confirmed")
assert updated.status == "confirmed"
async def test_update_status_cancelled_invalidates_cache(db, redis_mock):
business = await make_business(db, name="Cancel Cache Test")
r = await _create_test_reservation(db, redis_mock, business.id)
redis_mock.delete.reset_mock()
await update_status(db, redis_mock, business.id, r.id, "cancelled")
redis_mock.delete.assert_called_once() # caché invalidada al cancelar
async def test_update_status_invalid_raises(db, redis_mock):
business = await make_business(db, name="Invalid Status Test")
r = await _create_test_reservation(db, redis_mock, business.id)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
await update_status(db, redis_mock, business.id, r.id, "inexistente")
assert exc.value.status_code == 422
async def test_delete_reservation(db, redis_mock):
business = await make_business(db, name="Delete Test")
r = await _create_test_reservation(db, redis_mock, business.id)
r_id = r.id
redis_mock.delete.reset_mock()
await delete_reservation(db, redis_mock, business.id, r_id)
redis_mock.delete.assert_called_once()
from fastapi import HTTPException
with pytest.raises(HTTPException):
await get_reservation(db, business.id, r_id)

View File

@ -0,0 +1,42 @@
import pytest
from jose import JWTError
from app.core.security import (
create_access_token,
decode_token,
hash_password,
verify_password,
)
def test_hash_and_verify_password():
plain = "supersecret123"
hashed = hash_password(plain)
assert hashed != plain
assert verify_password(plain, hashed)
def test_wrong_password_fails():
hashed = hash_password("correct")
assert not verify_password("wrong", hashed)
def test_create_and_decode_token():
data = {"sub": "42", "business_id": 7}
token = create_access_token(data)
payload = decode_token(token)
assert payload["sub"] == "42"
assert payload["business_id"] == 7
def test_tampered_token_raises():
token = create_access_token({"sub": "1"})
tampered = token[:-5] + "XXXXX"
with pytest.raises(JWTError):
decode_token(tampered)
def test_token_contains_expiry():
token = create_access_token({"sub": "1"})
payload = decode_token(token)
assert "exp" in payload

View File

@ -0,0 +1,71 @@
import hashlib
import hmac
import pytest
from fastapi import HTTPException
from app.modules.whatsapp.service import verify_signature
def _make_signature(secret: str, body: bytes) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def test_valid_signature_passes(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
body = b'{"object":"whatsapp_business_account"}'
sig = _make_signature("mysecret", body)
verify_signature(body, sig) # no debe lanzar
def test_invalid_signature_raises(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
body = b'{"object":"whatsapp_business_account"}'
with pytest.raises(HTTPException) as exc:
verify_signature(body, "sha256=invalidsignature")
assert exc.value.status_code == 403
def test_missing_signature_raises(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
with pytest.raises(HTTPException) as exc:
verify_signature(b"body", "")
assert exc.value.status_code == 403
def test_missing_prefix_raises(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
with pytest.raises(HTTPException):
verify_signature(b"body", "notsha256=abc")
async def test_webhook_verification_endpoint(client_no_db):
import app.core.config as cfg
cfg.settings.META_WEBHOOK_VERIFY_TOKEN = "test-verify-token"
response = await client_no_db.get(
"/whatsapp/webhook",
params={
"hub.mode": "subscribe",
"hub.verify_token": "test-verify-token",
"hub.challenge": "challenge123",
},
)
assert response.status_code == 200
assert response.text == "challenge123"
async def test_webhook_verification_wrong_token(client_no_db):
import app.core.config as cfg
cfg.settings.META_WEBHOOK_VERIFY_TOKEN = "test-verify-token"
response = await client_no_db.get(
"/whatsapp/webhook",
params={
"hub.mode": "subscribe",
"hub.verify_token": "wrong-token",
"hub.challenge": "abc",
},
)
assert response.status_code == 403