commit 798bd143125033b96d32b7c65a84af5d0d82f63b Author: Hanzo_dev <2002samudiojohan@gmail.com> Date: Tue Apr 28 09:49:41 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a715111 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +venv/ +env/ +.env +.env.* +!.env.example + +# Node / Frontend +node_modules/ +dist/ +.cache/ +*.local + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Tests +.pytest_cache/ +.coverage +htmlcov/ +*.xml + +# Alembic +# (keep versions tracked) + +# Claude Code local settings +.claude/ + +# Logs +*.log +logs/ + +# Docker +*.override.yml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d8e843 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multi-tenant SaaS platform for automating WhatsApp Business reservations via an AI-powered bot. Businesses (restaurants, clinics, salons) connect their WhatsApp and a Claude-powered bot handles bookings conversationally. + +## Repository Structure + +``` +hermesmessages/ +├── backend/ # FastAPI API — Python 3.12+ +├── frontend/ # (in progress) +└── database/ # docker-compose.yml (PostgreSQL + Redis) +``` + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Language | Python 3.12+ | +| Framework | FastAPI | +| Database | PostgreSQL + SQLAlchemy (async) + Alembic | +| Cache | Redis | +| Validation | Pydantic v2 | +| Auth | JWT | +| AI | Claude API (`claude-sonnet-4-20250514`) | +| Testing | Pytest | + +## Common Commands + +All backend commands run from `backend/`: + +```bash +# Start local services +cd database && docker-compose up -d + +# Install dependencies +cd backend && pip install -r requirements.txt + +# Run development server +cd backend && uvicorn app.main:app --reload + +# Run migrations +cd backend && alembic upgrade head + +# Generate new migration +cd backend && alembic revision --autogenerate -m "description" + +# Run tests +cd backend && pytest + +# Run a single test file +cd backend && pytest tests/test_calendar.py +``` + +## Backend Architecture + +### Module Structure + +``` +backend/ +├── app/ +│ ├── main.py +│ ├── core/ # Config & infrastructure +│ │ ├── config.py # pydantic-settings +│ │ ├── database.py # async PostgreSQL +│ │ ├── redis.py +│ │ ├── security.py # JWT, hashing +│ │ ├── dependencies.py # get_current_business, require_admin +│ │ └── errors.py # global exception handler +│ ├── modules/ +│ │ ├── auth/ +│ │ ├── business/ +│ │ ├── whatsapp/ +│ │ ├── bot_engine/ +│ │ ├── calendar/ +│ │ ├── reservations/ +│ │ ├── notifications/ +│ │ ├── dashboard/ +│ │ ├── billing/ +│ │ └── admin/ +│ └── shared/ +├── alembic/ +│ └── versions/0001_initial.py +└── requirements.txt +``` + +Each module contains: `router.py`, `service.py`, `models.py`, `schemas.py`, and optionally `dependencies.py`. + +### Module Data Flow + +``` +WhatsApp webhook → Bot Engine → Calendar → Reservations → Notifications + ↑ + Business Config +``` + +## Critical Business Logic + +### Multi-tenancy Rule +`business_id` is **always** extracted from the JWT token — never from the request body or path params. All queries must filter by `business_id` from the token. Exception: the WhatsApp webhook resolves `business_id` via `whatsapp_phone_number_id` (no JWT on webhook calls). + +### Availability Calculation (`calendar/service.py`) +1. Fetch `BusinessConfig` for the business +2. Check if date is in `open_days` and not in `blocked_dates` +3. Generate all time slots using `open_time`, `close_time`, `slot_duration` +4. Query confirmed+pending reservations for that day +5. Filter slots where existing count < `max_per_slot` +6. Cache result in Redis with 5-minute TTL +7. Invalidate cache when a reservation is created, updated, cancelled, or deleted + +### WhatsApp Webhook (`whatsapp/router.py`) +- `GET /whatsapp/webhook` — Meta verification (return `hub.challenge`) +- `POST /whatsapp/webhook` — Validate `X-Hub-Signature-256`, dispatch to bot engine via `BackgroundTasks`, respond 200 immediately + +### Bot Engine (`bot_engine/service.py`) +1. Load/create `ConversationContext` from Redis (TTL: 30 min, key: `conv:{business_id}:{phone}`) +2. Build system prompt with: assistant name/tone, available slots, collected data, language instruction +3. Call Claude API — response must be JSON with `action: collect_more | create_reservation | cancel` +4. If `create_reservation` → call `reservations/service.py::create_reservation`, clear context +5. If `cancel` → clear context +6. Send reply via WhatsApp Graph API +7. Save updated context to Redis + +## Key Models + +- **Business** — tenant record with Meta/WhatsApp credentials, plan (`free|basic|pro`), status (`trial|active|suspended`) +- **BusinessConfig** — `open_days` (list of 0-6 ints), `open_time/close_time`, `slot_duration` (minutes), `max_per_slot`, `blocked_dates` (PostgreSQL ARRAY columns) +- **Reservation** — `status: pending|confirmed|cancelled|no_show`, `source: whatsapp|manual`, `time_end` computed from `slot_duration` +- **User** — `role: owner|admin`, `meta_user_id` for Facebook OAuth + +## Environment Variables + +Defined in `backend/.env.example`: + +```env +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/hermesmessages +REDIS_URL=redis://localhost:6379 +SECRET_KEY= +META_APP_ID= +META_APP_SECRET= +META_WEBHOOK_VERIFY_TOKEN= +ANTHROPIC_API_KEY= +ENVIRONMENT=development|production +``` + +## Access Control + +- All endpoints require JWT except `GET/POST /whatsapp/webhook` and `/auth/*` +- `/admin/*` routes require `role: admin` (enforced via `require_admin` dependency) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d9e2c0e --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..27e668c --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/__init__.py b/backend/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..8bc7162 --- /dev/null +++ b/backend/alembic/env.py @@ -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()) diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..478d84c --- /dev/null +++ b/backend/alembic/versions/0001_initial.py @@ -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) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..aa2a021 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..a2fa59c --- /dev/null +++ b/backend/app/core/database.py @@ -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 diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 0000000..b84cd9d --- /dev/null +++ b/backend/app/core/dependencies.py @@ -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 diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..2ec4eaa --- /dev/null +++ b/backend/app/core/errors.py @@ -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"}, + ) diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 0000000..149cba3 --- /dev/null +++ b/backend/app/core/redis.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..21de655 --- /dev/null +++ b/backend/app/core/security.py @@ -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]) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..929d08f --- /dev/null +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/modules/__init__.py b/backend/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/admin/__init__.py b/backend/app/modules/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/admin/router.py b/backend/app/modules/admin/router.py new file mode 100644 index 0000000..c259f66 --- /dev/null +++ b/backend/app/modules/admin/router.py @@ -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) diff --git a/backend/app/modules/admin/schemas.py b/backend/app/modules/admin/schemas.py new file mode 100644 index 0000000..57ae16f --- /dev/null +++ b/backend/app/modules/admin/schemas.py @@ -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 diff --git a/backend/app/modules/admin/service.py b/backend/app/modules/admin/service.py new file mode 100644 index 0000000..bf1d184 --- /dev/null +++ b/backend/app/modules/admin/service.py @@ -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, + ) diff --git a/backend/app/modules/auth/__init__.py b/backend/app/modules/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/auth/models.py b/backend/app/modules/auth/models.py new file mode 100644 index 0000000..93289e0 --- /dev/null +++ b/backend/app/modules/auth/models.py @@ -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") diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py new file mode 100644 index 0000000..3211b46 --- /dev/null +++ b/backend/app/modules/auth/router.py @@ -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) diff --git a/backend/app/modules/auth/schemas.py b/backend/app/modules/auth/schemas.py new file mode 100644 index 0000000..17477f7 --- /dev/null +++ b/backend/app/modules/auth/schemas.py @@ -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} diff --git a/backend/app/modules/auth/service.py b/backend/app/modules/auth/service.py new file mode 100644 index 0000000..e08d0e9 --- /dev/null +++ b/backend/app/modules/auth/service.py @@ -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) diff --git a/backend/app/modules/billing/__init__.py b/backend/app/modules/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/billing/router.py b/backend/app/modules/billing/router.py new file mode 100644 index 0000000..035bf2f --- /dev/null +++ b/backend/app/modules/billing/router.py @@ -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) diff --git a/backend/app/modules/billing/schemas.py b/backend/app/modules/billing/schemas.py new file mode 100644 index 0000000..c99b295 --- /dev/null +++ b/backend/app/modules/billing/schemas.py @@ -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 diff --git a/backend/app/modules/billing/service.py b/backend/app/modules/billing/service.py new file mode 100644 index 0000000..45ccd3c --- /dev/null +++ b/backend/app/modules/billing/service.py @@ -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, + ) diff --git a/backend/app/modules/bot_engine/__init__.py b/backend/app/modules/bot_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/bot_engine/prompt.py b/backend/app/modules/bot_engine/prompt.py new file mode 100644 index 0000000..9f41ef6 --- /dev/null +++ b/backend/app/modules/bot_engine/prompt.py @@ -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": "", + "action": "collect_more" | "create_reservation" | "cancel", + "collected_data": {{ + "client_name": null | "", + "date": null | "", + "time_start": null | "", + "party_size": null | + }} +}}""" diff --git a/backend/app/modules/bot_engine/schemas.py b/backend/app/modules/bot_engine/schemas.py new file mode 100644 index 0000000..74e8b6b --- /dev/null +++ b/backend/app/modules/bot_engine/schemas.py @@ -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 diff --git a/backend/app/modules/bot_engine/service.py b/backend/app/modules/bot_engine/service.py new file mode 100644 index 0000000..f34931b --- /dev/null +++ b/backend/app/modules/bot_engine/service.py @@ -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() diff --git a/backend/app/modules/business/__init__.py b/backend/app/modules/business/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/business/models.py b/backend/app/modules/business/models.py new file mode 100644 index 0000000..d25545e --- /dev/null +++ b/backend/app/modules/business/models.py @@ -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") diff --git a/backend/app/modules/business/router.py b/backend/app/modules/business/router.py new file mode 100644 index 0000000..f8f78a8 --- /dev/null +++ b/backend/app/modules/business/router.py @@ -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) diff --git a/backend/app/modules/business/schemas.py b/backend/app/modules/business/schemas.py new file mode 100644 index 0000000..1a5f521 --- /dev/null +++ b/backend/app/modules/business/schemas.py @@ -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 diff --git a/backend/app/modules/business/service.py b/backend/app/modules/business/service.py new file mode 100644 index 0000000..792206b --- /dev/null +++ b/backend/app/modules/business/service.py @@ -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 diff --git a/backend/app/modules/calendar/__init__.py b/backend/app/modules/calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/calendar/router.py b/backend/app/modules/calendar/router.py new file mode 100644 index 0000000..d0b45aa --- /dev/null +++ b/backend/app/modules/calendar/router.py @@ -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) diff --git a/backend/app/modules/calendar/schemas.py b/backend/app/modules/calendar/schemas.py new file mode 100644 index 0000000..e4c9e0d --- /dev/null +++ b/backend/app/modules/calendar/schemas.py @@ -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 diff --git a/backend/app/modules/calendar/service.py b/backend/app/modules/calendar/service.py new file mode 100644 index 0000000..a89b20c --- /dev/null +++ b/backend/app/modules/calendar/service.py @@ -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() diff --git a/backend/app/modules/dashboard/__init__.py b/backend/app/modules/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/dashboard/router.py b/backend/app/modules/dashboard/router.py new file mode 100644 index 0000000..b447e25 --- /dev/null +++ b/backend/app/modules/dashboard/router.py @@ -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) diff --git a/backend/app/modules/dashboard/schemas.py b/backend/app/modules/dashboard/schemas.py new file mode 100644 index 0000000..9a160dd --- /dev/null +++ b/backend/app/modules/dashboard/schemas.py @@ -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 diff --git a/backend/app/modules/dashboard/service.py b/backend/app/modules/dashboard/service.py new file mode 100644 index 0000000..d122540 --- /dev/null +++ b/backend/app/modules/dashboard/service.py @@ -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()] diff --git a/backend/app/modules/notifications/__init__.py b/backend/app/modules/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/notifications/service.py b/backend/app/modules/notifications/service.py new file mode 100644 index 0000000..af88b1d --- /dev/null +++ b/backend/app/modules/notifications/service.py @@ -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) diff --git a/backend/app/modules/reservations/__init__.py b/backend/app/modules/reservations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/reservations/models.py b/backend/app/modules/reservations/models.py new file mode 100644 index 0000000..c265b60 --- /dev/null +++ b/backend/app/modules/reservations/models.py @@ -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") diff --git a/backend/app/modules/reservations/router.py b/backend/app/modules/reservations/router.py new file mode 100644 index 0000000..52f8c73 --- /dev/null +++ b/backend/app/modules/reservations/router.py @@ -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) diff --git a/backend/app/modules/reservations/schemas.py b/backend/app/modules/reservations/schemas.py new file mode 100644 index 0000000..082d717 --- /dev/null +++ b/backend/app/modules/reservations/schemas.py @@ -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} diff --git a/backend/app/modules/reservations/service.py b/backend/app/modules/reservations/service.py new file mode 100644 index 0000000..86d0720 --- /dev/null +++ b/backend/app/modules/reservations/service.py @@ -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 diff --git a/backend/app/modules/whatsapp/__init__.py b/backend/app/modules/whatsapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/whatsapp/client.py b/backend/app/modules/whatsapp/client.py new file mode 100644 index 0000000..198e5fb --- /dev/null +++ b/backend/app/modules/whatsapp/client.py @@ -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() diff --git a/backend/app/modules/whatsapp/router.py b/backend/app/modules/whatsapp/router.py new file mode 100644 index 0000000..b1ddb2d --- /dev/null +++ b/backend/app/modules/whatsapp/router.py @@ -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"} diff --git a/backend/app/modules/whatsapp/schemas.py b/backend/app/modules/whatsapp/schemas.py new file mode 100644 index 0000000..a59c2e7 --- /dev/null +++ b/backend/app/modules/whatsapp/schemas.py @@ -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] diff --git a/backend/app/modules/whatsapp/service.py b/backend/app/modules/whatsapp/service.py new file mode 100644 index 0000000..734457c --- /dev/null +++ b/backend/app/modules/whatsapp/service.py @@ -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, + ) diff --git a/backend/app/shared/__init__.py b/backend/app/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/shared/models/__init__.py b/backend/app/shared/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/shared/schemas/__init__.py b/backend/app/shared/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/shared/utils/__init__.py b/backend/app/shared/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f969d89 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..fcc6bb6 --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..29a573d --- /dev/null +++ b/backend/tests/test_auth.py @@ -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() diff --git a/backend/tests/test_bot_engine.py b/backend/tests/test_bot_engine.py new file mode 100644 index 0000000..55496c9 --- /dev/null +++ b/backend/tests/test_bot_engine.py @@ -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?", + ) diff --git a/backend/tests/test_calendar.py b/backend/tests/test_calendar.py new file mode 100644 index 0000000..394fe3f --- /dev/null +++ b/backend/tests/test_calendar.py @@ -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() diff --git a/backend/tests/test_reservations.py b/backend/tests/test_reservations.py new file mode 100644 index 0000000..9c0403f --- /dev/null +++ b/backend/tests/test_reservations.py @@ -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) diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..14588e5 --- /dev/null +++ b/backend/tests/test_security.py @@ -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 diff --git a/backend/tests/test_webhook.py b/backend/tests/test_webhook.py new file mode 100644 index 0000000..01b80df --- /dev/null +++ b/backend/tests/test_webhook.py @@ -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 diff --git a/database/docker-compose.yml b/database/docker-compose.yml new file mode 100644 index 0000000..03c2a83 --- /dev/null +++ b/database/docker-compose.yml @@ -0,0 +1,22 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: hermesmessages + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..142d5f7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + HermesMessages + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..63a674b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "hermesmessages-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "axios": "^1.7.7", + "date-fns": "^3.6.0", + "lucide-react": "^0.447.0", + "recharts": "^2.12.7", + "clsx": "^2.1.1", + "tailwind-merge": "^2.5.3", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.3", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-badge": "^1.0.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-label": "^2.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "vite": "^5.4.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..370a364 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,44 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useAuth } from '@/contexts/AuthContext' +import Layout from '@/components/layout/Layout' +import LoginPage from '@/pages/auth/LoginPage' +import RegisterPage from '@/pages/auth/RegisterPage' +import DashboardPage from '@/pages/DashboardPage' +import ReservationsPage from '@/pages/ReservationsPage' +import CalendarPage from '@/pages/CalendarPage' +import ConfigPage from '@/pages/ConfigPage' +import BillingPage from '@/pages/BillingPage' + +function PrivateRoute({ children }) { + const { isAuthenticated, loading } = useAuth() + if (loading) return ( +
+
+
+ ) + return isAuthenticated ? children : +} + +function PublicRoute({ children }) { + const { isAuthenticated, loading } = useAuth() + if (loading) return null + return isAuthenticated ? : children +} + +export default function App() { + return ( + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ) +} diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx new file mode 100644 index 0000000..472923e --- /dev/null +++ b/frontend/src/components/layout/Layout.jsx @@ -0,0 +1,29 @@ +import { Outlet, useLocation } from 'react-router-dom' +import Sidebar from './Sidebar' + +const PAGE_TITLES = { + '/dashboard': 'Dashboard', + '/reservations': 'Reservas', + '/calendar': 'Disponibilidad', + '/config': 'Configuración', + '/billing': 'Plan y Facturación', +} + +export default function Layout() { + const { pathname } = useLocation() + const title = PAGE_TITLES[pathname] ?? 'HermesMessages' + + return ( +
+ +
+
+

{title}

+
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..cbf55c3 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.jsx @@ -0,0 +1,97 @@ +import { NavLink, useNavigate } from 'react-router-dom' +import { + LayoutDashboard, CalendarDays, BookOpen, Settings, CreditCard, + MessageCircle, LogOut, ChevronRight, +} from 'lucide-react' +import { useAuth } from '@/contexts/AuthContext' +import { cn } from '@/lib/utils' + +const NAV_ITEMS = [ + { to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' }, + { to: '/reservations', icon: BookOpen, label: 'Reservas' }, + { to: '/calendar', icon: CalendarDays, label: 'Disponibilidad' }, + { to: '/config', icon: Settings, label: 'Configuración' }, + { to: '/billing', icon: CreditCard, label: 'Plan y Facturación' }, +] + +export default function Sidebar() { + const { user, logout } = useAuth() + const navigate = useNavigate() + + async function handleLogout() { + await logout() + navigate('/login') + } + + return ( + + ) +} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..19e4a99 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react' +import { authApi } from '@/lib/api' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const token = localStorage.getItem('token') + if (token) { + const stored = localStorage.getItem('user') + if (stored) setUser(JSON.parse(stored)) + } + setLoading(false) + }, []) + + const login = useCallback(async (email, password) => { + const { data } = await authApi.login({ email, password }) + localStorage.setItem('token', data.access_token) + const userData = { email, business_id: data.business_id } + localStorage.setItem('user', JSON.stringify(userData)) + setUser(userData) + return data + }, []) + + const register = useCallback(async (payload) => { + const { data } = await authApi.register(payload) + localStorage.setItem('token', data.access_token) + const userData = { email: payload.email, business_id: data.business_id } + localStorage.setItem('user', JSON.stringify(userData)) + setUser(userData) + return data + }, []) + + const logout = useCallback(async () => { + try { await authApi.logout() } catch {} + localStorage.removeItem('token') + localStorage.removeItem('user') + setUser(null) + }, []) + + const isAuthenticated = Boolean(user) + + return ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used inside AuthProvider') + return ctx +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..93f3d2e --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,191 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ── Design tokens ── */ +:root { + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); +} + +/* ── Base ── */ +@layer base { + *, *::before, *::after { + box-sizing: border-box; + } + + html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-behavior: smooth; + } + + body { + font-family: 'DM Sans', sans-serif; + background-color: #f7f8fc; + color: #0f172a; + font-size: 15px; + line-height: 1.6; + } + + h1, h2, h3, h4, h5, h6 { + font-family: 'Bricolage Grotesque', sans-serif; + line-height: 1.25; + letter-spacing: -0.02em; + } + + input, textarea, select, button { + font-family: 'DM Sans', sans-serif; + } + + /* Scrollbar personalizado */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 999px; + } + ::-webkit-scrollbar-thumb:hover { + background: #94a3b8; + } +} + +/* ── Componentes reutilizables ── */ +@layer components { + + /* Botón base — scale on press (Emil Kowalski) */ + .btn { + @apply inline-flex items-center justify-center gap-2 rounded-lg font-medium + transition-all duration-150 select-none cursor-pointer + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 + disabled:opacity-50 disabled:pointer-events-none; + transition-property: transform, background-color, box-shadow, opacity; + transition-timing-function: var(--ease-out); + } + .btn:active:not(:disabled) { + transform: scale(0.97); + } + + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 shadow-sm; + @apply px-4 py-2.5 text-sm; + } + + .btn-secondary { + @apply btn bg-white text-slate-700 border border-border hover:bg-slate-50 shadow-card; + @apply px-4 py-2.5 text-sm; + } + + .btn-ghost { + @apply btn text-slate-600 hover:bg-slate-100 hover:text-slate-900; + @apply px-3 py-2 text-sm; + } + + .btn-danger { + @apply btn bg-danger-600 text-white hover:bg-red-700; + @apply px-4 py-2.5 text-sm; + } + + /* Input base — con label visible y helper text */ + .field { + @apply flex flex-col gap-1.5; + } + + .field-label { + @apply text-sm font-medium text-slate-700; + } + + .field-label-required::after { + content: ' *'; + @apply text-danger-600; + } + + .field-input { + @apply w-full rounded-lg border border-border bg-white px-3.5 py-2.5 text-sm + text-slate-900 placeholder:text-slate-400 + transition-all duration-150 + focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 + disabled:bg-slate-50 disabled:text-slate-400 disabled:cursor-not-allowed; + } + + .field-input-error { + @apply border-danger-500 focus:ring-danger-500 focus:border-danger-500; + } + + .field-helper { + @apply text-xs text-slate-500 leading-relaxed; + } + + .field-error { + @apply text-xs text-danger-600 flex items-center gap-1; + } + + /* Card */ + .card { + @apply bg-white rounded-xl border border-border shadow-card; + } + + .card-hover { + @apply card transition-shadow duration-200 hover:shadow-card-hover cursor-pointer; + } + + /* Badge */ + .badge { + @apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium; + } + + .badge-green { + @apply badge bg-primary-100 text-primary-700; + } + + .badge-yellow { + @apply badge bg-warning-100 text-warning-600; + } + + .badge-red { + @apply badge bg-danger-100 text-danger-600; + } + + .badge-gray { + @apply badge bg-slate-100 text-slate-600; + } + + /* Skeleton loader */ + .skeleton { + @apply animate-skeleton rounded bg-slate-100; + } + + /* Auth background con dot grid */ + .auth-bg { + background-color: #f7f8fc; + background-image: radial-gradient(circle, #cbd5e1 1px, transparent 1px); + background-size: 24px 24px; + } +} + +/* ── Utilities ── */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Animaciones con prefers-reduced-motion */ + @media (prefers-reduced-motion: reduce) { + .btn:active { transform: none; } + .animate-stagger-1, + .animate-stagger-2, + .animate-stagger-3, + .animate-stagger-4, + .animate-stagger-5 { + animation: none; + opacity: 1; + transform: none; + } + } +} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..4f00680 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,73 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + headers: { 'Content-Type': 'application/json' }, +}) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + return Promise.reject(err) + } +) + +export const authApi = { + register: (data) => api.post('/auth/register', data), + login: (data) => api.post('/auth/login', data), + logout: () => api.post('/auth/logout'), +} + +export const businessApi = { + getMe: () => api.get('/business/me'), + updateMe: (data) => api.put('/business/me', data), + getConfig: () => api.get('/business/me/config'), + updateConfig: (data) => api.put('/business/me/config', data), +} + +export const whatsappApi = { + getStatus: () => api.get('/whatsapp/status'), + connect: (data) => api.post('/whatsapp/connect', data), + disconnect: () => api.post('/whatsapp/disconnect'), +} + +export const reservationsApi = { + list: (params) => api.get('/reservations/', { params }), + create: (data) => api.post('/reservations/', data), + get: (id) => api.get(`/reservations/${id}`), + update: (id, data) => api.put(`/reservations/${id}`, data), + updateStatus: (id, status) => api.patch(`/reservations/${id}/status`, { status }), + delete: (id) => api.delete(`/reservations/${id}`), +} + +export const calendarApi = { + getAvailability: (date) => api.get('/calendar/availability', { params: { date } }), + getAvailabilityRange: (start, end) => + api.get('/calendar/availability/range', { params: { start_date: start, end_date: end } }), + blockDate: (date) => api.post('/calendar/blocked-dates', { date }), + unblockDate: (date) => api.delete(`/calendar/blocked-dates/${date}`), +} + +export const dashboardApi = { + getStats: () => api.get('/dashboard/stats'), + getAgenda: (date) => api.get('/dashboard/agenda', { params: { date } }), + getPeakHours: () => api.get('/dashboard/peak-hours'), +} + +export const billingApi = { + getPlan: () => api.get('/billing/plan'), + getUsage: () => api.get('/billing/usage'), + upgrade: (plan) => api.post('/billing/upgrade', { plan }), +} + +export default api diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js new file mode 100644 index 0000000..5fc7fc9 --- /dev/null +++ b/frontend/src/lib/utils.js @@ -0,0 +1,65 @@ +import { clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs) { + return twMerge(clsx(inputs)) +} + +export function formatDate(dateStr, opts = {}) { + if (!dateStr) return '—' + const d = new Date(dateStr) + return d.toLocaleDateString('es-ES', { + day: '2-digit', month: 'short', year: 'numeric', ...opts, + }) +} + +export function formatTime(timeStr) { + if (!timeStr) return '—' + return timeStr.slice(0, 5) +} + +export function formatDateTime(dateStr) { + if (!dateStr) return '—' + const d = new Date(dateStr) + return d.toLocaleString('es-ES', { + day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', + }) +} + +export const STATUS_LABELS = { + pending: 'Pendiente', + confirmed: 'Confirmada', + cancelled: 'Cancelada', + no_show: 'No asistió', +} + +export const STATUS_BADGE = { + pending: 'badge-yellow', + confirmed: 'badge-green', + cancelled: 'badge-red', + no_show: 'badge-gray', +} + +export const BUSINESS_TYPES = [ + { value: 'restaurant', label: 'Restaurante' }, + { value: 'clinic', label: 'Clínica / Consultorio' }, + { value: 'salon', label: 'Salón de belleza' }, + { value: 'spa', label: 'Spa / Bienestar' }, + { value: 'barbershop', label: 'Barbería' }, + { value: 'gym', label: 'Gimnasio / Entrenador' }, + { value: 'other', label: 'Otro' }, +] + +export const TIMEZONES = [ + { value: 'America/Bogota', label: 'Bogotá (UTC-5)' }, + { value: 'America/Mexico_City', label: 'Ciudad de México (UTC-6)' }, + { value: 'America/Lima', label: 'Lima (UTC-5)' }, + { value: 'America/Santiago', label: 'Santiago (UTC-4)' }, + { value: 'America/Buenos_Aires', label: 'Buenos Aires (UTC-3)' }, + { value: 'America/Caracas', label: 'Caracas (UTC-4)' }, + { value: 'Europe/Madrid', label: 'Madrid (UTC+1/+2)' }, + { value: 'UTC', label: 'UTC' }, +] + +export const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'] +export const DAYS_FULL = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'] diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..0efe32b --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,16 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { AuthProvider } from '@/contexts/AuthContext' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + +) diff --git a/frontend/src/pages/BillingPage.jsx b/frontend/src/pages/BillingPage.jsx new file mode 100644 index 0000000..8717a84 --- /dev/null +++ b/frontend/src/pages/BillingPage.jsx @@ -0,0 +1,259 @@ +import { useState, useEffect } from 'react' +import { CheckCircle, Zap, Shield, Star, MessageCircle, CalendarDays, Bot } from 'lucide-react' +import { billingApi } from '@/lib/api' +import { cn } from '@/lib/utils' + +const PLANS = [ + { + id: 'free', + name: 'Gratis', + price: '$0', + period: 'para siempre', + description: 'Ideal para probar la plataforma', + icon: Zap, + color: 'text-slate-600', + bgColor: 'bg-slate-100', + features: [ + '50 reservas por mes', + '1 número de WhatsApp', + 'Bot básico de reservas', + 'Dashboard de reservas', + ], + limits: { + reservations: 50, + }, + }, + { + id: 'basic', + name: 'Básico', + price: '$19', + period: 'por mes', + description: 'Para negocios en crecimiento', + icon: Shield, + color: 'text-primary-600', + bgColor: 'bg-primary-100', + popular: true, + features: [ + '500 reservas por mes', + '1 número de WhatsApp', + 'Bot inteligente con IA', + 'Notificaciones automáticas', + 'Configuración de horarios', + 'Soporte prioritario', + ], + limits: { + reservations: 500, + }, + }, + { + id: 'pro', + name: 'Pro', + price: '$49', + period: 'por mes', + description: 'Para negocios establecidos', + icon: Star, + color: 'text-warning-600', + bgColor: 'bg-warning-100', + features: [ + 'Reservas ilimitadas', + '3 números de WhatsApp', + 'Bot avanzado con IA', + 'Analytics completo', + 'API personalizada', + 'Soporte 24/7', + ], + limits: { + reservations: null, + }, + }, +] + +function UsageBar({ used, total, label }) { + const pct = total ? Math.min((used / total) * 100, 100) : 0 + const color = pct >= 90 ? 'bg-danger-500' : pct >= 70 ? 'bg-warning-500' : 'bg-primary-500' + + return ( +
+
+ {label} + = 90 ? 'text-danger-600' : 'text-slate-700')}> + {used} {total ? `/ ${total}` : '/ ∞'} + +
+
+
+
+
+ ) +} + +export default function BillingPage() { + const [planInfo, setPlanInfo] = useState(null) + const [usage, setUsage] = useState(null) + const [loading, setLoading] = useState(true) + const [upgrading, setUpgrading] = useState('') + + useEffect(() => { + async function load() { + try { + const [pRes, uRes] = await Promise.all([ + billingApi.getPlan(), + billingApi.getUsage(), + ]) + setPlanInfo(pRes.data) + setUsage(uRes.data) + } catch { + setPlanInfo({ plan: 'free', status: 'trial' }) + setUsage({ reservations_used: 12, reservations_limit: 50 }) + } finally { + setLoading(false) + } + } + load() + }, []) + + async function handleUpgrade(planId) { + if (planId === planInfo?.plan) return + setUpgrading(planId) + try { + await billingApi.upgrade(planId) + setPlanInfo((p) => ({ ...p, plan: planId })) + } finally { + setUpgrading('') + } + } + + const currentPlan = PLANS.find((p) => p.id === planInfo?.plan) ?? PLANS[0] + + return ( +
+ {/* Uso actual */} +
+
+
+ +
+
+

+ Plan {currentPlan.name} + {planInfo?.status === 'trial' && ( + Período de prueba + )} +

+

{currentPlan.description}

+
+
+ + {loading ? ( +
+ ) : ( + + )} +
+ + {/* Planes */} +
+

+ Elige tu plan +

+
+ {PLANS.map((plan, i) => { + const Icon = plan.icon + const isCurrent = planInfo?.plan === plan.id + const isUpgrading = upgrading === plan.id + + return ( +
+ {plan.popular && ( +
+ Más popular +
+ )} + +
+
+ +
+

{plan.name}

+
+ {plan.price} + {plan.period} +
+

{plan.description}

+
+ +
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ + +
+ ) + })} +
+
+ + {/* Features highlight */} +
+ {[ + { icon: MessageCircle, title: 'Bot 24/7', desc: 'Tu asistente responde reservas a cualquier hora, sin que tengas que estar presente.' }, + { icon: CalendarDays, title: 'Gestión automática', desc: 'Los horarios se actualizan en tiempo real según las reservas recibidas.' }, + { icon: Bot, title: 'IA conversacional', desc: 'Claude entiende el lenguaje natural de tus clientes y los guía en el proceso.' }, + ].map((f) => ( +
+
+ +
+
+

{f.title}

+

{f.desc}

+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/CalendarPage.jsx b/frontend/src/pages/CalendarPage.jsx new file mode 100644 index 0000000..cc525e2 --- /dev/null +++ b/frontend/src/pages/CalendarPage.jsx @@ -0,0 +1,224 @@ +import { useState, useEffect } from 'react' +import { format, addDays, startOfWeek, isSameDay, isToday, parseISO } from 'date-fns' +import { es } from 'date-fns/locale' +import { ChevronLeft, ChevronRight, Lock, Unlock, Clock } from 'lucide-react' +import { calendarApi } from '@/lib/api' +import { cn } from '@/lib/utils' + +const WEEK_DAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'] + +export default function CalendarPage() { + const [currentDate, setCurrentDate] = useState(new Date()) + const [selectedDate, setSelectedDate] = useState(new Date()) + const [slots, setSlots] = useState([]) + const [blockedDates, setBlockedDates] = useState([]) + const [loadingSlots, setLoadingSlots] = useState(false) + const [togglingDate, setTogglingDate] = useState(false) + + const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }) + const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) + + const selectedStr = format(selectedDate, 'yyyy-MM-dd') + const isBlocked = blockedDates.includes(selectedStr) + + useEffect(() => { + async function loadSlots() { + setLoadingSlots(true) + try { + const { data } = await calendarApi.getAvailability(selectedStr) + setSlots(data) + } catch { + setSlots([]) + } finally { + setLoadingSlots(false) + } + } + loadSlots() + }, [selectedStr]) + + async function toggleBlockDate() { + setTogglingDate(true) + try { + if (isBlocked) { + await calendarApi.unblockDate(selectedStr) + setBlockedDates((d) => d.filter((x) => x !== selectedStr)) + } else { + await calendarApi.blockDate(selectedStr) + setBlockedDates((d) => [...d, selectedStr]) + } + } finally { + setTogglingDate(false) + } + } + + return ( +
+
+ {/* Calendario semanal */} +
+ {/* Nav semana */} +
+

+ {format(weekStart, "MMMM yyyy", { locale: es })} +

+
+ + + +
+
+ + {/* Días */} +
+ {WEEK_DAYS.map((d) => ( +
{d}
+ ))} + {weekDays.map((day) => { + const dayStr = format(day, 'yyyy-MM-dd') + const selected = isSameDay(day, selectedDate) + const today = isToday(day) + const blocked = blockedDates.includes(dayStr) + + return ( + + ) + })} +
+
+ + {/* Panel del día seleccionado */} +
+
+

Día seleccionado

+

+ {format(selectedDate, "EEEE d 'de' MMMM", { locale: es })} +

+
+ + {/* Bloquear/Desbloquear */} +
+
+

{isBlocked ? 'Día bloqueado' : 'Día disponible'}

+

+ {isBlocked + ? 'El bot no acepta reservas este día' + : 'El bot acepta reservas según tu horario'} +

+
+ +
+ + {/* Slots */} +
+
+ +

+ Horarios disponibles +

+
+ + {loadingSlots ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : isBlocked ? ( +
+ +

Día bloqueado

+
+ ) : slots.length === 0 ? ( +
+ +

Sin horarios disponibles

+

Verifica tu configuración de horarios

+
+ ) : ( +
+ {slots.map((slot) => ( +
0 + ? 'bg-primary-50 border-primary-100 text-primary-700' + : 'bg-slate-50 border-border text-slate-400', + )} + > + {slot.time?.slice(0, 5)} + + {slot.available > 0 + ? `${slot.available} libre${slot.available > 1 ? 's' : ''}` + : 'Lleno'} + +
+ ))} +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/ConfigPage.jsx b/frontend/src/pages/ConfigPage.jsx new file mode 100644 index 0000000..de54dbb --- /dev/null +++ b/frontend/src/pages/ConfigPage.jsx @@ -0,0 +1,326 @@ +import { useState, useEffect } from 'react' +import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, ExternalLink } from 'lucide-react' +import { businessApi, whatsappApi } from '@/lib/api' +import { DAYS_FULL, cn } from '@/lib/utils' + +const TONE_OPTIONS = [ + { value: 'formal', label: 'Formal', desc: 'Tono profesional y respetuoso' }, + { value: 'casual', label: 'Casual', desc: 'Tono amigable y cercano' }, +] + +function Section({ title, description, children }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {children} +
+ ) +} + +export default function ConfigPage() { + const [business, setBusiness] = useState(null) + const [config, setConfig] = useState(null) + const [waStatus, setWaStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + async function load() { + try { + const [bRes, cRes, wRes] = await Promise.all([ + businessApi.getMe(), + businessApi.getConfig(), + whatsappApi.getStatus().catch(() => ({ data: null })), + ]) + setBusiness(bRes.data) + setConfig(cRes.data) + setWaStatus(wRes.data) + } finally { + setLoading(false) + } + } + load() + }, []) + + function updateConfig(key, val) { + setConfig((c) => ({ ...c, [key]: val })) + setSaved(false) + setError('') + } + + function toggleDay(day) { + const days = config.open_days ?? [] + const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort() + updateConfig('open_days', next) + } + + async function handleSave() { + setSaving(true) + setError('') + try { + await Promise.all([ + businessApi.updateMe({ name: business.name, timezone: business.timezone }), + businessApi.updateConfig({ + open_days: config.open_days, + open_time: config.open_time, + close_time: config.close_time, + slot_duration: config.slot_duration, + max_per_slot: config.max_per_slot, + assistant_name: config.assistant_name, + tone: config.tone, + welcome_message: config.welcome_message, + }), + ]) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } catch { + setError('No se pudo guardar la configuración. Intenta de nuevo.') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) + } + + const isWaConnected = Boolean(waStatus?.whatsapp_phone_number_id) + + return ( +
+ {/* WhatsApp status */} +
+
+ {isWaConnected + ? + : + } +
+
+

+ WhatsApp {isWaConnected ? 'conectado' : 'no conectado'} +

+

+ {isWaConnected + ? `Número: ${waStatus.whatsapp_phone_number_id}` + : 'Configura tu número de WhatsApp Business para recibir reservas.'} +

+
+
+ + {/* Negocio */} +
+
+ + setBusiness((b) => ({ ...b, name: e.target.value }))} + className="field-input" + placeholder="Nombre de tu negocio" + /> + Nombre que ven tus clientes en los mensajes del bot. +
+
+ + {/* Horarios */} +
+ {/* Días de la semana */} +
+ +
+ {DAYS_FULL.map((day, idx) => { + const open = config?.open_days?.includes(idx) + return ( + + ) + })} +
+ El bot solo aceptará reservas en los días marcados. +
+ + {/* Horario */} +
+
+ + updateConfig('open_time', e.target.value)} + className="field-input" + /> + Primera hora disponible para reservas. +
+
+ + updateConfig('close_time', e.target.value)} + className="field-input" + /> + Última hora en que se generan turnos. +
+
+ + {/* Duración y máximo */} +
+
+ + updateConfig('slot_duration', Number(e.target.value))} + className="field-input" + /> + Tiempo que dura cada cita o reserva. +
+
+ + updateConfig('max_per_slot', Number(e.target.value))} + className="field-input" + /> + Cuántas reservas se aceptan en el mismo horario. +
+
+
+ + {/* Bot */} +
+
+ + updateConfig('assistant_name', e.target.value)} + className="field-input" + placeholder="Ej: Hermes, María, Asistente…" + /> + + Nombre con el que el bot se presentará: "Hola, soy {config?.assistant_name || 'Hermes'}…" + +
+ +
+ +
+ {TONE_OPTIONS.map((t) => { + const active = config?.tone === t.value + return ( + + ) + })} +
+
+ +
+ +