Files
admin-edu-space/frontend/src/app.js
T

542 lines
22 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const nunjucks = require('nunjucks');
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
try {
require('nunjucks/src/jinja-compat').call(nunjucks);
} catch (e) {
console.warn('Jinja compat loader notice:', e.message);
}
const nunjucksRuntime = require('nunjucks/src/runtime');
const origMemberLookup = nunjucksRuntime.memberLookup;
nunjucksRuntime.memberLookup = function(obj, val) {
if (obj && val === 'items' && Array.isArray(obj.items)) {
return obj.items;
}
if (obj && val === 'get' && typeof obj.get !== 'function') {
return function(key, defaultVal) {
return (obj[key] !== undefined && obj[key] !== null) ? obj[key] : defaultVal;
};
}
const res = origMemberLookup(obj, val);
if (typeof res === 'function' && obj && typeof obj[val] === 'function') {
Object.assign(res, obj[val]);
}
return res;
};
const cookieParser = require('cookie-parser');
const path = require('path');
const axios = require('axios');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
const port = process.env.PORT || 3000;
app.disable('x-powered-by');
app.set('trust proxy', 1);
// Blindaje HTTP con Helmet (compatible con despliegues directos por IP y proxies HTTPS)
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
originAgentCluster: false
}));
// Rate Limiting para protección contra ataques de fuerza bruta y abuso
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
message: 'Demasiados intentos de autenticación. Por favor intente nuevamente en 15 minutos.',
standardHeaders: true,
legacyHeaders: false
});
const generalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 180,
standardHeaders: true,
legacyHeaders: false
});
app.post('/auth/login', authLimiter);
app.post('/auth/moodle', authLimiter);
app.use('/api', generalLimiter);
// Configuración de middlewares
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Configuración de archivos estáticos
app.use(express.static(path.join(__dirname, '../public')));
app.use('/static', express.static(path.join(__dirname, '../public')));
// Configuración de Nunjucks
const env = nunjucks.configure(path.join(__dirname, '../views'), {
autoescape: true,
express: app,
watch: process.env.NODE_ENV !== 'production'
});
// Nunjucks custom filters/helpers
env.addFilter('get', function(obj, key, defaultVal) {
if (!obj) return defaultVal;
return obj[key] !== undefined ? obj[key] : defaultVal;
});
env.addFilter('min', function(arr) {
if (Array.isArray(arr)) return Math.min(...arr);
return arr;
});
env.addFilter('max', function(arr) {
if (Array.isArray(arr)) return Math.max(...arr);
return arr;
});
env.addFilter('round', function(val, precision = 0) {
if (val === undefined || val === null) return 0;
const factor = Math.pow(10, precision);
return Math.round(val * factor) / factor;
});
env.addFilter('format', function(str, ...args) {
if (!str) return '';
let i = 0;
return String(str).replace(/%0?(\d+)?d|%s/g, (match, pad) => {
let val = args[i++];
if (pad && typeof val === 'number') {
return String(val).padStart(parseInt(pad, 10), '0');
}
return val !== undefined ? String(val) : match;
});
});
const formatDateHelper = function(d, fmt) {
if (!d) return '';
if (typeof d === 'string') return d.split('T')[0];
return new Date(d).toISOString().split('T')[0];
};
const formatDateTimeHelper = function(d, fmt) {
if (!d) return '';
if (typeof d === 'string') return d.replace('T', ' ').substring(0, 16);
return new Date(d).toLocaleString();
};
env.addGlobal('format_date', formatDateHelper);
env.addGlobal('format_datetime', formatDateTimeHelper);
env.addFilter('format_date', formatDateHelper);
env.addFilter('format_datetime', formatDateTimeHelper);
env.addFilter('tojson', function(val) {
return JSON.stringify(val !== undefined ? val : null);
});
env.addFilter('sum', function(arr, attribute) {
if (!Array.isArray(arr)) return 0;
return arr.reduce((acc, item) => {
let val = 0;
if (attribute && typeof item === 'object' && item !== null) {
val = item[attribute] || 0;
} else {
val = Number(item) || 0;
}
return acc + Number(val);
}, 0);
});
const translateHelper = function(s) {
return s || '';
};
env.addGlobal('_', translateHelper);
env.addFilter('_', translateHelper);
app.set('view engine', 'html');
// Helper para extraer usuario de cookie si existe
app.use((req, res, next) => {
const token = req.cookies.auth_token;
let user = {
name: 'System',
first_name: 'System',
email: 'admin@edu-space.com',
role: 'admin',
is_authenticated: Boolean(token),
is_admin: () => true,
has_permission: () => true,
has_role: () => true,
can_edit_reservation: () => true,
can_delete_reservation: () => true,
get_institutional_role: () => 'admin',
role_badge_display: { class: 'bg-primary text-white', icon: 'bi-shield-check', label: 'Administrador' }
};
if (token) {
try {
const decoded = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString('utf8'));
const roleStr = (decoded.role || 'Admin').toLowerCase();
user = {
...user,
id: decoded.sub,
name: decoded.name || 'System',
first_name: (decoded.name || 'System').split(' ')[0],
email: decoded.email || 'admin@edu-space.com',
role: roleStr,
is_admin: () => roleStr === 'admin' || decoded.is_admin === true
};
} catch (e) {}
}
let isImpersonating = false;
let impersonatedUser = null;
if (user.is_admin() && req.cookies.impersonate_user_id) {
isImpersonating = true;
const targetId = req.cookies.impersonate_user_id;
try {
if (req.cookies.impersonate_user_data) {
impersonatedUser = JSON.parse(req.cookies.impersonate_user_data);
}
} catch (e) {}
if (!impersonatedUser) {
impersonatedUser = { id: targetId, name: `Usuario #${targetId}`, role: 'usuario' };
}
}
req.user = user;
res.locals.user = user;
res.locals.current_user = user;
res.locals.role = user.role;
res.locals.is_impersonating = isImpersonating;
res.locals.impersonated_user = impersonatedUser;
res.locals.session = { get: (k, d) => d };
res.locals.occupancy_metrics = { get: (k, d) => d };
next();
});
// Rutas Principales
const indexRoutes = require('./routes/index');
const authRoutes = require('./routes/auth');
const dashboardRoutes = require('./routes/dashboard');
const classroomsRoutes = require('./routes/classrooms');
const scheduleRoutes = require('./routes/schedule');
const buildingsRoutes = require('./routes/buildings');
const adminRoutes = require('./routes/admin');
const mySubjectsRoutes = require('./routes/my_subjects');
// Proxy transparente hacia la API REST de Flask para llamadas AJAX frontend
app.use('/api/v1', async (req, res) => {
try {
const token = req.cookies.auth_token;
const targetUrl = `${process.env.FLASK_API_URL || 'http://localhost:5000/api/v1'}${req.url}`;
const headers = { ...req.headers };
delete headers.host;
if (token && !headers.authorization) {
headers.authorization = `Bearer ${token}`;
}
const resp = await axios({
method: req.method,
url: targetUrl,
data: req.body,
params: req.query,
headers,
validateStatus: () => true
});
return res.status(resp.status).json(resp.data);
} catch (err) {
console.error('API v1 proxy error:', err.message);
return res.status(500).json({ error: 'ProxyError', message: 'Error de comunicación con el backend API: ' + err.message });
}
});
app.use('/', indexRoutes);
app.use('/auth', authRoutes);
app.use('/dashboard', dashboardRoutes);
app.use('/classrooms', classroomsRoutes);
app.use('/schedule', scheduleRoutes);
app.use('/buildings', buildingsRoutes);
app.use('/admin', adminRoutes);
app.use('/my_subjects', mySubjectsRoutes);
app.use('/mis-materias', mySubjectsRoutes);
// Redirección de enlaces heredados de Jinja /main/dashboard preservando query params
app.get(['/main/dashboard', '/main'], (req, res) => {
const query = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
res.redirect('/dashboard' + query);
});
// Módulo Predictivo y Optimizador
app.get('/predictive', (req, res) => {
res.render('predictive/index', {
title: 'Dashboard Predictivo - Edu-Space Admin'
});
});
app.get(['/genetic_optimizer', '/genetic_algorithm_web/genetic_optimizer'], (req, res) => {
res.render('genetic_optimizer', {
title: 'Optimizador de Aulas (IA) - Edu-Space Admin'
});
});
// Proxy transparente de peticiones /api a Flask
app.use('/api', async (req, res) => {
// Si es dashboard-stats mock directo
if (req.path === '/dashboard-stats') {
return res.json({
monthly_data: [
{ month: 'Ene', reservations: 120 }, { month: 'Feb', reservations: 180 }, { month: 'Mar', reservations: 250 },
{ month: 'Abr', reservations: 220 }, { month: 'May', reservations: 300 }, { month: 'Jun', reservations: 310 },
{ month: 'Jul', reservations: 200 }, { month: 'Ago', reservations: 350 }, { month: 'Sep', reservations: 420 },
{ month: 'Oct', reservations: 480 }, { month: 'Nov', reservations: 520 }, { month: 'Dic', reservations: 210 }
]
});
}
try {
const token = req.cookies.auth_token;
const headers = { ...req.headers };
delete headers.host;
delete headers.connection;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (req.cookies.impersonate_user_id) {
headers['X-Impersonate-User'] = req.cookies.impersonate_user_id;
}
const flaskUrl = `http://127.0.0.1:5000/api${req.url}`;
const response = await axios({
method: req.method,
url: flaskUrl,
data: req.body,
headers,
validateStatus: () => true
});
res.status(response.status).json(response.data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Wildcard route to catch unmigrated menu links and attempt to render the Nunjucks templates
app.get('/:blueprint/:route', (req, res) => {
const { blueprint, route } = req.params;
// Redirects for specific known route names
if (blueprint === 'main' && route === 'dashboard') {
return res.redirect('/dashboard');
}
if (blueprint === 'classrooms' && (route === 'list_classrooms' || route === 'list')) {
return res.redirect('/classrooms/list_classrooms');
}
if (blueprint === 'classrooms' && (route === 'add_classroom' || route === 'add')) {
return res.redirect('/classrooms/add_classroom');
}
if (blueprint === 'buildings' && (route === 'list_buildings' || route === 'list')) {
return res.redirect('/buildings/list_buildings');
}
if (blueprint === 'buildings' && (route === 'add_building' || route === 'add')) {
return res.redirect('/buildings/add_building');
}
if (blueprint === 'schedule' && (route === 'calendar_view' || route === 'calendar')) {
return res.redirect('/schedule/calendar_view');
}
if (blueprint === 'schedule' && (route === 'add_reservation' || route === 'add')) {
return res.redirect('/schedule/add_reservation');
}
if (blueprint === 'schedule' && (route === 'today_schedule' || route === 'today')) {
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
return res.redirect('/schedule/today' + query);
}
if (blueprint === 'schedule' && (route === 'list_reservations' || route === 'list')) {
return res.redirect('/schedule/list_reservations');
}
if (blueprint === 'admin' && (route === 'google_sheets_import' || route === 'import')) {
return res.redirect('/admin/google_sheets_import');
}
if (blueprint === 'admin' && (route === 'occupancy_metrics' || route === 'occupancy-metrics')) {
return res.redirect('/admin/occupancy_metrics');
}
if (blueprint === 'admin' && (route === 'users_list' || route === 'user_list' || route === 'users')) {
return res.redirect('/admin/users_list');
}
if (blueprint === 'admin' && (route === 'user_add' || route === 'user_new')) {
return res.redirect('/admin/user_add');
}
if (blueprint === 'admin' && (route === 'user_edit' || route === 'user_edit/:id')) {
const id = req.query.id || '';
return res.redirect(id ? `/admin/user_edit/${id}` : '/admin/user_edit');
}
if (blueprint === 'admin' && (route === 'roles_list' || route === 'role_list' || route === 'roles')) {
return res.redirect('/admin/roles_list');
}
if (blueprint === 'admin' && (route === 'role_add' || route === 'role_new')) {
return res.redirect('/admin/role_add');
}
if (blueprint === 'admin' && (route === 'role_edit' || route === 'role_edit/:id')) {
const id = req.query.id || '';
return res.redirect(id ? `/admin/role_edit/${id}` : '/admin/role_edit');
}
if (blueprint === 'classrooms' && (route === 'view_classroom' || route === 'view')) {
return res.redirect('/classrooms/view_classroom');
}
if (blueprint === 'classrooms' && (route === 'edit_classroom' || route === 'edit')) {
const id = req.query.id || req.query.classroom_id || '';
return res.redirect(`/classrooms/edit/${id}`);
}
if (blueprint === 'admin' && route.includes('careers')) {
return res.redirect('/admin/careers_list');
}
if (blueprint === 'admin' && route.includes('subjects')) {
return res.redirect('/admin/subjects_list');
}
if (blueprint === 'admin' && route.includes('commissions')) {
return res.redirect('/admin/commissions_list');
}
if (blueprint === 'admin' && (route.includes('academic_term') || route.includes('academic_terms'))) {
return res.redirect('/admin/academic_terms_list');
}
if (blueprint === 'admin' && (route.includes('milestone_type') || route.includes('milestone_types'))) {
return res.redirect('/admin/milestone_types_list');
}
if (blueprint === 'admin' && route.includes('audit')) {
return res.redirect('/admin/audit_logs');
}
if (blueprint === 'genetic_algorithm_web' && route === 'genetic_optimizer') {
return res.redirect('/genetic_optimizer');
}
let templateName = `${blueprint}/${route}`;
if (route.endsWith('_list') || route.endsWith('_view')) {
templateName = `${blueprint}/list`;
if (blueprint === 'admin' && route.includes('users')) templateName = 'admin/users/list';
if (blueprint === 'admin' && route.includes('roles')) templateName = 'admin/roles/list';
if (blueprint === 'admin' && route.includes('academic_terms')) templateName = 'admin/academic_terms/list';
if (blueprint === 'admin' && route.includes('subjects')) templateName = 'admin/subjects/list';
if (blueprint === 'admin' && route.includes('careers')) templateName = 'admin/careers/list';
if (blueprint === 'admin' && route.includes('commissions')) templateName = 'admin/commissions/list';
if (blueprint === 'admin' && route.includes('milestone_types')) templateName = 'admin/milestone_types/list';
}
const mockContext = {
title: 'Vista de Administración',
role: 'admin',
session: { get: (k, d) => d },
occupancy_metrics: { get: (id, d) => d },
users: [],
roles: [],
careers: [],
subjects: [],
commissions: [],
academic_terms: [],
terms: [],
items: [],
audit_logs: []
};
res.render(templateName, mockContext, function(err, html) {
if (err) {
console.log(`Fallback for ${blueprint}/${route}, tried to render ${templateName}. Error:`, err.message);
res.send(`
<!DOCTYPE html><html><head><title>Vista en Migración</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>body { background: #121212; color: #fff; font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }</style>
</head><body><div class="text-center">
<h1 class="display-4 text-primary mb-3"><i class="bi bi-tools"></i> En Construcción</h1>
<p class="lead">La funcionalidad <strong>${blueprint}.${route}</strong> está siendo acoplada al nuevo BFF Node.js.</p>
<p class="text-muted">La migración de ORM Python a JSON REST está en curso para este módulo.</p>
<a href="/dashboard" class="btn btn-outline-light mt-4">Volver al Panel</a>
</div></body></html>
`);
} else {
res.send(html);
}
});
});
// Middleware 404 para cualquier ruta no encontrada (evita pantallas en blanco y errores Cannot POST/GET)
app.use((req, res) => {
res.status(404).send(`
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Página No Encontrada — Edu-Space</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { background: #0f172a; color: #f8fafc; font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; }
.card-error { background: #1e293b; border: 1px solid #334155; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); }
</style>
</head>
<body>
<div class="container text-center p-4" style="max-width: 520px;">
<div class="card card-error p-4 p-md-5">
<div class="mb-3 text-warning">
<i class="bi bi-compass fs-1"></i>
</div>
<h1 class="h3 fw-bold mb-2">Página o Acción No Encontrada</h1>
<p class="text-secondary small mb-4">
La ruta solicitada <code class="text-light">${req.originalUrl}</code> no existe o no admite el método HTTP ejecutado.
</p>
<div class="d-flex justify-content-center gap-2">
<a href="/dashboard" class="btn btn-primary px-4 fw-semibold">
<i class="bi bi-house-door me-1"></i>Volver al Panel
</a>
<button onclick="window.history.back()" class="btn btn-outline-light px-4">
Volver Atrás
</button>
</div>
</div>
</div>
</body>
</html>
`);
});
// Middleware Global de Error 500
app.use((err, req, res, next) => {
console.error('Error no capturado en BFF:', err);
res.status(500).send(`
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Error del Sistema — Edu-Space</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { background: #0f172a; color: #f8fafc; font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; }
.card-error { background: #1e293b; border: 1px solid #334155; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); }
</style>
</head>
<body>
<div class="container text-center p-4" style="max-width: 520px;">
<div class="card card-error p-4 p-md-5">
<div class="mb-3 text-danger">
<i class="bi bi-exclamation-triangle-fill fs-1"></i>
</div>
<h1 class="h3 fw-bold mb-2">Error en la Aplicación</h1>
<p class="text-secondary small mb-4">
Ocurrió una eventualidad procesando la solicitud. Nuestro equipo técnico ha sido notificado.
</p>
<a href="/dashboard" class="btn btn-primary px-4 fw-semibold">
<i class="bi bi-house-door me-1"></i>Volver al Panel
</a>
</div>
</div>
</body>
</html>
`);
});
app.listen(port, () => {
console.log(`BFF Frontend corriendo en http://localhost:${port}`);
});