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:
8
backend/.env.example
Normal file
8
backend/.env.example
Normal 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
38
backend/alembic.ini
Normal 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
|
||||
0
backend/alembic/__init__.py
Normal file
0
backend/alembic/__init__.py
Normal file
51
backend/alembic/env.py
Normal file
51
backend/alembic/env.py
Normal 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())
|
||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
155
backend/alembic/versions/0001_initial.py
Normal file
155
backend/alembic/versions/0001_initial.py
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
22
backend/app/core/config.py
Normal file
22
backend/app/core/config.py
Normal 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()
|
||||
30
backend/app/core/database.py
Normal file
30
backend/app/core/database.py
Normal 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
|
||||
43
backend/app/core/dependencies.py
Normal file
43
backend/app/core/dependencies.py
Normal 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
|
||||
9
backend/app/core/errors.py
Normal file
9
backend/app/core/errors.py
Normal 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"},
|
||||
)
|
||||
9
backend/app/core/redis.py
Normal file
9
backend/app/core/redis.py
Normal 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
|
||||
27
backend/app/core/security.py
Normal file
27
backend/app/core/security.py
Normal 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
40
backend/app/main.py
Normal 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"])
|
||||
0
backend/app/modules/__init__.py
Normal file
0
backend/app/modules/__init__.py
Normal file
0
backend/app/modules/admin/__init__.py
Normal file
0
backend/app/modules/admin/__init__.py
Normal file
43
backend/app/modules/admin/router.py
Normal file
43
backend/app/modules/admin/router.py
Normal 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)
|
||||
21
backend/app/modules/admin/schemas.py
Normal file
21
backend/app/modules/admin/schemas.py
Normal 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
|
||||
51
backend/app/modules/admin/service.py
Normal file
51
backend/app/modules/admin/service.py
Normal 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,
|
||||
)
|
||||
0
backend/app/modules/auth/__init__.py
Normal file
0
backend/app/modules/auth/__init__.py
Normal file
17
backend/app/modules/auth/models.py
Normal file
17
backend/app/modules/auth/models.py
Normal 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")
|
||||
55
backend/app/modules/auth/router.py
Normal file
55
backend/app/modules/auth/router.py
Normal 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)
|
||||
56
backend/app/modules/auth/schemas.py
Normal file
56
backend/app/modules/auth/schemas.py
Normal 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}
|
||||
153
backend/app/modules/auth/service.py
Normal file
153
backend/app/modules/auth/service.py
Normal 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)
|
||||
0
backend/app/modules/billing/__init__.py
Normal file
0
backend/app/modules/billing/__init__.py
Normal file
33
backend/app/modules/billing/router.py
Normal file
33
backend/app/modules/billing/router.py
Normal 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)
|
||||
25
backend/app/modules/billing/schemas.py
Normal file
25
backend/app/modules/billing/schemas.py
Normal 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
|
||||
59
backend/app/modules/billing/service.py
Normal file
59
backend/app/modules/billing/service.py
Normal 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,
|
||||
)
|
||||
0
backend/app/modules/bot_engine/__init__.py
Normal file
0
backend/app/modules/bot_engine/__init__.py
Normal file
73
backend/app/modules/bot_engine/prompt.py
Normal file
73
backend/app/modules/bot_engine/prompt.py
Normal 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>
|
||||
}}
|
||||
}}"""
|
||||
21
backend/app/modules/bot_engine/schemas.py
Normal file
21
backend/app/modules/bot_engine/schemas.py
Normal 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
|
||||
145
backend/app/modules/bot_engine/service.py
Normal file
145
backend/app/modules/bot_engine/service.py
Normal 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()
|
||||
0
backend/app/modules/business/__init__.py
Normal file
0
backend/app/modules/business/__init__.py
Normal file
65
backend/app/modules/business/models.py
Normal file
65
backend/app/modules/business/models.py
Normal 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")
|
||||
42
backend/app/modules/business/router.py
Normal file
42
backend/app/modules/business/router.py
Normal 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)
|
||||
48
backend/app/modules/business/schemas.py
Normal file
48
backend/app/modules/business/schemas.py
Normal 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
|
||||
49
backend/app/modules/business/service.py
Normal file
49
backend/app/modules/business/service.py
Normal 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
|
||||
0
backend/app/modules/calendar/__init__.py
Normal file
0
backend/app/modules/calendar/__init__.py
Normal file
57
backend/app/modules/calendar/router.py
Normal file
57
backend/app/modules/calendar/router.py
Normal 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)
|
||||
21
backend/app/modules/calendar/schemas.py
Normal file
21
backend/app/modules/calendar/schemas.py
Normal 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
|
||||
135
backend/app/modules/calendar/service.py
Normal file
135
backend/app/modules/calendar/service.py
Normal 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()
|
||||
0
backend/app/modules/dashboard/__init__.py
Normal file
0
backend/app/modules/dashboard/__init__.py
Normal file
35
backend/app/modules/dashboard/router.py
Normal file
35
backend/app/modules/dashboard/router.py
Normal 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)
|
||||
26
backend/app/modules/dashboard/schemas.py
Normal file
26
backend/app/modules/dashboard/schemas.py
Normal 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
|
||||
65
backend/app/modules/dashboard/service.py
Normal file
65
backend/app/modules/dashboard/service.py
Normal 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()]
|
||||
0
backend/app/modules/notifications/__init__.py
Normal file
0
backend/app/modules/notifications/__init__.py
Normal file
47
backend/app/modules/notifications/service.py
Normal file
47
backend/app/modules/notifications/service.py
Normal 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)
|
||||
0
backend/app/modules/reservations/__init__.py
Normal file
0
backend/app/modules/reservations/__init__.py
Normal file
31
backend/app/modules/reservations/models.py
Normal file
31
backend/app/modules/reservations/models.py
Normal 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")
|
||||
74
backend/app/modules/reservations/router.py
Normal file
74
backend/app/modules/reservations/router.py
Normal 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)
|
||||
44
backend/app/modules/reservations/schemas.py
Normal file
44
backend/app/modules/reservations/schemas.py
Normal 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}
|
||||
140
backend/app/modules/reservations/service.py
Normal file
140
backend/app/modules/reservations/service.py
Normal 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
|
||||
0
backend/app/modules/whatsapp/__init__.py
Normal file
0
backend/app/modules/whatsapp/__init__.py
Normal file
21
backend/app/modules/whatsapp/client.py
Normal file
21
backend/app/modules/whatsapp/client.py
Normal 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()
|
||||
76
backend/app/modules/whatsapp/router.py
Normal file
76
backend/app/modules/whatsapp/router.py
Normal 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"}
|
||||
49
backend/app/modules/whatsapp/schemas.py
Normal file
49
backend/app/modules/whatsapp/schemas.py
Normal 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]
|
||||
87
backend/app/modules/whatsapp/service.py
Normal file
87
backend/app/modules/whatsapp/service.py
Normal 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,
|
||||
)
|
||||
0
backend/app/shared/__init__.py
Normal file
0
backend/app/shared/__init__.py
Normal file
0
backend/app/shared/models/__init__.py
Normal file
0
backend/app/shared/models/__init__.py
Normal file
0
backend/app/shared/schemas/__init__.py
Normal file
0
backend/app/shared/schemas/__init__.py
Normal file
0
backend/app/shared/utils/__init__.py
Normal file
0
backend/app/shared/utils/__init__.py
Normal file
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
20
backend/requirements.txt
Normal file
20
backend/requirements.txt
Normal 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
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
115
backend/tests/conftest.py
Normal file
115
backend/tests/conftest.py
Normal 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
164
backend/tests/test_auth.py
Normal 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()
|
||||
140
backend/tests/test_bot_engine.py
Normal file
140
backend/tests/test_bot_engine.py
Normal 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?",
|
||||
)
|
||||
81
backend/tests/test_calendar.py
Normal file
81
backend/tests/test_calendar.py
Normal 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:00–18: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()
|
||||
118
backend/tests/test_reservations.py
Normal file
118
backend/tests/test_reservations.py
Normal 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)
|
||||
42
backend/tests/test_security.py
Normal file
42
backend/tests/test_security.py
Normal 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
|
||||
71
backend/tests/test_webhook.py
Normal file
71
backend/tests/test_webhook.py
Normal 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
|
||||
Reference in New Issue
Block a user