caso de estudio · 2026

Plataforma de formación / LMS a medida
portaldeestudio.aibymel.site

portaldeestudio.aibymel.site

01

el reto

IAgency, una academia colombiana de formación en IA, llevaba su promoción con tres herramientas prestadas: el curso vivía en Hotmart, la conversación con las alumnas en WhatsApp y la agenda de las sesiones individuales en un Excel. Cada una funcionaba por su cuenta; juntas, no se hablaban. Nadie podía decir, en un momento dado, por dónde iba una alumna: qué había visto del curso, qué había preguntado, cuándo tenía cita. El encargo inicial cabía en una frase: una pantalla de acceso con un enlace al curso alojado en Hotmart. Creció a medida que cada herramienta prestada enseñaba su límite: un contenido que no se puede abrir tanda por tanda, una conversación de trabajo mezclada con los mensajes personales, una cita cobrada a mano. Dos condiciones enmarcaban el resto, y no se movieron: las alumnas entran desde el móvil, y el alojamiento es un compartido de Hostinger, sin contenedor, sin build en el servidor, sin node_modules en el paquete.

02

la solución

Una aplicación web privada, diseñada y desarrollada desde cero, donde el contenido, la mensajería, la agenda con cobro y los avisos viven en el mismo sitio. La alumna ve ahí su curso, sus clases en vivo, las grabaciones de sus sesiones 1:1, sus objetivos y su certificado; el equipo gestiona todo eso desde el mismo portal, sin tocar nunca la base de datos. La decisión estructural es un contrato: un único fichero OpenAPI describe las 116 operaciones de la API, y Orval genera de él dos paquetes que nadie edita a mano: los esquemas Zod con los que el servidor valida la entrada y da forma a la salida, y los hooks de TanStack Query que consume el frontend. La API y la pantalla ya no pueden desincronizarse en silencio: el typecheck cae antes que la usuaria. La segunda regla, escrita en el código, es que el portal es de la clienta: ninguna integración puede tumbarlo. Google, el asistente, el correo, el push y el pago apagan su función, lo dicen por pantalla, y lo demás sigue. Vale para las siete.

03

el resultado

En producción, con alumnas reales. Hotmart, WhatsApp y el Excel de la agenda quedaron fuera: el contenido propio se abre tanda por tanda en fecha fija o «a los X días de empezar tu tanda», la mensajería lleva adjuntos detrás de la sesión, y una sesión con coach se reserva y se paga sin salir de la página. A día de hoy: ~34.000 líneas de TypeScript, 37 tablas, 116 operaciones de API, 123 handlers de Express repartidos en 18 routers y 107 commits en cuatro meses. Lo que me llevo está sobre todo en las averías. Un alojamiento que suspende el buzón por «actividad sospechosa», sin ningún intruso: era el propio portal, un anuncio saliendo como treinta envíos a la vez. Una verificación de marca de Google fallada dos veces porque el robot descargaba una SPA vacía detrás de un login. Un `drizzle-kit push --force` que propuso vaciar la tabla de alumnas. Cada una dejó una regla escrita en algún sitio: en el código, en el package.json, o en el documento de control que se lee antes de tocar nada.

dimensión

el proyecto en cifras

~34.000

líneas de TypeScript: 22.400 en el frontend, 11.800 en la API

37

tablas, más la de sesiones

116

operaciones descritas en el contrato OpenAPI

123

handlers de Express, en 18 routers

107

commits, de mayo a septiembre de 2026

7

integraciones externas, todas opcionales

el encargo

a qué sustituyó el portal

Hotmart

El curso está ahora dentro: módulos, lecciones, progreso por alumna, recursos e hilos de comentarios bajo cada lección. Y sobre todo la liberación programada por tanda (fecha fija o «a los X días de empezar la tuya»), que antes no existía.

WhatsApp

Conversaciones directas alumna ↔ equipo y salas por tanda, con fotos, vídeos y enlaces. El trabajo salió del teléfono personal de las profesoras, y la conversación se quedó donde están las alumnas.

el Excel de la agenda

Cada coach abre sus fechas, la alumna elige su día en hora de Colombia y paga por ePayco sin salir de la página. La reserva se le retiene treinta minutos y después se libera sola.

funcionalidad

tres superficies de trabajo

