Inmersys VRT VRL API — Documentación Técnica Completa
Tabla de Contenidos
- Descripción General
- Estructura del Proyecto
- Dependencias
- Variables de Entorno
- Instalación y Ejecución
- Arquitectura
- Base de Datos
- Modelos Mongoose
- Endpoints de la API
- Middlewares y Validaciones
- Helpers y Utilidades
- Autenticación y Seguridad
- Agregaciones MongoDB
- Integraciones Externas
- Soporte Multi-Base de Datos
- Testing
- CI/CD y Despliegue
- Notas Técnicas y Observaciones
1. Descripción General
inmersys-vrt-vrl-api es el backend REST API para la plataforma de capacitación y aprendizaje en realidad virtual de Inmersys. Gestiona dos modalidades:
- VRT (Virtual Reality Training): entrenamiento industrial/corporativo
- VRL (Virtual Reality Learning): aprendizaje académico/educativo
La API administra empresas, proyectos, grupos de aprendizaje, estudiantes y el registro de respuestas/resultados que los usuarios envían desde los headsets de realidad virtual (principalmente Meta Quest/Oculus). Soporta integraciones con la plataforma HBSA Engage y con una base de datos secundaria denominada Simi.
Stack tecnológico:
| Capa | Tecnología |
|---|---|
| Runtime | Node.js 16.19.0 |
| Framework | Express 4.18.1 |
| Base de datos | MongoDB (Mongoose 6.4.4) |
| Autenticación | JWT (jsonwebtoken 8.5.1) |
| Validación | express-validator 6.14.2 |
| Nodemailer 6.8.0 | |
| HTTP Client | Axios 1.6.7 |
| Proceso prod. | PM2 |
2. Estructura del Proyecto
inmersys-vrt-vrl-api/
├── src/
│ ├── app.js # Punto de entrada
│ ├── server.js # Clase Server (Express)
│ ├── config/
│ │ ├── database.js # Conexión MongoDB principal
│ │ └── DatabaseClientSimi.js # Conexión MongoDB secundaria (Singleton)
│ ├── controllers/
│ │ ├── admin.js # CRUD y auth de administradores
│ │ ├── auth.js # Login, restauración de contraseña, tokens
│ │ ├── enterprise.js # CRUD empresas
│ │ ├── project.js # CRUD proyectos
│ │ ├── learningGroup.js # CRUD grupos de aprendizaje
│ │ ├── student.js # CRUD estudiantes, login Oculus
│ │ ├── learningAnswer.js # Registro respuestas VRL
│ │ ├── trainigAnswer.js # Registro respuestas VRT
│ │ ├── projectTraining.js # Datos completos de entrenamiento
│ │ └── thirdPartyControllers.js # Integración HBSA Engage
│ ├── routes/
│ │ ├── index.js # Exportación de todas las rutas
│ │ ├── auth.js
│ │ ├── admin.js
│ │ ├── enterprise.js
│ │ ├── project.js
│ │ ├── learningGroup.js
│ │ ├── student.js
│ │ ├── learningAnswer.js
│ │ ├── trainigAnswer.js
│ │ └── projectTraining.js
│ ├── models/
│ │ ├── index.js # Exportación de modelos
│ │ ├── Admin.js
│ │ ├── Enterprise.js
│ │ ├── Project.js
│ │ ├── LearningGroup.js
│ │ ├── Student.js
│ │ ├── LearningAnswer.js
│ │ ├── TrainigAnswer.js
│ │ └── Schemas/
│ │ ├── LearningAnswerSchema.js
│ │ ├── LearningGrupSchema.js
│ │ └── StudenSchema.js
│ ├── middlewares/
│ │ ├── index.js
│ │ ├── validateJWT.js # Validación de token admin
│ │ ├── validateDataExpress.js # Validación express-validator
│ │ ├── admin-validation.js
│ │ ├── enterprise-validation.js
│ │ ├── project-validation.js
│ │ ├── learningGroup-validation.js
│ │ ├── learningAnswer-validation.js
│ │ └── student-validations.js
│ ├── helpers/
│ │ ├── index.js
│ │ ├── router.js # Definición de rutas base
│ │ ├── response.js # Respuestas HTTP estándar
│ │ ├── bcrypt.js # Hash de contraseñas
│ │ ├── token.js # Generación/verificación JWT
│ │ ├── constanst.js # Constantes (claves JWT)
│ │ ├── db-validations.js # Transformaciones Mongoose
│ │ └── email.js # Envío de emails SMTP
│ ├── aggregates/
│ │ └── learningAnswer.js # Pipeline aggregation MongoDB
│ └── test/
│ ├── controllers/
│ │ ├── admin.test.js
│ │ ├── enterprise.test.js
│ │ └── utils.js
│ └── services/
│ └── enterprise.test.js
├── package.json
├── jest.config.js
├── .envexample
├── .gitignore
├── .gitlab-ci.yml
└── .vscode/
└── settings.json
3. Dependencias
Producción
| Paquete | Versión | Uso |
|---|---|---|
express | ^4.18.1 | Framework web |
mongoose | ^6.4.4 | ODM para MongoDB |
jsonwebtoken | ^8.5.1 | Autenticación JWT |
bcryptjs | ^2.4.3 | Encriptación de contraseñas |
express-validator | ^6.14.2 | Validación declarativa de requests |
cors | ^2.8.5 | CORS middleware |
dotenv | ^16.0.1 | Variables de entorno |
nodemailer | ^6.8.0 | Envío de emails SMTP |
axios | ^1.6.7 | Cliente HTTP para integraciones externas |
@sendgrid/mail | ^7.7.0 | Integración SendGrid (importado pero SMTP activo) |
Desarrollo
| Paquete | Versión | Uso |
|---|---|---|
jest | ^29.3.1 | Framework de testing |
supertest | ^6.3.3 | Testing de endpoints HTTP |
nodemon | ^2.0.20 | Hot-reload en desarrollo |
Scripts disponibles
npm start # Producción: node ./src/app
npm run dev # Desarrollo: nodemon ./src/app
npm test # Tests: jest --detectOpenHandles --watch
4. Variables de Entorno
Crear un archivo .env basado en .envexample:
| Variable | Descripción | Requerida |
|---|---|---|
PORT | Puerto del servidor (ej: 5000) | Sí |
SECRETORPRIVATEKEY | Clave secreta para firma de JWT de admins | Sí |
MONGO_DB_URL | URI de conexión a MongoDB principal | Sí |
DB_CLIENT_SIMI | URI de conexión a MongoDB secundaria (Simi) | Sí |
AUTH_OCULUS_KEY | Clave para firmar/verificar tokens de headsets Oculus | Sí |
PROJECT_SIMI_VR | ObjectId del proyecto Simi VR en BD | Sí |
PROJECT_SIMI_AR | ObjectId del proyecto Simi AR en BD | Sí |
PROJECT_HBSA | ObjectId del proyecto HBSA para integración Engage | Sí |
PROJECT_MERZ | ObjectId del proyecto MERZ | Sí |
MAIL_HOST | Host SMTP para envío de correos | Sí |
MAIL_USER | Usuario SMTP | Sí |
MAIL_PASSWORD | Contraseña SMTP | Sí |
RESPONSE_TOKEN | Token de respuesta genérico | Sí |
TEST_TOKEN | Token para ambiente de pruebas | Solo testing |
5. Instalación y Ejecución
# 1. Clonar repositorio
git clone <repo-url>
cd inmersys-vrt-vrl-api
# 2. Instalar dependencias
npm install
# 3. Configurar variables de entorno
cp .envexample .env
# Editar .env con los valores reales
# 4. Ejecutar en desarrollo
npm run dev
# 5. Ejecutar en producción (directo)
npm start
# 6. Ejecutar en producción con PM2
pm2 start src/app.js --name "app"
pm2 save
pm2 startup
La API levanta en http://localhost:{PORT} y expone un health check en:
GET /api → { "message": "working good!", "version": "0.20.0" }
6. Arquitectura
Inicialización
app.js
└── new Server()
├── initDB() → Conecta MongoDB principal + MongoDB Simi
├── middlewares() → cors(), express.json(), static files
└── routes() → Monta todos los routers con sus middlewares
Clase Server (src/server.js)
Orquesta toda la aplicación Express:
initDB()— conectadatabase.js(principal) yDatabaseClientSimi(secundaria) de forma asíncrona.middlewares()— habilita CORS abierto, parseo JSON y archivos estáticos desde/public.routes()— monta cada router; la mayoría de rutas tienenvalidateJWTcomo primer middleware (excepto/api/authy/api/trainig-answer).listen()— imprime hostname y puerto al iniciar.
Flujo de un request típico
Request HTTP
→ Express
→ CORS
→ express.json()
→ [validateJWT] ← verifica x-token, carga admin en req.body.requestingAdminInstance
→ [validaciones route] ← express-validator + custom validators
→ [validateData] ← corta si hay errores de validación
→ Controller function
→ getBadRequest / getGoodResponse
→ Response HTTP
Rutas base (src/helpers/router.js)
| Constante | Ruta base |
|---|---|
auth | /api/auth |
admin | /api/admin |
enterprise | /api/enterprise |
project | /api/project |
learningGroup | /api/learning-group |
student | /api/student |
learningAnswer | /api/learning-answer |
trainigAnswer | /api/trainig-answer |
projectTrainig | /api/project-trainig |
Nota: El typo
trainig(sin segunda 'n') está en el código fuente en múltiples lugares. Mantener consistencia al agregar nuevas rutas.
7. Base de Datos
BD Principal (MONGO_DB_URL)
Contiene todas las colecciones del sistema. Se conecta via mongoose.connect().
BD Secundaria — Simi (DB_CLIENT_SIMI)
Base de datos separada para los proyectos del cliente "Simi" (Simi VR y Simi AR). Se conecta mediante mongoose.createConnection() usando el patrón Singleton en DatabaseClientSimi.
class DatabaseClientSimi {
constructor() {
if (DatabaseClientSimi.instance) {
return DatabaseClientSimi.instance; // Retorna instancia existente
}
DatabaseClientSimi.instance = this;
this.connectDB(this.schemas.bind(this));
}
schemas() {
// Registra los modelos en la conexión secundaria
this.learningAnswers = this.db.model("LearningAnswer", LearningAnswerSchema);
this.learningGroup = this.db.model("LearningGroup", LearningGroupSchema);
this.Student = this.db.model("Student", StudentSchema);
}
}
¿Cuándo se usa la BD Simi? Cuando el projectId del request coincide con process.env.PROJECT_SIMI_VR o process.env.PROJECT_SIMI_AR. Esta lógica está distribuida en los controladores de learningGroup, student y learningAnswer.
8. Modelos Mongoose
Admin
Colección: admins
{
name: String,
email: { type: String, required: true, unique: true },
password: { type: String, required: true }, // bcrypt hash
isActive: { type: Boolean, default: true, required: true },
enterpriseId: { type: ObjectId, ref: "Enterprise", required: true },
typeUser: { type: String, enum: ["VRT", "VRL", "INMERSYS"] },
createdAt: { type: Date, default: new Date(), required: true },
}
Tipos de usuario:
VRT— Admin de entrenamiento (Training)VRL— Admin de aprendizaje (Learning)INMERSYS— Super-admin con acceso cruzado a todas las empresas
Enterprise
Colección: enterprises
{
name: { type: String, required: true, unique: true },
imgUrl: String, // URL del logo
createdAt: { type: Date, required: true, default: new Date() },
isActive: { type: Boolean, required: true, default: true },
}
Project
Colección: projects
{
name: String,
enterpriseId: { type: ObjectId, ref: "Enterprise", required: true },
createdAt: { type: Date, default: new Date(), required: true },
}
LearningGroup
Colección: learninggroups
{
profesor: {
_id: { type: ObjectId, ref: "Admin", required: true },
name: { type: String, required: true },
},
name: String,
projectId: { type: ObjectId, ref: "Project", required: true },
isActive: { type: Boolean, required: true, default: true },
}
// Índice compuesto único: no puede haber dos grupos con el mismo nombre en el mismo proyecto
learningGroupSchema.index({ projectId: 1, name: 1 }, { unique: true });
Student
Colección: students
{
name: String,
userId: { type: String, required: true }, // ID/matrícula del usuario en el headset
createdAt: { type: Date, default: new Date(), required: true },
learningGroupId: { type: ObjectId, ref: "LearningGroup", required: true },
}
LearningAnswer
Colección: learninganswers
{
learningGroupId: { type: ObjectId, ref: "LearningGroup", required: true },
responses: Schema.Types.Mixed, // Objeto libre con respuestas a preguntas
studentId: { type: ObjectId, ref: "Student", required: true },
createdAt: { type: Date, default: new Date() },
updatedAt: Date,
durationInSeconds: Number,
}
TrainigAnswer
Colección: traiginanswers
{
matricula: { type: String, required: true }, // ID/matrícula del usuario
nameUser: String,
responses: Schema.Types.Mixed,
projectId: { type: ObjectId, ref: "Project", required: true },
durationInSeconds: Number,
createdAt: { type: Date, default: new Date() },
updatedAt: Date,
}
9. Endpoints de la API
Convenciones
- Auth requerida: la mayoría de rutas requieren el header
x-token: <JWT_admin> - Respuesta exitosa:
{ message: "...", status: 0, ...datos }HTTP 200 - Respuesta error:
{ message: "...", status: 0|1, ...datos }HTTP 400/500
9.1 Autenticación — /api/auth
POST /api/auth/login
Login de administrador.
Body:
{
"email": "admin@empresa.com",
"password": "secret123"
}
Respuesta:
{
"message": "ok",
"token": "<JWT>",
"email": "admin@empresa.com"
}
POST /api/auth/email
Solicitar restauración de contraseña. Envía un email con enlace que contiene un token JWT.
Body:
{ "email": "admin@empresa.com" }
POST /api/auth/restore/:token
Restaurar contraseña usando el token recibido por email.
Params: token — JWT de restauración
Body:
{ "newPassword": "nuevaContraseña" }
POST /api/auth/oculus/key/
Login de estudiante desde headset Oculus/Meta Quest.
Headers: y-token: <oculus_jwt>
Body:
{ "userId": "matricula-del-estudiante" }
Respuesta:
{
"token": "<student_JWT>",
"projectId": "...",
"groupId": "..."
}
POST /api/auth/responses/addOne
Guardar respuesta de estudiante desde headset.
Headers: w-token: <student_JWT>
Body:
{
"responses": [...],
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:01:00Z"
}
9.2 Administradores — /api/admin (requiere JWT admin)
POST /api/admin
Crear nuevo administrador.
Body:
{
"name": "Juan Pérez",
"email": "juan@empresa.com",
"password": "contraseña",
"enterpriseId": "<ObjectId>",
"typeUser": "VRL"
}
Validaciones: email no existente, empresa existente, typeUser en enum ["VRT","VRL","INMERSYS"].
GET /api/admin
Obtener información del admin autenticado (desde el token). Respuesta incluye populate de empresa.
9.3 Empresas — /api/enterprise (requiere JWT admin)
POST /api/enterprise
Crear empresa.
Body:
{
"name": "Empresa S.A.",
"imgUrl": "https://..."
}
Validación: nombre único.
PUT /api/enterprise
Editar empresa.
Body:
{
"enterpriseId": "<ObjectId>",
"name": "Nuevo nombre",
"imgUrl": "https://..."
}
DELETE /api/enterprise/:id
Toggle isActive de una empresa (no elimina, solo desactiva/activa).
Params: id — ObjectId de la empresa.
GET /api/enterprise/all
Obtener todas las empresas activas.
9.4 Proyectos — /api/project (requiere JWT admin)
POST /api/project
Crear proyecto.
Body:
{
"name": "Proyecto Capacitación",
"enterpriseId": "<ObjectId>"
}
PUT /api/project
Editar nombre de proyecto.
Body:
{
"projectId": "<ObjectId>",
"name": "Nuevo nombre"
}
GET /api/project/all
Obtener todos los proyectos.
GET /api/project/enterprise/
Obtener proyectos de la empresa del admin autenticado.
GET /api/project/inmersys/enterprise/:enterpriseId
Obtener proyectos de una empresa específica. Solo para admins INMERSYS.
Params: enterpriseId
POST /api/project/completeData/:enterpriseId
Obtener proyectos de una empresa con estadísticas de uso (usuarios, grupos, respuestas).
Params: enterpriseId
Body (opcional):
{
"startDate": "2026-01-01",
"endDate": "2026-06-30"
}
9.5 Grupos de Aprendizaje — /api/learning-group (requiere JWT admin)
POST /api/learning-group
Crear grupo. El profesor del grupo es el admin autenticado.
Body:
{
"name": "Grupo A",
"projectId": "<ObjectId>"
}
Validación: nombre único dentro del mismo proyecto.
PUT /api/learning-group
Actualizar grupo.
Body:
{
"groupId": "<ObjectId>",
"name": "Nuevo nombre",
"projectId": "<ObjectId>"
}
GET /api/learning-group
Obtener todos los grupos del admin autenticado (filtrado por profesor._id).
GET /api/learning-group/project/:projectId
Obtener grupos de un proyecto específico.
Params: projectId
DELETE /api/learning-group/:groupId
Toggle isActive de un grupo.
Params: groupId
9.6 Estudiantes — /api/student (requiere JWT admin)
POST /api/student
Crear estudiante y asignarlo a un grupo.
Body:
{
"name": "María García",
"userId": "matricula-123",
"groupId": "<ObjectId>"
}
Validación: el userId no debe existir ya en el grupo.
PUT /api/student
Editar estudiante.
Body:
{
"studentId": "<ObjectId>",
"name": "Nuevo nombre",
"userId": "nueva-matricula",
"groupId": "<ObjectId>"
}
GET /api/student/all
Obtener todos los estudiantes.
GET /api/student/byGroup/:groupId
Obtener estudiantes de un grupo.
Params: groupId
PUT /api/student/group
Asignar múltiples estudiantes a un grupo.
Body:
{
"groupId": "<ObjectId>",
"userIds": ["<ObjectId>", "<ObjectId>"]
}
POST /api/student/many
Crear múltiples estudiantes en lote.
Body:
{
"students": [
{ "name": "Ana", "userId": "mat-001" },
{ "name": "Luis", "userId": "mat-002" }
],
"learningGroupId": "<ObjectId>"
}
POST /api/student/deleteMany
Eliminar múltiples estudiantes.
Body:
{
"users": ["<ObjectId>", "<ObjectId>"]
}
9.7 Respuestas de Aprendizaje — /api/learning-answer (requiere JWT admin)
POST /api/learning-answer/addMany
Registrar múltiples respuestas de sesiones de aprendizaje.
Body:
{
"responses": [
{
"userId": "matricula-123",
"createdAt": "2026-01-01T10:00:00Z",
"response": { "q1": "A", "q2": "B" },
"durationInSeconds": 300
}
]
}
GET /api/learning-answer
Obtener respuestas con datos de estudiante y grupo (via aggregation).
Query params (opcionales):
projectId— filtrar por proyectouserId— filtrar por estudiante
DELETE /api/learning-answer
Eliminar múltiples respuestas.
Body:
{ "ids": ["<ObjectId>", "<ObjectId>"] }
9.8 Respuestas de Entrenamiento — /api/trainig-answer
Esta ruta no requiere JWT admin. Usa mecanismo propio del dispositivo.
POST /api/trainig-answer/addOne
Registrar una respuesta de entrenamiento.
Body:
{
"responses": [...],
"projectId": "<ObjectId>",
"createdAt": "2026-01-01T10:00:00Z"
}
Efecto secundario: si el proyecto es HBSA, envía los puntos a Engage automáticamente.
POST /api/trainig-answer/addMany
Registrar múltiples respuestas de entrenamiento en lote.
Body:
{
"responses": [...]
}
GET /api/trainig-answer
Obtener respuestas de entrenamiento.
Query params (opcionales):
projectId
9.9 Entrenamiento por Empresa — /api/project-trainig (requiere JWT admin)
POST /api/project-trainig/completeData/:enterpriseId
Obtener datos completos de entrenamiento de una empresa: proyectos, grupos, respuestas y progreso.
Params: enterpriseId
10. Middlewares y Validaciones
validateJWT (src/middlewares/validateJWT.js)
Verifica el header x-token. Decodifica el JWT usando SECRETORPRIVATEKEY, busca el admin en BD, verifica que esté activo e inyecta la instancia en req.body.requestingAdminInstance.
Header: x-token: <JWT>
↓
verifyJWT(token, jwtSecretKey)
↓
getActiveAdminById(uid)
↓
req.body.requestingAdminInstance = admin
↓
next()
validateDataExpress (src/middlewares/validateDataExpress.js)
Ejecuta validationResult(req) de express-validator. Si hay errores retorna HTTP 400. Siempre se coloca al final del array de middlewares en cada ruta.
Validaciones de dominio
| Archivo | Funciones principales |
|---|---|
admin-validation.js | Email único, existencia por ID/email, token de restauración |
enterprise-validation.js | Nombre único, existencia por ID |
project-validation.js | Existencia de proyecto por ID |
learningGroup-validation.js | Nombre único por proyecto, existencia por ID |
learningAnswer-validation.js | Validaciones de respuestas |
student-validations.js | userId único en grupo, existencia por ID, validación masiva de IDs |
11. Helpers y Utilidades
response.js — Respuestas HTTP estándar
// Error HTTP (default 400)
getBadRequest(res, status, message, payload, httpStatus = 400)
// → { message, status, ...payload }
// Éxito HTTP (default 200)
getGoodResponse(res, message, payload, httpStatus = 200)
// → { message, status: 0, ...payload }
bcrypt.js — Contraseñas
encryptPassword(password) // Genera hash bcrypt con salt
verifyPassword(hash, password) // Compara texto plano con hash
token.js — JWT
generateJWT(payload, secret?) // Crea token JWT (default: jwtSecretKey)
verifyToken(token) // Decodifica y verifica firma
constanst.js — Claves
const jwtSecretKey = process.env.SECRETORPRIVATEKEY // Para admins
const jwtOculusKey = process.env.AUTH_OCULUS_KEY // Para headsets
email.js — SMTP
createTransporter() // Crea transporter nodemailer
verifyTransporter(transporter) // Verifica conexión SMTP
sendEmail(transporter, { toEmail, subject, html })
helpers/index.js
eliminateInvalidParams(bodyParams)
// Filtra keys con valor null o undefined
// Se usa antes de $set en MongoDB para evitar sobreescribir campos existentes
db-validations.js
Define jsonSchemaFunction que elimina __v del resultado al serializar documentos Mongoose.
12. Autenticación y Seguridad
Flujo Admin
1. POST /api/auth/login { email, password }
2. Busca admin por email en BD
3. bcrypt.compareSync(password, admin.password)
4. generateJWT({ uid: admin._id }, SECRETORPRIVATEKEY)
5. Retorna token JWT
─────────────────────────────────────────────
6. Requests siguientes: Header x-token: <token>
7. validateJWT decodifica → carga admin en req.body.requestingAdminInstance
Flujo Estudiante (Oculus/Meta Quest)
1. POST /api/auth/oculus/key/ { userId }
Header: y-token: <oculus_token> (firmado con AUTH_OCULUS_KEY)
2. Verifica y-token (autenticación del dispositivo)
3. Busca estudiante por userId en BD principal Y BD Simi
4. generateJWT({ userId, groupId }, AUTH_OCULUS_KEY)
5. Retorna student token con datos del proyecto/grupo
─────────────────────────────────────────────────────
6. El headset envía: Header w-token: <student_token>
7. Controller verifica y usa datos del payload
Restauración de contraseña
1. POST /api/auth/email { email }
2. Valida que el admin exista
3. generateJWT({ email }, SECRETORPRIVATEKEY)
4. Envía email con enlace que contiene el token
5. Usuario hace: POST /api/auth/restore/:token { newPassword }
6. Verifica token → hashea con bcrypt → actualiza en BD
Resumen de headers de autenticación
| Header | Usado por | Secreto usado |
|---|---|---|
x-token | Admins | SECRETORPRIVATEKEY |
y-token | Dispositivos Oculus autorizados | AUTH_OCULUS_KEY |
w-token | Sesión de estudiante en headset | AUTH_OCULUS_KEY |
13. Agregaciones MongoDB
src/aggregates/learningAnswer.js
Pipeline que enriquece las respuestas de aprendizaje con datos de estudiante y grupo:
[
// 1. Join con colección Student
{
$lookup: {
from: "Student",
localField: "studentId",
foreignField: "_id",
as: "student",
},
},
// 2. Join con colección LearningGroup
{
$lookup: {
from: "LearningGroup",
localField: "learningGroupId",
foreignField: "_id",
as: "group",
},
},
// 3. Desenvuelve array student (excluye docs sin estudiante)
{ $unwind: { path: "$student", preserveNullAndEmptyArrays: false } },
// 4. Desenvuelve array group (excluye docs sin grupo)
{ $unwind: { path: "$group", preserveNullAndEmptyArrays: false } },
// 5. Excluye campos innecesarios
{ $project: { "group.profesor": 0, "group.__v": 0 } },
]
Los filtros dinámicos (projectId, userId) se prependen como $match antes de este pipeline en el controlador.
14. Integraciones Externas
HBSA Engage (src/controllers/thirdPartyControllers.js)
Cuando se guarda una respuesta de entrenamiento y el projectId es PROJECT_HBSA, se ejecuta automáticamente el reporte a Engage:
1. pointsWon(payload)
→ Calcula puntos: (respuestas correctas / total respuestas) * 100
2. saveResponseEngage({ idUser, ...payload })
→ Obtiene token de autenticación de Engage API
→ POST https://my.engage.bz/api/v1/hbsa/answers
→ Body: { userId, competencia, puntuacion, ... }
Importante: el token de Engage se obtiene en cada llamada (sin cache). Si la API de Engage falla, el error se loguea pero no impide que la respuesta se guarde en BD local.
15. Soporte Multi-Base de Datos
Los controladores learningGroup, student y learningAnswer implementan selección de BD según el proyecto:
const isSimiProject = (projectId) =>
projectId.equals(process.env.PROJECT_SIMI_VR) ||
projectId.equals(process.env.PROJECT_SIMI_AR);
if (isSimiProject(projectId)) {
const simiDB = new DatabaseClientSimi();
// Opera con: simiDB.learningAnswers / simiDB.learningGroup / simiDB.Student
} else {
// Opera con los modelos Mongoose de la BD principal
}
Casos de uso clave:
- Al buscar un estudiante para login Oculus, se busca en ambas BDs y se consolida el resultado para evitar duplicados.
- Las respuestas de aprendizaje se almacenan en la BD que corresponde al proyecto.
- Los grupos de Simi se crean y consultan en la BD secundaria.
16. Testing
Configuración (jest.config.js)
module.exports = {
collectCoverage: true,
coverageDirectory: "coverage",
coverageProvider: "v8",
};
Archivos de test
| Archivo | Qué prueba |
|---|---|
test/controllers/admin.test.js | Creación de admin: validaciones nombre, email, contraseña |
test/controllers/enterprise.test.js | Endpoints de empresa |
test/services/enterprise.test.js | Capa de servicios de empresa |
test/controllers/utils.js | Helper con instancia de supertest |
Comandos
npm test # Modo watch con coverage
npx jest --no-coverage # Sin coverage
npx jest admin.test.js # Test específico
Los tests usan
TEST_TOKENdel.envpara autenticarse sin pasar por el flujo real de login.
17. CI/CD y Despliegue
Pipeline GitLab CI (.gitlab-ci.yml)
stages:
- deploy
deploy-production:
stage: deploy
environment: production
before_script:
- apt-get install openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY_PROD" | ssh-add -
- ssh-keyscan -t rsa $SERVER_HOST_PROD >> ~/.ssh/known_hosts
script:
- ssh $SERVER_USER_PROD@$SERVER_HOST_PROD "cd /projects/inmersys-vrt-vrl-api && git pull origin main"
- ssh $SERVER_USER_PROD@$SERVER_HOST_PROD "pm2 stop app && pm2 start app && pm2 status"
only:
- main
Variables requeridas en GitLab CI/CD Settings:
| Variable | Descripción |
|---|---|
SSH_PRIVATE_KEY_PROD | Clave privada SSH para el servidor |
SERVER_HOST_PROD | IP o dominio del servidor de producción |
SERVER_USER_PROD | Usuario SSH del servidor |
Flujo de deploy:
- Push a rama
main→ dispara el pipeline automáticamente - SSH al servidor de producción
git pull origin main- Reinicio de la app con PM2
El deploy solo se activa en
main. La ramadevelopno tiene pipeline de CI/CD configurado.
Gestión con PM2 en producción
pm2 start src/app.js --name "app" # Primera vez
pm2 stop app # Detener
pm2 start app # Iniciar
pm2 restart app # Reiniciar
pm2 status # Ver estado
pm2 logs app # Ver logs en tiempo real
pm2 save # Guardar configuración
pm2 startup # Habilitar inicio automático con el OS
18. Notas Técnicas y Observaciones
Typos intencionales en el código
El nombre trainig (sin segunda 'n') aparece en toda la codebase: rutas, archivos, modelos y variables. Esto es el estado actual del código. No corregir sin migrar también los clientes (headsets, frontends) que consumen estas rutas, ya que rompería la compatibilidad.
default: new Date() en schemas (bug potencial)
Varios schemas usan default: new Date(), que se evalúa una sola vez al cargar el módulo Node, no en cada inserción. Todos los documentos creados en el mismo proceso tendrán la misma fecha. La corrección es usar default: Date.now (referencia a función, sin invocar).
responses como Schema.Types.Mixed
Tanto LearningAnswer como TrainigAnswer usan Mixed para el campo responses. Esto da flexibilidad total pero impide validaciones a nivel de schema. El formato real depende de cada módulo VR y proyecto.
Complejidad O(n²) en datos completos
formattProjectByEnterprise() en projectTraining.js tiene complejidad cuadrática al cruzar listas. Para volúmenes grandes se recomienda refactorizar usando Map o mover la lógica a una aggregation de MongoDB.
Error handling faltante en email
En el flujo de restauración de contraseña (auth.js), los errores de sendEmail() no están envueltos en try/catch. Si el SMTP falla, la API retorna un error 500 sin mensaje claro al cliente.
requestingAdminInstance en req.body
El middleware validateJWT inyecta el admin en req.body.requestingAdminInstance. Esto mezcla datos de autenticación con el body del request, lo cual podría causar conflictos si algún endpoint recibiera un campo con ese nombre. Se puede mejorar usando req.auth o req.user.
@sendgrid/mail instalado pero inactivo
El paquete está en dependencies pero el sistema usa Nodemailer SMTP directamente. Si se migra a SendGrid en el futuro, la dependencia ya está disponible.
Resumen General
| Métrica | Valor |
|---|---|
| Endpoints totales | ~35 |
| Modelos Mongoose | 7 |
| Bases de datos | 2 (Principal + Simi) |
| Integraciones externas | 1 (HBSA Engage) |
| Proyectos especiales configurados | 4 (Simi VR, Simi AR, HBSA, MERZ) |
| Versión Node recomendada | 16.19.0 |
| Framework | Express 4.18.1 |
| ODM | Mongoose 6.4.4 |
| Deploy | GitLab CI → SSH → PM2 |