feat: initial commit — HermesMessages SaaS platform

Backend (FastAPI + Python 3.12):
- Multi-tenant auth with JWT: login, register, refresh, Meta OAuth
- Business & BusinessConfig management
- WhatsApp webhook with HMAC signature verification
- Bot engine powered by Claude AI
- Calendar availability with Redis caching
- Reservations CRUD with status management
- Dashboard analytics (stats, agenda, peak hours)
- Billing & plan management
- Admin panel with platform-wide stats
- Async bcrypt via asyncio.to_thread
- IntegrityError handling for concurrent registration race conditions

Frontend (React 18 + Vite + Tailwind CSS):
- Multi-step guided registration form with helper text on every field
- Login page with show/hide password toggle
- Protected routes with AuthContext
- Dashboard with stats cards, bar chart, and daily agenda
- Reservations list with search, filters, and inline status actions
- Calendar with weekly view, slot availability, and date blocking
- Config page: business info, schedules, bot personality
- Billing page with plan comparison and usage bar

Design system:
- Bricolage Grotesque + DM Sans typography
- Emerald primary palette with semantic color tokens
- scale(0.97) button press feedback, ease-out animations
- Skeleton loaders, stagger animations, prefers-reduced-motion support
- Accessible: aria-labels, visible focus rings, 4.5:1 contrast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:49:41 -05:00
commit 798bd14312
95 changed files with 5836 additions and 0 deletions

View File

View File

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

View File

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

View File

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