el portal de la alumna

«Mi portal»
  • Seis secciones en un menú lateral, y se recuerda la última abierta: sesiones 1:1, clases en vivo, contenido IAgency, certificados, objetivos y proyectos.
  • Las sesiones individuales van embebidas desde Google Drive, con sus notas y sus archivos complementarios.
  • Los objetivos se leen en tres niveles (los suyos, los de su tanda y los de toda la academia) y la etiqueta dice a quién va cada uno. La alumna marca, el equipo valida.
  • En proyectos, la alumna sube el enlace a su trabajo (Drive, Figma, Canva, una web) con adjuntos, y el equipo le responde en un hilo.

el panel del equipo

«Administración»
  • Diez secciones: alumnas, equipo, tandas, clases en vivo, anuncios, cursos, comentarios, objetivos, asistente de IA y la propia ficha de coach.
  • La tanda es la unidad de todo: quién ve qué contenido, a quién va dirigida una clase, qué tanda tiene sesión gratis con tal coach.
  • Tres roles. La profesora comparte panel con el admin salvo tres gestos reservados: dar de alta o borrar alumnas, crear o borrar cursos y publicar anuncios. El servidor lo exige; el frontend solo evita enseñar botones que darían un 403.
  • Bandeja unificada de comentarios, con lecciones y clases mezcladas en modo hilo, para responder sin ir sección por sección.

la mensajería

«Conversaciones»
  • Conversaciones directas y salas por tanda: los miembros de una sala se derivan del grupo en vez de materializarse, para que un alta o una baja no deje nada que arreglar.
  • Los adjuntos se comprueban por sus bytes mágicos contra el MIME declarado: un .png que en realidad lleva HTML se borra y se rechaza.
  • La fila nace con message_id en NULL y se reclama al enviar, así que el servidor nunca se fía de una ruta que venga del cliente; lo que queda sin reclamar se barre a las 24 h.
  • El refresco en vivo va sin WebSockets: el service worker reenvía el push a la pestaña abierta y solo se invalidan las claves de la mensajería.

funcionalidad

las dos caras del portal

La franja de arriba es la vista previa de solo lectura del equipo: un admin ve el portal exactamente como lo ve una alumna concreta, sin poder marcar ni entregar en su nombre. El mismo builder de dashboard sirve a los dos, para no escribir dos veces las reglas de visibilidad. Nombres y correos difuminados.
El aviso «el correo funciona», con su botón de prueba, nació de la suspensión del buzón: desde entonces el estado del remitente se lee arriba del panel y se comprueba sin abrir los logs.

mensajería

lo que sustituyó a whatsapp

La lista de conversaciones: a la izquierda los hilos directos con el equipo, y la sala de cada tanda. Los miembros de una sala se derivan del grupo en vez de materializarse, así que un alta o una baja no deja nada que arreglar. Nombres difuminados.
Las descargas van por GET /api/uploads/:name detrás de la sesión, con soporte de rangos para que un vídeo se pueda adelantar sin descargarlo entero. Mensajes y nombres difuminados.

stack

lo que hay debajo

Frontend

React 19.1.0 fijado en el catálogo · Vite 7 · Tailwind CSS 4 · ~28 primitivas de Radix con envoltorio estilo shadcn (52 componentes) · Framer Motion · wouter para 9 rutas · TanStack Query sobre hooks generados · PWA instalable, con manifiesto y service worker

API

Express 5 sobre Node.js 24, empaquetado con esbuild a un único fichero con sourcemaps · express-session con store en MySQL, cookie httpOnly y secure, 7 días · Zod generado del contrato a la entrada y a la salida · pino · 18 routers montados bajo /api

Datos

MariaDB/MySQL con Drizzle ORM sobre mysql2, migrado del Postgres original en julio de 2026 · 37 tablas en 12 ficheros por dominio · sin migraciones: push en desarrollo y DDL revisado a mano en producción · claves primarias con int().autoincrement(), nunca serial

Integraciones

Google OAuth, flujo code escrito a mano y sin dependencias añadidas · Google Calendar y Meet · Groq (openai/gpt-oss-120b, endpoint compatible con OpenAI) · ePayco (Davivienda) · SMTP de Hostinger · Web Push VAPID · AES-256-GCM para el refresh_token de Google de cada coach

