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:
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,
|
||||
)
|
||||
Reference in New Issue
Block a user