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

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

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

View File

View File

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