lightMe – App de salud integral
Situación
El mercado de apps de fitness y nutrición está muy fragmentado: los usuarios hacen malabares por separado con contadores de calorías, rastreadores de actividad, diarios de sueño y programas de coaching de pago. Para el programa de 12 semanas de LightMe destacaban tres problemas centrales. Los datos nutricionales estaban fragmentados y llenos de lagunas: ninguna API cubre de forma fiable todos los alimentos, códigos de barras y creaciones propias, así que las búsquedas fallaban o devolvían macros incompletos. Administradores y coaches no tenían manera de evaluar el progreso de los usuarios (p. ej. porcentaje de pérdida de peso, cumplimiento) por cohortes. Y los flujos de pago entre los niveles Free, Trial, Basic y Premium exigían un sistema a prueba de manipulaciones que controlara las pruebas y procesara los pagos cancelados sin pérdida de datos.
Tarea
El objetivo del MVP (fecha límite: 16 de mayo de 2026) era una app web full-stack integrada sin fisuras y de altísimo rendimiento. A nivel funcional necesitaba un diario de comidas interactivo con escalado de nutrientes en vivo y búsqueda por código de barras con la cámara, un panel visual de peso, medidas, sueño y actividad en periodos libremente elegibles, y un acceso de prueba Premium de 14 días sin tarjeta, estrictamente único por usuario. Los retos técnicos: en Express 5, req.query es un getter de solo lectura, de modo que las conversiones de tipo de las librerías de validación se pierden por defecto y hacen caer las agregaciones de base de datos; los webhooks de Stripe había que endurecerlos frente a condiciones de carrera y entregas duplicadas; y el potente panel de administración no podía ralentizar la carga inicial de la app normal.
Acción
El frontend guarda el token de acceso solo en memoria volátil; cuando expira (15 min), un interceptor de Axios captura el error 401, hace en segundo plano un refresco silencioso a través de la cookie httpOnly (/auth/refresh) y reintenta la petición original sin que el usuario lo note. Al cambiar la contraseña se vacía todo el array refreshTokens[] en MongoDB, invalidando al instante cualquier otra sesión en toda la plataforma. La búsqueda de alimentos pasa por una fachada en cascada en nutritionService.ts —OpenFoodFacts → FatSecret (OAuth 1.0) → USDA FDC → ingredientes propios— hasta obtener resultados. El webhook de Stripe es estricto y ack-first: verificar la firma sobre el raw body, escribir un marcador de idempotencia (un índice único eventId; un error de clave duplicada corta de inmediato con 200 OK), devolver received:true en ~100 ms para evitar reintentos y solo entonces procesar el evento de forma asíncrona, borrando el marcador ante un fallo para que el reintento de Stripe funcione. En el frontend, todo el panel de administración se carga con React.lazy para que los usuarios normales nunca lo descarguen, las páginas monolíticas se dividieron en hooks y paneles enfocados (la página del diario de comidas pasó de 991 a 278 líneas) y el escalado de nutrientes (scaleFood) está centralizado para que escáner, favoritos y búsqueda usen la misma lógica.
Resultado
El enfoque de API en cascada y un patrón de snapshot en las recetas (los valores nutricionales se congelan al añadir un alimento a la receta) mantienen el diario de comidas consistente, incluso si el alimento subyacente se edita o elimina después. La prueba sin tarjeta reduce enormemente la barrera de entrada —sin datos de pago para 14 días de Premium— mientras que un flag persistente hasUsedTrial en el modelo de usuario bloquea el abuso repetido. El panel de administración calcula métricas demográficas y basadas en progreso (p. ej. avgWeightLossPct, medias de nutrición por cohorte), lo que permite al operador ajustar el programa de 12 semanas a partir de datos reales.
Conclusiones clave
Varios escollos de Express/Mongo marcaron el desarrollo. Como req.query es un getter en Express 5, delete y Object.assign fallan en silencio y los valores de Zod convertidos nunca llegaban a los controladores —resuelto redefiniendo por completo el objeto query con Object.defineProperty en un middleware validateQuery. Mongoose convierte automáticamente los IDs de string a ObjectIds en las consultas normales, pero no dentro del $match de una agregación, así que las pipelines devolvían arrays vacíos hasta castear los IDs explícitamente con new mongoose.Types.ObjectId(userId). Los valores opcionales ausentes (pasos, distancia) hacían que sumas semanales enteras mutaran a null hasta aplicar $ifNull de forma consistente. Y Express casaba /recipes/:id antes que /recipes/range, leyendo «range» como un id —corregido registrando siempre las rutas estáticas antes que las dinámicas.