Monorepo

pnpm workspaces, con npm y yarn rechazados por un hook preinstall · versiones fijadas en el bloque catalog: de pnpm-workspace.yaml · TypeScript 5.9 con project references · minimumReleaseAge en 1.440 minutos: ningún paquete de npm publicado hace menos de 24 h entra en el proyecto

arquitectura

el contrato primero

La decisión estructural del proyecto: no hay un tipo en el servidor y otro en el cliente que alguien mantiene sincronizados a mano. Hay un contrato, y dos paquetes que bajan de él.

↕ orval: los dos directorios generated/ no se tocan nunca a mano

Contrato

lib/api-spec/openapi.yaml

OpenAPI 3.1: 5.823 líneas, 116 operaciones, 143 esquemas. La única fuente de lo que la API acepta y de lo que devuelve.

Generado

lib/api-zod

Esquemas Zod y tipos de TypeScript. La API hace Schema.safeParse(req.body) a la entrada y Response.parse(...) a la salida: el servidor no puede devolver algo que el contrato no describa.

lib/api-client-react

Hooks de TanStack Query con su propio envoltorio de fetch, base /api. El frontend no escribe ni un fetch a mano; la caché y la invalidación van por clave.

Fuera del contrato

y a propósito

Los redirects de OAuth, la descarga de adjuntos, el PDF del certificado y la confirmación servidor-a-servidor de ePayco. No son JSON: no pintan nada en el YAML, y meterlos a la fuerza habría descrito el sistema torcido.

Cambiar la API es siempre el mismo gesto: editar el YAML, relanzar el codegen y después tocar la ruta de Express y la pantalla. Frontend y backend no pueden divergir en silencio: el typecheck lo grita antes que la usuaria.

pantallas

el curso, del módulo a la lección

El curso entero, fase por fase. El avance se cuenta sobre las lecciones que esa alumna ve: tres niveles de acceso que se acumulan (curso, módulo, lección) y ninguna fila significa «visible para todas».
La lección se abre donde está en vez de llevarte a otra parte: el vídeo, sus recursos y su hilo de comentarios, sin perder el hilo del módulo.
Las clases en vivo de su tanda, todas en hora de Colombia (COT, UTC−5) y dicho así de claro. Después, la misma fila lleva la grabación y el material. Nombres de las coaches difuminados.
La etiqueta dice a quién va cada objetivo: «Para ti» es individual, «IAgency» es de todas. La alumna marca y el equipo valida, y esa validación cuenta, porque de ella depende el certificado.

cobro

reservar, y pagar

Mientras un coach no publica su ficha, no aparece en ninguna lista. La primera sesión gratis se configura coach por coach, y se puede acotar a ciertas tandas.
El calendario va en hora de Colombia y el precio en pesos, con un interruptor informativo a dólares (tasa cacheada 24 h). En esta captura el coach todavía no ha abierto fechas, y la pantalla lo dice en vez de ofrecer un calendario vacío. Quien decide que una sesión está pagada no es el navegador: es el aviso servidor-a-servidor de ePayco, y solo vale si la firma SHA-256 cuadra, si el importe es exactamente el de la reserva y si la propia ePayco confirma la referencia cuando se le vuelve a preguntar.

asistente

una ia que solo responde con los datos de quien pregunta

Una pregunta real y su respuesta. El contexto se relee de la base en cada consulta (sus cursos, las clases de su tanda, sus sesiones, objetivos, certificados, anuncios recientes y reservas) con las mismas reglas de visibilidad que el portal, y nunca con datos de otra alumna. De ahí que salgan las fechas en hora de Colombia y el enlace de su reunión. Nombres y enlaces difuminados.
El modelo, el tope diario por alumna (30 por defecto, contado en hora de Colombia; el equipo no tiene tope) y el saludo se configuran aquí, sin tocar código. El admin también lo alimenta con documentos: se extrae el texto (PDF con unpdf, DOCX con mammoth), se trocea en fragmentos de unos mil caracteres, y en cada pregunta un ranking BM25 se queda solo con los pasajes útiles, porque el plan gratuito de Groq corta hacia los 8.000 tokens por minuto, así que mandarlo todo no era opción. Del archivo solo se guarda el texto.

entrega

