feat: initial commit — HermesMessages SaaS platform
Backend (FastAPI + Python 3.12): - Multi-tenant auth with JWT: login, register, refresh, Meta OAuth - Business & BusinessConfig management - WhatsApp webhook with HMAC signature verification - Bot engine powered by Claude AI - Calendar availability with Redis caching - Reservations CRUD with status management - Dashboard analytics (stats, agenda, peak hours) - Billing & plan management - Admin panel with platform-wide stats - Async bcrypt via asyncio.to_thread - IntegrityError handling for concurrent registration race conditions Frontend (React 18 + Vite + Tailwind CSS): - Multi-step guided registration form with helper text on every field - Login page with show/hide password toggle - Protected routes with AuthContext - Dashboard with stats cards, bar chart, and daily agenda - Reservations list with search, filters, and inline status actions - Calendar with weekly view, slot availability, and date blocking - Config page: business info, schedules, bot personality - Billing page with plan comparison and usage bar Design system: - Bricolage Grotesque + DM Sans typography - Emerald primary palette with semantic color tokens - scale(0.97) button press feedback, ease-out animations - Skeleton loaders, stagger animations, prefers-reduced-motion support - Accessible: aria-labels, visible focus rings, 4.5:1 contrast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/alembic/__init__.py
Normal file
0
backend/alembic/__init__.py
Normal file
51
backend/alembic/env.py
Normal file
51
backend/alembic/env.py
Normal file
@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
|
||||
# Importar todos los modelos para que Alembic los detecte
|
||||
import app.modules.auth.models # noqa
|
||||
import app.modules.business.models # noqa
|
||||
import app.modules.reservations.models # noqa
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
context.configure(
|
||||
url=settings.DATABASE_URL,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online():
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
155
backend/alembic/versions/0001_initial.py
Normal file
155
backend/alembic/versions/0001_initial.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-04-27
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- Enums ---
|
||||
business_status = postgresql.ENUM("trial", "active", "suspended", name="business_status")
|
||||
business_plan = postgresql.ENUM("free", "basic", "pro", name="business_plan")
|
||||
user_role = postgresql.ENUM("owner", "admin", name="user_role")
|
||||
assistant_tone = postgresql.ENUM("formal", "casual", name="assistant_tone")
|
||||
reservation_status = postgresql.ENUM(
|
||||
"pending", "confirmed", "cancelled", "no_show", name="reservation_status"
|
||||
)
|
||||
reservation_source = postgresql.ENUM("whatsapp", "manual", name="reservation_source")
|
||||
|
||||
for enum in [business_status, business_plan, user_role, assistant_tone,
|
||||
reservation_status, reservation_source]:
|
||||
enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# --- businesses ---
|
||||
op.create_table(
|
||||
"businesses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("type", sa.String(), nullable=False),
|
||||
sa.Column("timezone", sa.String(), nullable=False, server_default="UTC"),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("trial", "active", "suspended", name="business_status"),
|
||||
nullable=False,
|
||||
server_default="trial",
|
||||
),
|
||||
sa.Column(
|
||||
"plan",
|
||||
sa.Enum("free", "basic", "pro", name="business_plan"),
|
||||
nullable=False,
|
||||
server_default="free",
|
||||
),
|
||||
sa.Column("meta_business_id", sa.String(), nullable=True),
|
||||
sa.Column("whatsapp_phone_number_id", sa.String(), nullable=True, unique=True),
|
||||
sa.Column("whatsapp_access_token", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.Date(), server_default=sa.func.current_date()),
|
||||
)
|
||||
op.create_index("ix_businesses_id", "businesses", ["id"])
|
||||
|
||||
# --- users ---
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"business_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("email", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("hashed_password", sa.String(), nullable=True),
|
||||
sa.Column("meta_user_id", sa.String(), nullable=True, unique=True),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum("owner", "admin", name="user_role"),
|
||||
nullable=False,
|
||||
server_default="owner",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_users_id", "users", ["id"])
|
||||
op.create_index("ix_users_email", "users", ["email"])
|
||||
|
||||
# --- business_configs ---
|
||||
op.create_table(
|
||||
"business_configs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"business_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
),
|
||||
sa.Column("open_days", postgresql.ARRAY(sa.Integer()), nullable=False, server_default="{}"),
|
||||
sa.Column("open_time", sa.Time(), nullable=False, server_default="09:00"),
|
||||
sa.Column("close_time", sa.Time(), nullable=False, server_default="18:00"),
|
||||
sa.Column("slot_duration", sa.Integer(), nullable=False, server_default="60"),
|
||||
sa.Column("max_per_slot", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("blocked_dates", postgresql.ARRAY(sa.Date()), nullable=False, server_default="{}"),
|
||||
sa.Column("assistant_name", sa.String(), nullable=False, server_default="Hermes"),
|
||||
sa.Column(
|
||||
"tone",
|
||||
sa.Enum("formal", "casual", name="assistant_tone"),
|
||||
nullable=False,
|
||||
server_default="formal",
|
||||
),
|
||||
sa.Column("welcome_message", sa.String(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_business_configs_id", "business_configs", ["id"])
|
||||
|
||||
# --- reservations ---
|
||||
op.create_table(
|
||||
"reservations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"business_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("client_name", sa.String(), nullable=False),
|
||||
sa.Column("client_phone", sa.String(), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("time_start", sa.Time(), nullable=False),
|
||||
sa.Column("time_end", sa.Time(), nullable=False),
|
||||
sa.Column("party_size", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("pending", "confirmed", "cancelled", "no_show", name="reservation_status"),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
),
|
||||
sa.Column(
|
||||
"source",
|
||||
sa.Enum("whatsapp", "manual", name="reservation_source"),
|
||||
nullable=False,
|
||||
server_default="manual",
|
||||
),
|
||||
sa.Column("notes", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.Date(), server_default=sa.func.current_date()),
|
||||
)
|
||||
op.create_index("ix_reservations_id", "reservations", ["id"])
|
||||
op.create_index("ix_reservations_date", "reservations", ["date"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("reservations")
|
||||
op.drop_table("business_configs")
|
||||
op.drop_table("users")
|
||||
op.drop_table("businesses")
|
||||
|
||||
for name in [
|
||||
"reservation_source", "reservation_status", "assistant_tone",
|
||||
"user_role", "business_plan", "business_status",
|
||||
]:
|
||||
postgresql.ENUM(name=name).drop(op.get_bind(), checkfirst=True)
|
||||
Reference in New Issue
Block a user