aguantar en un alojamiento compartido

Sin contenedor, sin build en el servidor y sin node_modules en el paquete: el panel de Hostinger solo instala mysql2 y express-mysql-session. Todo lo demás de la entrega está escrito para vivir con esa condición en vez de contra ella.

  1. 01

    Un solo fichero, construido aquí

    esbuild empaqueta la API en un único .mjs con sourcemaps. El zip de entrega lleva el servidor ya compilado y el frontend estático, nada más. En la máquina de producción no se construye nada, así que ahí no puede fallar ninguna construcción.

  2. 02

    El esquema antes que el zip, siempre

    Los cambios de esquema salen como SQL listo para pegar en phpMyAdmin (deploy/schema-*.sql), y van antes del código, nunca después. El orden inverso deja una API preguntando por columnas que todavía no existen.

  3. 03

    El index.html leído una sola vez al arrancar

    Hostinger despliega en carpetas versionadas y borra la anterior. Con sendFile, todas las rutas acababan en 404 después de una entrega: la ruta resuelta al arrancar apuntaba a una carpeta que ya no estaba. Así que el fichero se lee a memoria una vez, al encender.

  4. 04

    UPLOADS_DIR fuera de la carpeta de despliegue

    Misma causa, consecuencia más cara: una ruta relativa se llevaba por delante las fotos que subían las alumnas en cada entrega. La variable apunta ahora fuera, y el aviso está escrito en el documento de control.

  5. 05

    Cada entrega lleva su documento, y su vuelta atrás

    deploy/INSTRUCCIONES.md y deploy/QUE-SUBIR.md dicen qué sube, en qué orden, qué variables hacen falta, qué cambia de cara a la usuaria y qué se comprobó en local. El paquete anterior se conserva con fecha (portal-deploy-2026-09-11-anterior.zip) para que volver atrás sea subir un fichero, no reconstruirlo.

ingeniería

cinco averías reales, y la regla que dejó cada una

Pasaron en producción, en el alojamiento de verdad y con alumnas de verdad. Es la parte del proyecto de la que más he aprendido, así que la documento con el síntoma tal como se presentó.

el alojamiento suspendió el buzón

correo
síntoma
Hostinger suspendió la cuenta de correo por «actividad sospechosa» y borró su contenido. Los avisos por correo cayeron de golpe.
causa
No había ningún intruso: era el portal. Un anuncio a una tanda salía como unos treinta envíos simultáneos, cada mensaje del chat disparaba el suyo, y con el buzón ya cerrado el servidor seguía reintentando, lo que se parecía todavía más a un envío masivo.
solución
lib/mail-policy.ts: ritmo sostenible por hora, tope diario global y por destinataria, descanso entre los avisos charlatanes (chat y comentarios) a la misma persona, lista de direcciones que rebotan y un cortafuegos que para en seco cuando el fallo es del remitente. Todo en memoria a propósito: son medidas de caudal, no datos del portal.

google no conseguía verificar el sitio

posicionamiento
síntoma
La verificación de marca de Google falló dos veces seguidas, sin que nada estuviera roto en el portal.
causa
El robot no ejecuta la SPA. Descargaba una página vacía detrás de un login, y la verificación dependía justo de lo que pudiera leer.
solución
Una página pública en HTML plano (public/inicio/index.html), escrita sin una línea de JavaScript, a propósito. Explica qué es el portal, qué hay dentro y cómo se tratan los datos: legible para el robot y para quien no tiene cuenta.

push --force propuso vaciar la tabla de alumnas

datos
síntoma
En un empujón de esquema, drizzle-kit propuso hacer TRUNCATE de la tabla students.
causa
En MySQL, drizzle-kit push --force acepta automáticamente el TRUNCATE de las tablas referenciadas por claves foráneas nuevas. La bandera que sirve para no ser interrumpido es exactamente la que dice que sí a la pregunta que había que leer.
solución
El comando está prohibido y además bloqueado en el package.json: sale con código 1. El esquema se empuja en interactivo leyendo cada sentencia propuesta, o la tabla se crea a mano con DDL que case con Drizzle. La prohibición es también una de las líneas rojas del documento de control.

la tabla de sesiones dejó de crearse, sin decir nada

build
síntoma
Tras empaquetar, las sesiones dejaron de aguantar. Ningún error, ningún rastro: simplemente nadie se quedaba dentro.
causa
express-mysql-session lee su schema.sql desde su propia carpeta de paquete, en tiempo de ejecución. Bundleado con esbuild, el fichero ya no estaba donde la librería lo buscaba, y la creación de la tabla fallaba en silencio.
solución
El paquete se queda en la lista external de esbuild, y el motivo está escrito al lado; si no, la siguiente persona que optimice el bundle lo vuelve a empaquetar de buena fe.

el campo de fecha nativo no entregaba nada en el móvil de la clienta

móvil
síntoma
En su teléfono, elegir fecha u hora parecía funcionar, pero el formulario salía con el campo vacío.
causa
El input nativo de fecha y hora no devolvía su valor en ese dispositivo. En los nuestros iba bien, y eso es justo lo que hace que este tipo de avería tarde en verse.
solución
Desplegables propios (date-field.tsx, time-field.tsx) en lugar de los inputs nativos. Desde entonces cada pantalla se comprueba a 375 px antes de darse por terminada: las alumnas entran por el móvil, así que ahí es donde la página tiene que estar bien primero.

pantallas

la puerta de entrada, y el móvil

Google como vía principal y el código como respaldo para quien no tiene cuenta. La tabla de alumnas hace de lista blanca: un correo que no esté dado de alta no entra, ni con un Google válido.
La página en HTML plano que nació de las dos verificaciones fallidas. Sirve además como página de confianza: el bloque verde explica qué hace el portal con el Google Calendar de un coach: lee las horas ya ocupadas, nunca el contenido, ni el título, ni los invitados.
Alta, tanda, regenerar el código de acceso y vista previa de su portal. No hay registro abierto: alguien del equipo crea la cuenta, y esa es la única puerta. Nombres y correos difuminados.
Las secciones se apilan y se numeran en vez de apretarse en un menú lateral; el horario mantiene la fecha, la hora de Colombia y el botón de entrar; la lista de conversaciones ocupa el ancho entero. En iPhone, añadir el portal a la pantalla de inicio es el único camino para recibir notificaciones push, y así está documentado para las alumnas.

criterio

lo que me llevo

una integración caída no puede tumbar el portal

Las siete degradan sin romper: Google responde 503 y queda el código de acceso, el asistente avisa de que está apagado, ePayco deja reservar y retiene la fecha explicándolo, el correo deja la campana y el push. El portal es de la clienta: una clave caducada no puede dejar a nadie fuera.

el navegador no decide qué está pagado

Una sesión solo queda pagada cuando ePayco lo confirma servidor a servidor, con la firma correcta, el importe exacto y una repregunta a su API. Es la misma regla que con los adjuntos: el servidor nunca se fía de lo que el cliente le cuenta sobre su propio estado.

el certificado exige las dos cosas, no una u otra

Todas las lecciones que esa alumna ve, completadas, y todos sus objetivos validados por el equipo; marcarlos ella no basta, porque el documento afirma que alguien lo comprobó. El código de verificación va en un alfabeto sin 0/O ni 1/I: se lee en voz alta.

el documento de control describe la cicatriz, no el sistema

El proyecto tiene un documento de control que se lee antes de tocar nada, y no describe el sistema: lleva las líneas rojas, y cada una existe porque algo costó caro una vez. «Esto ya intentó vaciar students» se respeta; «no usar --force» se esquiva convenciéndose de que tu caso es distinto.

Y una honestidad que el proyecto obliga a añadir: el software es propietario y el repositorio privado. Las capturas son de la producción del 17 de septiembre de 2026, con nombres, correos, códigos de acceso y mensajes de las alumnas difuminados.

Contrato OpenAPI como fuente de la verdad Asistente de IA con contexto propio Mensajería con adjuntos Pasarela de pago Certificados en PDF verificables Instalable (PWA)
tecnologías

React 19·TypeScript·Vite·Tailwind CSS 4·TanStack Query·Express 5·Node.js 24·MySQL/MariaDB·Drizzle ORM·OpenAPI + Orval·Zod·pnpm workspaces·esbuild·Groq / LLM·ePayco·Google OAuth + Calendar·Web Push·PWA

¿un proyecto
parecido?

hablemos