Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol

Base de conocimiento para el personal del Instituto Santiago Tapia: oferta
educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas
de inscripcion, links de clases de Google Meet, preguntas frecuentes,
mensajes listos y datos del instituto.

Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en
la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos
canjes simultaneos del mismo enlace no pueden crear dos sesiones.

Tres roles con 19 permisos. El catalogo lo edita direccion; los links de
clases, direccion y administracion; el resto del equipo consulta. El menu se
pinta con lo que el servidor autoriza y cada endpoint exige la misma clave,
asi que no hay pantallas alcanzables escribiendo la direccion a mano.

Toda edicion pasa por una lista blanca de campos validados y queda en la
bitacora. Un precio vacio no se guarda como cero, un link que no es una
direccion se rechaza, y no se puede marcar una validez oficial como
confirmada sin numero de acuerdo.

Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de
44 px, tema claro y bloques plegados en telefono.

La oferta, los costos, las becas, los requisitos y el contacto son los reales
del instituto, tomados del CSV de oferta educativa y de la base de
conocimiento. Los usuarios de acceso y los links de Meet son de demostracion.

Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y
iPhone 13.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-07-29 23:20:34 -06:00
co-authored by Claude Opus 5
commit 0b9c9c1dc2
34 changed files with 8307 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
// =====================================================================
// Magic Login y sesiones
// ---------------------------------------------------------------------
// El token del enlace y el id de sesión viajan en claro al cliente, pero
// en la base solo se guarda su SHA-256. Así un dump de la base no permite
// suplantar a nadie. El token es de un solo uso y expira a los 15 minutos.
// =====================================================================
import crypto from 'node:crypto';
import { q1, exec } from './db.mjs';
import { config } from './config.mjs';
import { ip } from './bitacora.mjs';
const sha = (v) => crypto.createHash('sha256').update(v).digest('hex');
/** Fecha MySQL (UTC) a N minutos de ahora. */
function enMinutos(min) {
return new Date(Date.now() + min * 60_000).toISOString().slice(0, 19).replace('T', ' ');
}
export async function crearEnlaceMagico(usuarioId, origenIp = '') {
const token = crypto.randomBytes(32).toString('base64url');
await exec(
'INSERT INTO magic_tokens (usuario_id, token_hash, expira_en, ip_origen) VALUES (?,?,?,?)',
[usuarioId, sha(token), enMinutos(config.magicLinkMinutos), origenIp],
);
return { token, expiraEnMinutos: config.magicLinkMinutos };
}
/**
* Canjea un token por una sesión. Devuelve { ok, motivo?, sesionId?, usuarioId? }.
*
* El token se reclama con UN SOLO UPDATE condicional. Si se leyera primero y
* se marcara después, dos canjes simultáneos del mismo enlace pasarían los dos
* la validación y crearían dos sesiones: el enlace dejaría de ser de un solo
* uso justo cuando más importa, que es cuando alguien lo comparte.
*/
export async function canjearEnlaceMagico(token, req) {
const hash = sha(token);
const reclamo = await exec(
`UPDATE magic_tokens SET usado_en = UTC_TIMESTAMP()
WHERE token_hash = ? AND usado_en IS NULL AND expira_en > UTC_TIMESTAMP()`,
[hash],
);
if (reclamo.affectedRows !== 1) {
// No se pudo reclamar: se lee solo para poder decir por qué.
const fila = await q1(
`SELECT usado_en, expira_en < UTC_TIMESTAMP() AS vencido
FROM magic_tokens WHERE token_hash = ?`,
[hash],
);
if (!fila) return { ok: false, motivo: 'invalido' };
if (fila.usado_en) return { ok: false, motivo: 'ya-usado' };
return { ok: false, motivo: 'vencido' };
}
const fila = await q1(
`SELECT t.usuario_id, u.activo FROM magic_tokens t
JOIN usuarios u ON u.id = t.usuario_id WHERE t.token_hash = ?`,
[hash],
);
if (!fila) return { ok: false, motivo: 'invalido' };
if (!fila.activo) return { ok: false, motivo: 'usuario-inactivo' };
const sesionId = crypto.randomBytes(32).toString('base64url');
await exec(
'INSERT INTO sesiones (id, usuario_id, expira_en, user_agent, ip_origen) VALUES (?,?,?,?,?)',
[sha(sesionId), fila.usuario_id, enMinutos(config.sesionMinutos),
String(req.headers['user-agent'] ?? '').slice(0, 255), ip(req)],
);
await exec('UPDATE usuarios SET ultimo_acceso = UTC_TIMESTAMP() WHERE id = ?', [fila.usuario_id]);
return { ok: true, sesionId, usuarioId: fila.usuario_id };
}
export async function cerrarSesion(sesionId) {
if (!sesionId) return;
await exec('DELETE FROM sesiones WHERE id = ?', [sha(sesionId)]);
}
export function leerCookie(req, nombre) {
const cabecera = req.headers.cookie;
if (!cabecera) return null;
for (const parte of cabecera.split(';')) {
const i = parte.indexOf('=');
if (i < 0) continue;
if (parte.slice(0, i).trim() !== nombre) continue;
const cruda = parte.slice(i + 1).trim();
try {
return decodeURIComponent(cruda);
} catch {
// Una cookie con un % mal formado hace que decodeURIComponent lance.
// Se trata como "sin sesión" en lugar de dejar caer la petición.
return null;
}
}
return null;
}
function atributosCookie() {
const partes = ['Path=/', 'HttpOnly', `SameSite=${config.cookieSegura ? 'Strict' : 'Lax'}`];
if (config.cookieSegura) partes.push('Secure');
return partes.join('; ');
}
export function ponerCookieSesion(res, sesionId) {
res.setHeader('Set-Cookie', [
`${config.cookieNombre}=${encodeURIComponent(sesionId)}; ${atributosCookie()}; Max-Age=${config.sesionMinutos * 60}`,
]);
}
export function borrarCookieSesion(res) {
res.setHeader('Set-Cookie', [`${config.cookieNombre}=; ${atributosCookie()}; Max-Age=0`]);
}
/**
* Middleware: si hay cookie de sesión válida, deja el usuario y sus permisos
* en req.usuario / req.permisos. No bloquea: eso lo hace exigirSesion().
*/
export async function cargarSesion(req, _res, next) {
req.usuario = null;
req.permisos = new Set();
const cruda = leerCookie(req, config.cookieNombre);
if (!cruda) return next();
try {
const u = await q1(
`SELECT u.id, u.nombre, u.email, u.puesto, u.iniciales, u.extension,
r.clave AS rol_clave, r.nombre AS rol_nombre, r.nivel AS rol_nivel, r.color AS rol_color,
s.expira_en
FROM sesiones s
JOIN usuarios u ON u.id = s.usuario_id
JOIN roles r ON r.id = u.rol_id
WHERE s.id = ? AND s.expira_en > UTC_TIMESTAMP() AND u.activo = 1`,
[sha(cruda)],
);
if (!u) return next();
const filas = await q1(
`SELECT GROUP_CONCAT(p.clave) AS claves
FROM roles_permisos rp
JOIN permisos p ON p.id = rp.permiso_id
JOIN usuarios u ON u.rol_id = rp.rol_id
WHERE u.id = ?`,
[u.id],
);
req.usuario = u;
req.sesionCruda = cruda;
req.permisos = new Set((filas?.claves ?? '').split(',').filter(Boolean));
} catch (err) {
console.error('[auth] error al cargar la sesión:', err.message);
}
next();
}
+45
View File
@@ -0,0 +1,45 @@
import { exec } from './db.mjs';
import { config } from './config.mjs';
/**
* Registra una acción en la bitácora. Nunca lanza: si el registro falla,
* la petición del usuario no se cae por eso.
*/
export async function registrar(req, { accion, entidad = '', entidad_id = '', resultado = 'ok', detalle = '' }) {
const u = req.usuario ?? null;
try {
await exec(
`INSERT INTO bitacora (usuario_id, actor, rol, accion, entidad, entidad_id, resultado, detalle, ip_origen)
VALUES (?,?,?,?,?,?,?,?,?)`,
[
u?.id ?? null,
u?.nombre ?? 'anónimo',
u?.rol_clave ?? '',
accion,
String(entidad).slice(0, 48),
String(entidad_id).slice(0, 48),
resultado,
String(detalle).slice(0, 500),
ip(req),
],
);
} catch (err) {
console.error('[bitacora] no se pudo registrar:', err.message);
}
}
/**
* Origen de la petición. X-Forwarded-For lo pone quien llama, así que solo se
* cree cuando se declara explícitamente que hay un proxy de confianza delante
* (CONFIAR_PROXY=true). Si no, la bitácora registraría la IP que el cliente
* quiera y dejaría de servir para auditar.
*/
export function ip(req) {
let cruda = req.socket?.remoteAddress || '';
if (config.confiarProxy) {
const xff = req.headers['x-forwarded-for'];
const primera = (Array.isArray(xff) ? xff[0] : xff)?.split(',')[0]?.trim();
if (primera) cruda = primera;
}
return String(cruda).replace('::ffff:', '').slice(0, 64);
}
+174
View File
@@ -0,0 +1,174 @@
// =====================================================================
// Validación de campos editables
// ---------------------------------------------------------------------
// Cada entidad editable declara un mapa columna → validador. Solo las
// columnas declaradas llegan al UPDATE, y siempre por consulta preparada.
// Un campo ausente en el cuerpo no se toca; uno presente e inválido
// aborta la petición con un mensaje que se le puede mostrar a la persona.
// =====================================================================
class ErrorCampo extends Error {
constructor(campo, mensaje) {
super(mensaje);
this.campo = campo;
this.esErrorCampo = true;
}
}
const vacioONulo = (v) => v === null || v === undefined || v === '';
/**
* Solo se aceptan primitivos. Un objeto o un arreglo en el JSON acabaría
* guardado como "[object Object]" o como sus elementos unidos por comas.
*/
function comoTexto(v, campo, nombre) {
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
throw new ErrorCampo(campo, `${nombre} tiene que ser texto.`);
}
export const texto = (max, { requerido = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
return '';
}
const s = comoTexto(v, campo, nombre).trim();
if (requerido && !s) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
if (s.length > max) throw new ErrorCampo(campo, `${nombre} no puede pasar de ${max} caracteres. Lleva ${s.length}.`);
return s;
};
/**
* Cantidad en pesos. Un campo vacío NO se toma como cero: eso publicaría un
* programa gratis sin que nadie lo note. Se acepta "$1,500" tal como se
* escribe un precio, porque el formulario lo manda como texto.
*/
export const dinero = ({ min = 0, max = 999999, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío. Si el concepto no se cobra, escribe 0.`);
const limpio = comoTexto(v, campo, nombre).replace(/[$\s,]/g, '');
if (!/^\d+(\.\d{1,2})?$/.test(limpio)) {
throw new ErrorCampo(campo, `${nombre} tiene que ser una cantidad en pesos, por ejemplo 1500 o $1,500.`);
}
const n = Number(limpio);
if (n < min || n > max) {
throw new ErrorCampo(campo, `${nombre} tiene que estar entre ${min} y ${max.toLocaleString('es-MX')} pesos.`);
}
return Math.round(n * 100) / 100;
};
export const entero = ({ min = 0, max = 32767, nulable = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (nulable) return null;
throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
}
const limpio = comoTexto(v, campo, nombre).trim();
if (!/^-?\d+$/.test(limpio)) throw new ErrorCampo(campo, `${nombre} tiene que ser un número entero.`);
const n = Number(limpio);
if (n < min || n > max) throw new ErrorCampo(campo, `${nombre} tiene que estar entre ${min} y ${max}.`);
return n;
};
export const booleano = () => (v) => (v === true || v === 1 || v === '1' || v === 'true' ? 1 : 0);
export const opcion = (valores, { etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
const s = comoTexto(v, campo, nombre);
if (!valores.includes(s)) {
throw new ErrorCampo(campo, `${nombre} solo puede ser: ${valores.join(', ')}.`);
}
return s;
};
export const fecha = ({ etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) return null;
const s = comoTexto(v, campo, nombre).slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw new ErrorCampo(campo, `${nombre} debe tener el formato AAAA-MM-DD.`);
const d = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s) {
throw new ErrorCampo(campo, `${nombre} no es una fecha que exista.`);
}
return s;
};
/** Hora en formato de 24 horas, "20:00". Se normaliza a dos dígitos. */
export const hora = ({ requerido = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacía. Escríbela así: 20:00.`);
return '';
}
const m = /^(\d{1,2}):(\d{2})$/.exec(comoTexto(v, campo, nombre).trim());
if (!m) throw new ErrorCampo(campo, `${nombre} debe verse así: 20:00.`);
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59) throw new ErrorCampo(campo, `${nombre} no es una hora válida.`);
return `${String(h).padStart(2, '0')}:${m[2]}`;
};
/** Enlace de reunión. Solo http/https; se rechaza cualquier otro esquema. */
export const enlace = ({ requerido = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
return '';
}
const s = comoTexto(v, campo, nombre).trim();
let u;
try {
u = new URL(s);
} catch {
throw new ErrorCampo(campo, `${nombre} debe ser una dirección completa, empezando con https://`);
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new ErrorCampo(campo, `${nombre} debe empezar con https:// o http://`);
}
// El largo se comprueba sobre el valor normalizado: new URL() puede añadir
// una barra final, y un enlace de justo 500 caracteres se pasaría de la
// columna y reventaría al guardarse.
const normalizado = u.toString();
if (normalizado.length > 500) throw new ErrorCampo(campo, `${nombre} es demasiado largo: no puede pasar de 500 caracteres.`);
return normalizado;
};
/**
* Aplica el mapa de validadores al cuerpo de la petición.
* @returns {{columnas: string[], valores: any[], resumen: string}}
*/
export function validar(cuerpo, mapa, { exigirTodos = false } = {}) {
const columnas = [];
const valores = [];
for (const [columna, validador] of Object.entries(mapa)) {
const presente = Object.prototype.hasOwnProperty.call(cuerpo ?? {}, columna);
if (!presente) {
if (!exigirTodos) continue;
// En una creación, un campo ausente se valida como vacío para que
// el validador decida si era obligatorio.
columnas.push(columna);
valores.push(validador(undefined, columna));
continue;
}
columnas.push(columna);
valores.push(validador(cuerpo[columna], columna));
}
return { columnas, valores, resumen: columnas.join(', ') };
}
/** Convierte un error de validación o de llave foránea en una respuesta 400. */
export function responderError(res, err) {
if (err?.esErrorCampo) {
return res.status(400).json({ error: 'campo-invalido', campo: err.campo, mensaje: err.message });
}
if (err?.code === 'ER_NO_REFERENCED_ROW_2' || err?.code === 'ER_NO_REFERENCED_ROW') {
return res.status(400).json({ error: 'referencia-invalida', mensaje: 'Elegiste una opción que ya no existe. Recarga la página e inténtalo de nuevo.' });
}
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'duplicado', mensaje: 'Ya existe un registro con ese dato.' });
}
return null;
}
export { ErrorCampo };
+47
View File
@@ -0,0 +1,47 @@
// Configuración central. Todo tiene valor por omisión para que la demo
// arranque sin archivo .env.
const bandera = (valor, porOmision) => (valor === undefined ? porOmision : valor === 'true');
export const config = {
port: Number(process.env.PORT ?? 4180),
db: {
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3307),
user: process.env.DB_USER ?? 'root',
password: process.env.DB_PASSWORD ?? 'insta_root_demo',
database: process.env.DB_NAME ?? 'insta_portal',
},
// Vigencia del enlace de acceso y de la sesión, en minutos.
magicLinkMinutos: Number(process.env.MAGIC_LINK_MINUTOS ?? 15),
sesionMinutos: Number(process.env.SESION_MINUTOS ?? 480),
cookieNombre: 'insta_sesion',
/**
* En la demo el enlace de acceso se muestra en pantalla en lugar de
* enviarse por correo, para que cualquiera pueda entrar y probarla.
* OJO: con esto activo, quien conozca un correo dado de alta obtiene una
* sesión sin más. Es una decisión consciente para una demostración con
* datos ficticios. Para uso real hay que ponerlo en false y enviar el
* enlace por correo.
*/
mostrarEnlaceEnPantalla: bandera(process.env.MOSTRAR_ENLACE, true),
// Cookie Secure y SameSite=Strict cuando el portal va detrás de HTTPS.
cookieSegura: bandera(process.env.COOKIE_SEGURA, false),
// Solo con un proxy de confianza delante se cree el X-Forwarded-For.
confiarProxy: bandera(process.env.CONFIAR_PROXY, false),
/**
* Límite de enlaces de acceso por origen y por ventana, para que nadie los
* pida en ráfaga. El loopback queda exento: ahí viven el desarrollo y las
* pruebas.
*
* OJO detrás de un proxy: sin CONFIAR_PROXY=true todas las peticiones se
* ven llegar desde la IP del proxy y comparten el mismo cupo, así que un
* puñado de personas probando la demo lo agotaría entre todas.
*/
enlacesPorVentana: Number(process.env.ENLACES_POR_VENTANA ?? 60),
ventanaMinutos: Number(process.env.VENTANA_MINUTOS ?? 5),
};
+42
View File
@@ -0,0 +1,42 @@
import mysql from 'mysql2/promise';
import { config } from './config.mjs';
let pool;
export function getPool() {
if (!pool) {
pool = mysql.createPool({
...config.db,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
charset: 'utf8mb4',
timezone: 'Z',
dateStrings: ['DATE'],
});
}
return pool;
}
/** Consulta que devuelve filas. */
export async function q(sql, params = []) {
const [rows] = await getPool().execute(sql, params);
return rows;
}
/** Consulta que devuelve la primera fila o null. */
export async function q1(sql, params = []) {
const rows = await q(sql, params);
return rows[0] ?? null;
}
/** INSERT/UPDATE/DELETE; devuelve el ResultSetHeader. */
export async function exec(sql, params = []) {
const [res] = await getPool().execute(sql, params);
return res;
}
export async function cerrar() {
if (pool) await pool.end();
pool = undefined;
}
+119
View File
@@ -0,0 +1,119 @@
// =====================================================================
// Portal Interno INSTA · servidor
// =====================================================================
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.mjs';
import { getPool, q1 } from './db.mjs';
import { cargarSesion, canjearEnlaceMagico, ponerCookieSesion } from './auth.mjs';
import { registrar } from './bitacora.mjs';
import { rutas } from './rutas.mjs';
const raiz = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const publico = path.join(raiz, 'public');
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '256kb' }));
// El cuerpo mal formado o demasiado grande se contesta con su código y con un
// mensaje que la persona pueda entender, no con un 500 genérico.
app.use((err, _req, res, next) => {
if (!err) return next();
if (err.type === 'entity.too.large') {
return res.status(413).json({
error: 'texto-demasiado-largo',
mensaje: 'El texto que pegaste es demasiado largo. Recórtalo e inténtalo de nuevo.',
});
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'peticion-invalida', mensaje: 'No se entendió la petición. Recarga la página e inténtalo otra vez.' });
}
return next(err);
});
app.use(cargarSesion);
// Cabeceras defensivas. La CSP no permite ningún recurso externo: todo el
// portal se sirve desde este origen. Los estilos en línea sí se permiten
// porque las plantillas usan el atributo style para los colores del catálogo.
app.use((_req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"base-uri 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
].join('; '));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('X-Frame-Options', 'DENY');
next();
});
// ---- Magic Login por navegación directa ------------------------------
// Es el enlace que en producción llegaría por correo.
app.get('/acceso/:token', async (req, res) => {
const r = await canjearEnlaceMagico(req.params.token, req);
if (!r.ok) {
await registrar(req, { accion: 'auth.canje', resultado: 'denegado', detalle: r.motivo });
return res.redirect(`/?error=${encodeURIComponent(r.motivo)}`);
}
ponerCookieSesion(res, r.sesionId);
const u = await q1('SELECT nombre FROM usuarios WHERE id = ?', [r.usuarioId]);
req.usuario = { id: r.usuarioId, nombre: u?.nombre ?? '', rol_clave: '' };
await registrar(req, { accion: 'auth.entrada', entidad: 'usuario', entidad_id: String(r.usuarioId), detalle: 'sesión iniciada con enlace mágico' });
res.redirect('/portal');
});
// Salud del proceso. A propósito NO consulta la base: si MySQL tarda en
// arrancar, el contenedor no debe reiniciarse en bucle por eso.
app.get('/salud', (_req, res) => {
res.json({ ok: true, servicio: 'portal-insta', tiempoActivo: Math.round(process.uptime()) });
});
app.use('/api', rutas);
// ---- estáticos y shell ----------------------------------------------
app.use(express.static(publico, { extensions: ['html'], maxAge: '5m' }));
// El portal es una sola página; si no hay sesión, el propio shell manda a la entrada.
app.get('/portal', (_req, res) => res.sendFile(path.join(publico, 'portal.html')));
app.get('/', (_req, res) => res.sendFile(path.join(publico, 'index.html')));
app.use((req, res) => {
if (req.path.startsWith('/api')) return res.status(404).json({ error: 'endpoint-no-encontrado' });
res.status(404).sendFile(path.join(publico, 'index.html'));
});
app.use((err, _req, res, _next) => {
console.error('[error]', err);
if (res.headersSent) return;
res.status(500).json({ error: 'error-interno', mensaje: 'Algo falló del lado del servidor. Vuelve a intentarlo.' });
});
// Red de seguridad: un fallo suelto se registra, no tumba el portal.
process.on('unhandledRejection', (motivo) => console.error('[rechazo no atrapado]', motivo));
process.on('uncaughtException', (err) => console.error('[excepción no atrapada]', err));
// ---- arranque --------------------------------------------------------
try {
const fila = await q1('SELECT COUNT(*) AS n FROM programas');
if (!fila || fila.n === 0) throw new Error('la base está vacía: corre `npm run db:reset`');
app.listen(config.port, () => {
console.log(`\n ▸ Portal INSTA en http://localhost:${config.port}`);
console.log(` base: ${config.db.database}@${config.db.host}:${config.db.port} · ${fila.n} programas cargados`);
console.log(' accesos demo: [email protected] · [email protected] · [email protected]\n');
});
} catch (err) {
console.error(`\n✖ No se pudo arrancar: ${err.message}\n`);
console.error(' Revisa que el contenedor esté arriba: docker compose up -d');
await getPool().end().catch(() => {});
process.exit(1);
}
+78
View File
@@ -0,0 +1,78 @@
// =====================================================================
// Control de acceso por rol
// ---------------------------------------------------------------------
// SECCIONES es la única fuente de verdad: de aquí sale el menú que ve
// cada persona y el permiso que exige cada endpoint. No puede haber un
// menú que muestre algo que la API niega, ni al revés.
// =====================================================================
import { registrar } from './bitacora.mjs';
export const GRUPOS = [
{ id: 'interno', titulo: 'Uso interno' },
];
export const SECCIONES = [
{ id: 'inicio', titulo: 'Inicio', grupo: null, icono: 'inicio', permisos: ['inicio.ver'],
resumen: 'Punto de partida' },
{ id: 'oferta', titulo: 'Oferta educativa', grupo: null, icono: 'birrete', permisos: ['oferta.ver'],
resumen: 'Los 8 programas con costos, horarios y validez oficial' },
{ id: 'requisitos', titulo: 'Requisitos y titulación', grupo: null, icono: 'documento', permisos: ['requisitos.ver'],
resumen: 'Documentos por nivel y formas de titularse' },
{ id: 'becas', titulo: 'Becas y fechas', grupo: null, icono: 'etiqueta', permisos: ['becas.ver'],
resumen: 'Descuentos vigentes y fechas de inscripción' },
{ id: 'clases', titulo: 'Links de clases', grupo: null, icono: 'video', permisos: ['clases.ver'],
resumen: 'Los enlaces de Google Meet por programa y materia' },
{ id: 'preguntas', titulo: 'Preguntas frecuentes', grupo: null, icono: 'libro', permisos: ['kb.ver'],
resumen: 'La respuesta correcta a lo que más preguntan' },
{ id: 'mensajes', titulo: 'Mensajes listos', grupo: null, icono: 'mensaje', permisos: ['guiones.ver'],
resumen: 'Textos para copiar y enviar por WhatsApp' },
{ id: 'contacto', titulo: 'Datos del instituto', grupo: null, icono: 'edificio', permisos: ['contacto.ver'],
resumen: 'Dirección, teléfonos, horarios y misión' },
// Van bajo "Uso interno" y no bajo "Solo dirección": Equipo lo ve también
// administración, y un encabezado que dijera lo contrario sería falso.
{ id: 'equipo', titulo: 'Equipo', grupo: 'interno', icono: 'personas', permisos: ['equipo.ver'],
resumen: 'Quién es quién y qué puede editar cada rol' },
{ id: 'bitacora', titulo: 'Bitácora', grupo: 'interno', icono: 'reloj', permisos: ['bitacora.ver'],
resumen: 'Quién entró y qué cambió' },
];
/** ¿El usuario tiene al menos uno de estos permisos? */
export function puede(req, ...claves) {
return claves.some((c) => req.permisos?.has(c));
}
/** Secciones visibles para este usuario. */
export function navegacionDe(req) {
return SECCIONES
.filter((s) => puede(req, ...s.permisos))
.map(({ id, titulo, grupo, icono, resumen }) => ({ id, titulo, grupo, icono, resumen }));
}
// ---- middlewares -----------------------------------------------------
export function exigirSesion(req, res, next) {
if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' });
next();
}
/**
* Exige al menos uno de los permisos indicados. Un intento denegado queda
* en la bitácora: es justo lo que dirección quiere poder auditar.
*/
export function exigir(...claves) {
return async (req, res, next) => {
if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' });
if (puede(req, ...claves)) return next();
await registrar(req, {
accion: 'acceso.denegado', entidad: 'permiso', entidad_id: claves.join('|'),
resultado: 'denegado', detalle: `${req.method} ${req.originalUrl}`,
});
return res.status(403).json({
error: 'sin-permiso',
mensaje: `Tu rol (${req.usuario.rol_nombre}) no puede hacer esto.`,
requiere: claves,
});
};
}
+874
View File
@@ -0,0 +1,874 @@
// =====================================================================
// API del portal
// ---------------------------------------------------------------------
// Cada endpoint declara su permiso con exigir(...), así que ocultar algo
// del menú y negarlo en la API es la misma regla. Toda edición pasa por
// un mapa de campos validados y queda registrada en la bitácora.
// =====================================================================
import express from 'express';
import { q, q1, exec } from './db.mjs';
import { config } from './config.mjs';
import {
crearEnlaceMagico, canjearEnlaceMagico, cerrarSesion,
ponerCookieSesion, borrarCookieSesion,
} from './auth.mjs';
import { exigir, exigirSesion, puede, navegacionDe, GRUPOS, SECCIONES } from './rbac.mjs';
import { registrar, ip } from './bitacora.mjs';
import {
texto, dinero, entero, booleano, opcion, fecha, hora, enlace, validar, responderError,
} from './campos.mjs';
export const rutas = express.Router();
// Express 4 no atrapa el rechazo de un handler async: sin esto, cualquier
// error de base de datos se convierte en un unhandledRejection y tumba el
// proceso. Envolvemos cada handler al registrarlo para que el error viaje
// al manejador de errores y el servidor siga en pie.
for (const metodo of ['get', 'post', 'patch', 'put', 'delete']) {
const original = rutas[metodo].bind(rutas);
rutas[metodo] = (ruta, ...manejadores) => original(
ruta,
...manejadores.map((m) => (m.length === 4
? m
: (req, res, next) => Promise.resolve(m(req, res, next)).catch(next))),
);
}
// =====================================================================
// Autenticación · Magic Login
// =====================================================================
rutas.get('/auth/demo', async (_req, res) => {
const filas = await q(
`SELECT u.nombre, u.email, u.puesto, u.iniciales, r.clave AS rol_clave, r.nombre AS rol_nombre,
r.nivel AS rol_nivel, r.color AS rol_color, r.descripcion AS rol_descripcion
FROM usuarios u JOIN roles r ON r.id = u.rol_id
WHERE u.activo = 1 AND u.es_demo = 1 ORDER BY r.nivel DESC`,
);
res.json({ usuarios: filas, vigenciaMinutos: config.magicLinkMinutos });
});
// Freno sencillo por IP para que nadie pida enlaces en ráfaga. En memoria a
// propósito: es una sola instancia y no hace falta más.
const pedidosPorIp = new Map();
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost', '']);
function demasiadosPedidos(origen) {
if (LOOPBACK.has(origen)) return false; // desarrollo y pruebas
const ahora = Date.now();
const ventana = config.ventanaMinutos * 60_000;
const previos = (pedidosPorIp.get(origen) ?? []).filter((t) => ahora - t < ventana);
previos.push(ahora);
pedidosPorIp.set(origen, previos);
// Limpieza perezosa para que el mapa no crezca sin fin.
if (pedidosPorIp.size > 500) {
for (const [clave, marcas] of pedidosPorIp) {
if (!marcas.some((t) => ahora - t < ventana)) pedidosPorIp.delete(clave);
}
}
return previos.length > config.enlacesPorVentana;
}
rutas.post('/auth/enlace', async (req, res) => {
const email = String(req.body?.email ?? '').trim().toLowerCase();
if (!email) return res.status(400).json({ error: 'email-requerido', mensaje: 'Escribe tu correo institucional.' });
if (demasiadosPedidos(ip(req))) {
await registrar(req, { accion: 'auth.enlace', entidad: 'usuario', entidad_id: email, resultado: 'denegado', detalle: 'demasiados pedidos desde el mismo origen' });
return res.status(429).json({
error: 'demasiados-pedidos',
mensaje: `Pediste muchos enlaces seguidos. Espera ${config.ventanaMinutos} minutos e inténtalo otra vez.`,
});
}
const u = await q1(
`SELECT u.id, u.nombre, u.iniciales, r.nombre AS rol_nombre, r.clave AS rol_clave
FROM usuarios u JOIN roles r ON r.id = u.rol_id
WHERE u.email = ? AND u.activo = 1`,
[email],
);
if (!u) {
await registrar(req, { accion: 'auth.enlace', entidad: 'usuario', entidad_id: email, resultado: 'denegado', detalle: 'correo no registrado' });
return res.status(404).json({
error: 'no-registrado',
mensaje: 'Ese correo no está dado de alta en el portal. Pídele acceso a dirección.',
});
}
const { token, expiraEnMinutos } = await crearEnlaceMagico(u.id, ip(req));
await registrar(req, { accion: 'auth.enlace', entidad: 'usuario', entidad_id: String(u.id), detalle: `enlace generado para ${u.nombre}` });
res.json({
ok: true,
usuario: { nombre: u.nombre, iniciales: u.iniciales, rol_nombre: u.rol_nombre, rol_clave: u.rol_clave },
expiraEnMinutos,
enlace: config.mostrarEnlaceEnPantalla ? `/acceso/${token}` : null,
token: config.mostrarEnlaceEnPantalla ? token : null,
});
});
rutas.post('/auth/consumir', async (req, res) => {
const token = String(req.body?.token ?? '').trim();
const r = await canjearEnlaceMagico(token, req);
if (!r.ok) {
await registrar(req, { accion: 'auth.canje', resultado: 'denegado', detalle: r.motivo });
return res.status(401).json({ error: r.motivo, mensaje: motivoLegible(r.motivo) });
}
ponerCookieSesion(res, r.sesionId);
// Sin esto, los accesos por la pantalla de entrada no quedarían en la
// bitácora y el "quién entró" que dirección revisa saldría vacío.
const u = await q1('SELECT u.nombre, r.clave AS rol FROM usuarios u JOIN roles r ON r.id = u.rol_id WHERE u.id = ?', [r.usuarioId]);
req.usuario = { id: r.usuarioId, nombre: u?.nombre ?? '', rol_clave: u?.rol ?? '' };
await registrar(req, { accion: 'auth.entrada', entidad: 'usuario', entidad_id: String(r.usuarioId), detalle: 'sesión iniciada con enlace de acceso' });
res.json({ ok: true });
});
rutas.post('/auth/salir', async (req, res) => {
if (req.usuario) await registrar(req, { accion: 'auth.salida', entidad: 'usuario', entidad_id: String(req.usuario.id) });
await cerrarSesion(req.sesionCruda);
borrarCookieSesion(res);
res.json({ ok: true });
});
rutas.get('/auth/yo', exigirSesion, async (req, res) => {
res.json({
usuario: req.usuario,
permisos: [...req.permisos].sort(),
navegacion: navegacionDe(req),
grupos: GRUPOS,
fechaCorte: '2026-07-29',
});
});
function motivoLegible(motivo) {
return {
invalido: 'Ese enlace no es válido.',
'ya-usado': 'Ese enlace ya se usó. Los enlaces de acceso sirven una sola vez: genera uno nuevo.',
vencido: `El enlace venció. Tienen ${config.magicLinkMinutos} minutos de vigencia.`,
'usuario-inactivo': 'Tu cuenta está desactivada. Habla con dirección.',
}[motivo] ?? 'No se pudo validar el enlace.';
}
// =====================================================================
// Consultas compartidas
// =====================================================================
const SQL_PROGRAMAS = `
SELECT p.*, n.clave AS nivel_clave, n.nombre AS nivel_nombre, n.orden AS nivel_orden,
u.nombre AS actualizado_por_nombre
FROM programas p
JOIN niveles n ON n.id = p.nivel_id
LEFT JOIN usuarios u ON u.id = p.actualizado_por`;
async function horariosDe(programaId) {
return q(
`SELECT pm.id, pm.modalidad_id, pm.dias, pm.hora_inicio, pm.hora_fin, pm.nota,
m.clave AS modalidad_clave, m.nombre AS modalidad_nombre, m.color AS modalidad_color, m.detalle
FROM programas_modalidades pm JOIN modalidades m ON m.id = pm.modalidad_id
WHERE pm.programa_id = ? ORDER BY m.clave, pm.id`,
[programaId],
);
}
// =====================================================================
// Inicio
// =====================================================================
rutas.get('/inicio', exigir('inicio.ver'), async (req, res) => {
const [{ n: programas }] = await q('SELECT COUNT(*) AS n FROM programas WHERE activo = 1');
const [{ n: clases }] = await q('SELECT COUNT(*) AS n FROM clases WHERE activo = 1');
const [{ n: preguntas }] = await q('SELECT COUNT(*) AS n FROM kb_articulos');
const [{ n: mensajes }] = await q('SELECT COUNT(*) AS n FROM guiones');
const promo = await q1(
"SELECT nombre, resumen, vigencia FROM promociones WHERE clave = 'julio-inscripcion-gratis' AND activo = 1",
);
const contacto = await q(
"SELECT clave, etiqueta, valor FROM institucional WHERE grupo = 'contacto' ORDER BY orden",
);
const proximos = await q(
`SELECT nombre_corto, fecha_inicio, color, nivel_id FROM programas
WHERE activo = 1 ORDER BY nivel_id, orden`,
);
const extra = {};
if (puede(req, 'datos.ver')) {
const [{ n }] = await q("SELECT COUNT(*) AS n FROM discrepancias WHERE estado <> 'aclarado'");
extra.datosPorConfirmar = n;
}
if (puede(req, 'bitacora.ver')) {
extra.cambiosRecientes = await q(
`SELECT actor, accion, entidad, detalle, creado_en FROM bitacora
WHERE accion LIKE '%.editar' OR accion LIKE '%.crear' OR accion LIKE '%.borrar'
ORDER BY id DESC LIMIT 5`,
);
}
res.json({ conteos: { programas, clases, preguntas, mensajes }, promo, contacto, proximos, ...extra });
});
// =====================================================================
// Oferta educativa
// =====================================================================
rutas.get('/oferta', exigir('oferta.ver'), async (req, res) => {
const programas = await q(`${SQL_PROGRAMAS} WHERE p.activo = 1 ORDER BY n.orden, p.orden`);
for (const p of programas) p.horarios = await horariosDe(p.id);
const carga = {
niveles: await q('SELECT id, clave, nombre, orden FROM niveles ORDER BY orden'),
modalidades: await q('SELECT id, clave, nombre, color, detalle FROM modalidades ORDER BY clave'),
programas,
};
// El panel de datos por confirmar solo lo ve quien puede actuar sobre ellos.
if (puede(req, 'datos.ver')) {
carga.porConfirmar = await q(
"SELECT * FROM discrepancias WHERE estado <> 'aclarado' ORDER BY FIELD(prioridad,'alta','media','baja'), orden",
);
}
res.json(carga);
});
const CAMPOS_PROGRAMA = {
nombre: texto(120, { requerido: true, etiqueta: 'El nombre del programa' }),
nombre_corto: texto(48, { requerido: true, etiqueta: 'El nombre corto' }),
duracion: texto(48, { requerido: true, etiqueta: 'La duración' }),
fecha_inicio: texto(32, { requerido: true, etiqueta: 'La fecha de inicio' }),
inscripcion_lista: dinero({ etiqueta: 'La inscripción de lista' }),
inscripcion_beca: dinero({ etiqueta: 'La inscripción con beca' }),
mensualidad_lista: dinero({ etiqueta: 'La mensualidad de lista' }),
mensualidad_beca: dinero({ etiqueta: 'La mensualidad con beca' }),
ciclo_semanas: entero({ min: 1, max: 104, nulable: true, etiqueta: 'Las semanas del ciclo' }),
rvoe_acuerdo: texto(48, { etiqueta: 'El número de acuerdo' }),
rvoe_fecha: fecha({ etiqueta: 'La fecha del acuerdo' }),
rvoe_autoridad: texto(120, { etiqueta: 'La autoridad que lo otorgó' }),
rvoe_estado: opcion(['confirmado', 'revisar', 'por-confirmar'], { etiqueta: 'El estado del RVOE' }),
rvoe_nota: texto(500, { etiqueta: 'La nota del RVOE' }),
beneficios: texto(500, { etiqueta: 'Los beneficios' }),
};
rutas.patch('/oferta/:id', exigir('oferta.editar'), async (req, res) => {
const id = Number(req.params.id);
const actual = await q1(
'SELECT id, clave, nombre, rvoe_acuerdo, rvoe_autoridad FROM programas WHERE id = ?',
[id],
);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese programa no existe.' });
try {
const { columnas, valores, resumen } = validar(req.body, CAMPOS_PROGRAMA);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
// Marcar la validez como confirmada sin número de acuerdo dejaría en la
// ficha una frase a medias ("Acuerdo , otorgado el —"), y el equipo la
// citaría creyendo que el dato existe.
const iEstado = columnas.indexOf('rvoe_estado');
if (iEstado >= 0 && valores[iEstado] === 'confirmado') {
const iAcuerdo = columnas.indexOf('rvoe_acuerdo');
const iAutoridad = columnas.indexOf('rvoe_autoridad');
const acuerdo = iAcuerdo >= 0 ? valores[iAcuerdo] : actual.rvoe_acuerdo;
const autoridad = iAutoridad >= 0 ? valores[iAutoridad] : actual.rvoe_autoridad;
if (!acuerdo) {
return res.status(400).json({
error: 'campo-invalido', campo: 'rvoe_acuerdo',
mensaje: 'Para marcar la validez como confirmada hace falta el número de acuerdo.',
});
}
if (!autoridad) {
return res.status(400).json({
error: 'campo-invalido', campo: 'rvoe_autoridad',
mensaje: 'Para marcar la validez como confirmada hace falta la autoridad que la otorgó.',
});
}
}
await exec(
`UPDATE programas SET ${columnas.map((c) => `${c} = ?`).join(', ')}, actualizado_por = ? WHERE id = ?`,
[...valores, req.usuario.id, id],
);
await registrar(req, { accion: 'oferta.editar', entidad: 'programa', entidad_id: actual.clave, detalle: `${actual.nombre} · campos: ${resumen}` });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
// --- horarios de un programa -----------------------------------------
const CAMPOS_HORARIO = {
programa_id: entero({ min: 1, etiqueta: 'El programa' }),
modalidad_id: entero({ min: 1, etiqueta: 'La modalidad' }),
dias: texto(64, { requerido: true, etiqueta: 'Los días' }),
hora_inicio: hora({ etiqueta: 'La hora de inicio' }),
hora_fin: hora({ etiqueta: 'La hora de fin' }),
nota: texto(160, { etiqueta: 'La nota' }),
};
rutas.post('/horarios', exigir('oferta.editar'), async (req, res) => {
try {
const { columnas, valores } = validar(req.body, CAMPOS_HORARIO, { exigirTodos: true });
const r = await exec(
`INSERT INTO programas_modalidades (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
valores,
);
await registrar(req, { accion: 'oferta.crear', entidad: 'horario', entidad_id: String(r.insertId), detalle: `horario nuevo en el programa ${req.body?.programa_id}` });
res.status(201).json({ ok: true, id: r.insertId });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.patch('/horarios/:id', exigir('oferta.editar'), async (req, res) => {
try {
const { programa_id: _omitir, ...editables } = CAMPOS_HORARIO;
const { columnas, valores, resumen } = validar(req.body, editables);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
const r = await exec(
`UPDATE programas_modalidades SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...valores, Number(req.params.id)],
);
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await registrar(req, { accion: 'oferta.editar', entidad: 'horario', entidad_id: req.params.id, detalle: resumen });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.delete('/horarios/:id', exigir('oferta.editar'), async (req, res) => {
const r = await exec('DELETE FROM programas_modalidades WHERE id = ?', [Number(req.params.id)]);
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await registrar(req, { accion: 'oferta.borrar', entidad: 'horario', entidad_id: req.params.id });
res.json({ ok: true });
});
// =====================================================================
// Requisitos y titulación
// =====================================================================
rutas.get('/requisitos', exigir('requisitos.ver'), async (_req, res) => {
const niveles = await q('SELECT id, clave, nombre FROM niveles ORDER BY orden');
for (const n of niveles) {
n.requisitos = await q('SELECT id, texto, nota, orden FROM requisitos WHERE nivel_id = ? ORDER BY orden, id', [n.id]);
n.programas = await q('SELECT nombre_corto, color FROM programas WHERE nivel_id = ? AND activo = 1 ORDER BY orden', [n.id]);
}
res.json({
niveles,
titulacion: await q('SELECT id, nombre, descripcion, orden FROM titulacion_opciones ORDER BY orden, id'),
});
});
const CAMPOS_REQUISITO = {
nivel_id: entero({ min: 1, etiqueta: 'El nivel' }),
texto: texto(160, { requerido: true, etiqueta: 'El requisito' }),
nota: texto(200, { etiqueta: 'La nota' }),
orden: entero({ min: 0, max: 127, etiqueta: 'El orden' }),
};
rutas.post('/requisitos', exigir('requisitos.editar'), async (req, res) => {
try {
const { columnas, valores } = validar(req.body, CAMPOS_REQUISITO, { exigirTodos: true });
const r = await exec(
`INSERT INTO requisitos (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
valores,
);
await registrar(req, { accion: 'requisitos.crear', entidad: 'requisito', entidad_id: String(r.insertId), detalle: String(req.body?.texto ?? '').slice(0, 120) });
res.status(201).json({ ok: true, id: r.insertId });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.patch('/requisitos/:id', exigir('requisitos.editar'), async (req, res) => {
try {
const { nivel_id: _omitir, ...editables } = CAMPOS_REQUISITO;
const { columnas, valores, resumen } = validar(req.body, editables);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
const r = await exec(
`UPDATE requisitos SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...valores, Number(req.params.id)],
);
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await registrar(req, { accion: 'requisitos.editar', entidad: 'requisito', entidad_id: req.params.id, detalle: resumen });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.delete('/requisitos/:id', exigir('requisitos.editar'), async (req, res) => {
const fila = await q1('SELECT texto FROM requisitos WHERE id = ?', [Number(req.params.id)]);
if (!fila) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await exec('DELETE FROM requisitos WHERE id = ?', [Number(req.params.id)]);
await registrar(req, { accion: 'requisitos.borrar', entidad: 'requisito', entidad_id: req.params.id, detalle: fila.texto.slice(0, 120) });
res.json({ ok: true });
});
const CAMPOS_TITULACION = {
nombre: texto(64, { requerido: true, etiqueta: 'El nombre' }),
descripcion: texto(255, { etiqueta: 'La descripción' }),
orden: entero({ min: 0, max: 127, etiqueta: 'El orden' }),
};
rutas.post('/titulacion', exigir('requisitos.editar'), async (req, res) => {
try {
const { columnas, valores } = validar(req.body, CAMPOS_TITULACION, { exigirTodos: true });
const r = await exec(
`INSERT INTO titulacion_opciones (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
valores,
);
await registrar(req, { accion: 'requisitos.crear', entidad: 'titulacion', entidad_id: String(r.insertId), detalle: String(req.body?.nombre ?? '') });
res.status(201).json({ ok: true, id: r.insertId });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.patch('/titulacion/:id', exigir('requisitos.editar'), async (req, res) => {
try {
const { columnas, valores, resumen } = validar(req.body, CAMPOS_TITULACION);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
const r = await exec(
`UPDATE titulacion_opciones SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...valores, Number(req.params.id)],
);
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await registrar(req, { accion: 'requisitos.editar', entidad: 'titulacion', entidad_id: req.params.id, detalle: resumen });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.delete('/titulacion/:id', exigir('requisitos.editar'), async (req, res) => {
const r = await exec('DELETE FROM titulacion_opciones WHERE id = ?', [Number(req.params.id)]);
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await registrar(req, { accion: 'requisitos.borrar', entidad: 'titulacion', entidad_id: req.params.id });
res.json({ ok: true });
});
// =====================================================================
// Becas, promociones y fechas de inscripción
// =====================================================================
rutas.get('/becas', exigir('becas.ver'), async (_req, res) => {
res.json({
promociones: await q('SELECT * FROM promociones ORDER BY activo DESC, orden'),
fechas: await q(
`SELECT p.nombre_corto, p.nombre, p.fecha_inicio, p.duracion, p.color, n.nombre AS nivel
FROM programas p JOIN niveles n ON n.id = p.nivel_id
WHERE p.activo = 1 ORDER BY n.orden, p.orden`,
),
escalaReferidos: [1, 2, 3, 4, 5].map((n) => ({
referidos: n,
descuento: n * 20,
mensualidad_licenciatura: 2000 * (1 - n * 0.2),
mensualidad_bachillerato: 1500 * (1 - n * 0.2),
conviene: 2000 * (1 - n * 0.2) > 1500 ? 'beca' : 'referidos',
})),
});
});
const CAMPOS_PROMOCION = {
nombre: texto(120, { requerido: true, etiqueta: 'El nombre' }),
resumen: texto(200, { etiqueta: 'La frase para el interesado' }),
aplica_a: opcion(['inscripcion', 'mensualidad', 'ambos'], { etiqueta: 'A qué aplica' }),
vigencia: texto(120, { etiqueta: 'La vigencia' }),
acumulable: booleano(),
condiciones: texto(900, { etiqueta: 'Las condiciones' }),
activo: booleano(),
};
rutas.post('/becas', exigir('becas.editar'), async (req, res) => {
try {
const { columnas, valores } = validar(req.body, CAMPOS_PROMOCION, { exigirTodos: true });
const clave = `promo-${Date.now().toString(36)}`;
const [{ n }] = await q('SELECT COALESCE(MAX(orden), 0) + 1 AS n FROM promociones');
const r = await exec(
`INSERT INTO promociones (clave, orden, ${columnas.join(', ')}) VALUES (?, ?, ${columnas.map(() => '?').join(', ')})`,
[clave, n, ...valores],
);
await registrar(req, { accion: 'becas.crear', entidad: 'promocion', entidad_id: clave, detalle: String(req.body?.nombre ?? '') });
res.status(201).json({ ok: true, id: r.insertId });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.patch('/becas/:id', exigir('becas.editar'), async (req, res) => {
const actual = await q1('SELECT clave, nombre FROM promociones WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Esa promoción no existe.' });
try {
const { columnas, valores, resumen } = validar(req.body, CAMPOS_PROMOCION);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
await exec(
`UPDATE promociones SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...valores, Number(req.params.id)],
);
await registrar(req, { accion: 'becas.editar', entidad: 'promocion', entidad_id: actual.clave, detalle: `${actual.nombre} · campos: ${resumen}` });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.delete('/becas/:id', exigir('becas.editar'), async (req, res) => {
const actual = await q1('SELECT clave, nombre FROM promociones WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await exec('DELETE FROM promociones WHERE id = ?', [Number(req.params.id)]);
await registrar(req, { accion: 'becas.borrar', entidad: 'promocion', entidad_id: actual.clave, detalle: actual.nombre });
res.json({ ok: true });
});
// =====================================================================
// Links de clases
// =====================================================================
rutas.get('/clases', exigir('clases.ver'), async (req, res) => {
const puedeEditar = puede(req, 'clases.editar');
const clases = await q(
`SELECT c.id, c.programa_id, c.materia, c.docente, c.grupo, c.dias, c.hora_inicio, c.hora_fin,
c.enlace, c.notas, c.activo, c.actualizado_en,
p.nombre_corto AS programa, p.nombre AS programa_nombre, p.clave AS programa_clave, p.color AS programa_color,
n.nombre AS nivel, u.nombre AS actualizado_por
FROM clases c
JOIN programas p ON p.id = c.programa_id
JOIN niveles n ON n.id = p.nivel_id
LEFT JOIN usuarios u ON u.id = c.actualizado_por
${puedeEditar ? '' : 'WHERE c.activo = 1'}
ORDER BY n.orden, p.orden, FIELD(c.dias,'Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo'), c.hora_inicio, c.materia`,
);
res.json({
clases,
programas: await q(
`SELECT p.id, p.nombre_corto, p.nombre, p.color, n.nombre AS nivel
FROM programas p JOIN niveles n ON n.id = p.nivel_id
WHERE p.activo = 1 ORDER BY n.orden, p.orden`,
),
dias: ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'],
puedeEditar,
});
});
const CAMPOS_CLASE = {
programa_id: entero({ min: 1, etiqueta: 'El programa' }),
materia: texto(120, { requerido: true, etiqueta: 'La materia' }),
docente: texto(96, { etiqueta: 'El docente' }),
grupo: texto(48, { etiqueta: 'El grupo' }),
dias: opcion(['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'], { etiqueta: 'El día' }),
// La hora es obligatoria: una clase sin hora aparece en la tabla con el día
// pero sin cuándo, y es justo lo que se le dicta a un alumno.
hora_inicio: hora({ requerido: true, etiqueta: 'La hora de inicio' }),
hora_fin: hora({ requerido: true, etiqueta: 'La hora de fin' }),
enlace: enlace({ requerido: true, etiqueta: 'El link de la clase' }),
notas: texto(300, { etiqueta: 'Las notas' }),
activo: booleano(),
};
rutas.post('/clases', exigir('clases.editar'), async (req, res) => {
try {
const { columnas, valores } = validar(req.body, CAMPOS_CLASE, { exigirTodos: true });
const [{ n }] = await q('SELECT COALESCE(MAX(orden), 0) + 1 AS n FROM clases');
const r = await exec(
`INSERT INTO clases (${columnas.join(', ')}, orden, actualizado_por) VALUES (${columnas.map(() => '?').join(', ')}, ?, ?)`,
[...valores, n, req.usuario.id],
);
await registrar(req, {
accion: 'clases.crear', entidad: 'clase', entidad_id: String(r.insertId),
detalle: `${req.body?.materia ?? ''} · ${req.body?.dias ?? ''} ${req.body?.hora_inicio ?? ''}`,
});
res.status(201).json({ ok: true, id: r.insertId });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.patch('/clases/:id', exigir('clases.editar'), async (req, res) => {
const actual = await q1('SELECT id, materia FROM clases WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Esa clase ya no existe.' });
try {
const { columnas, valores, resumen } = validar(req.body, CAMPOS_CLASE);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
await exec(
`UPDATE clases SET ${columnas.map((c) => `${c} = ?`).join(', ')}, actualizado_por = ? WHERE id = ?`,
[...valores, req.usuario.id, Number(req.params.id)],
);
await registrar(req, { accion: 'clases.editar', entidad: 'clase', entidad_id: req.params.id, detalle: `${actual.materia} · campos: ${resumen}` });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.delete('/clases/:id', exigir('clases.editar'), async (req, res) => {
const actual = await q1('SELECT materia FROM clases WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await exec('DELETE FROM clases WHERE id = ?', [Number(req.params.id)]);
await registrar(req, { accion: 'clases.borrar', entidad: 'clase', entidad_id: req.params.id, detalle: actual.materia });
res.json({ ok: true });
});
// =====================================================================
// Preguntas frecuentes
// =====================================================================
/** Escapa los comodines de LIKE para que buscar "%" no devuelva todo. */
function paraLike(texto) {
return `%${String(texto).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
}
rutas.get('/preguntas', exigir('kb.ver'), async (req, res) => {
const busq = String(req.query.q ?? '').trim().slice(0, 120);
let articulos;
if (busq) {
const like = paraLike(busq);
articulos = await q(
`SELECT id, seccion, pregunta, respuesta, palabras_clave, fuente, orden FROM kb_articulos
WHERE pregunta LIKE ? OR respuesta LIKE ? OR palabras_clave LIKE ?
ORDER BY orden, id`,
[like, like, like],
);
} else {
articulos = await q('SELECT id, seccion, pregunta, respuesta, palabras_clave, fuente, orden FROM kb_articulos ORDER BY orden, id');
}
// GROUP BY en lugar de DISTINCT: con ONLY_FULL_GROUP_BY activo, un
// ORDER BY por una columna fuera del SELECT rompe el DISTINCT.
const todas = await q('SELECT seccion FROM kb_articulos GROUP BY seccion ORDER BY MIN(orden)');
res.json({ secciones: todas.map((s) => s.seccion), articulos, busqueda: busq });
});
const CAMPOS_PREGUNTA = {
seccion: texto(48, { requerido: true, etiqueta: 'La categoría' }),
pregunta: texto(255, { requerido: true, etiqueta: 'La pregunta' }),
respuesta: texto(4000, { requerido: true, etiqueta: 'La respuesta' }),
palabras_clave: texto(255, { etiqueta: 'Las palabras clave' }),
orden: entero({ min: 0, max: 32767, etiqueta: 'El orden' }),
};
rutas.post('/preguntas', exigir('kb.editar'), async (req, res) => {
try {
const { columnas, valores } = validar(req.body, CAMPOS_PREGUNTA, { exigirTodos: true });
const r = await exec(
`INSERT INTO kb_articulos (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
valores,
);
await registrar(req, { accion: 'kb.crear', entidad: 'pregunta', entidad_id: String(r.insertId), detalle: String(req.body?.pregunta ?? '').slice(0, 120) });
res.status(201).json({ ok: true, id: r.insertId });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.patch('/preguntas/:id', exigir('kb.editar'), async (req, res) => {
const actual = await q1('SELECT pregunta FROM kb_articulos WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
try {
const { columnas, valores, resumen } = validar(req.body, CAMPOS_PREGUNTA);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
await exec(
`UPDATE kb_articulos SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...valores, Number(req.params.id)],
);
await registrar(req, { accion: 'kb.editar', entidad: 'pregunta', entidad_id: req.params.id, detalle: `${actual.pregunta.slice(0, 90)} · campos: ${resumen}` });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.delete('/preguntas/:id', exigir('kb.editar'), async (req, res) => {
const actual = await q1('SELECT pregunta FROM kb_articulos WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await exec('DELETE FROM kb_articulos WHERE id = ?', [Number(req.params.id)]);
await registrar(req, { accion: 'kb.borrar', entidad: 'pregunta', entidad_id: req.params.id, detalle: actual.pregunta.slice(0, 120) });
res.json({ ok: true });
});
// =====================================================================
// Mensajes listos
// =====================================================================
rutas.get('/mensajes', exigir('guiones.ver'), async (_req, res) => {
const guiones = await q('SELECT id, categoria, titulo, cuerpo, canal, orden FROM guiones ORDER BY orden, id');
res.json({ categorias: [...new Set(guiones.map((g) => g.categoria))], guiones });
});
const CAMPOS_MENSAJE = {
categoria: texto(48, { requerido: true, etiqueta: 'La categoría' }),
titulo: texto(160, { requerido: true, etiqueta: 'El título' }),
cuerpo: texto(4000, { requerido: true, etiqueta: 'El mensaje' }),
canal: texto(24, { etiqueta: 'El canal' }),
orden: entero({ min: 0, max: 32767, etiqueta: 'El orden' }),
};
rutas.post('/mensajes', exigir('guiones.editar'), async (req, res) => {
try {
const { columnas, valores } = validar(req.body, CAMPOS_MENSAJE, { exigirTodos: true });
const r = await exec(
`INSERT INTO guiones (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
valores,
);
await registrar(req, { accion: 'guiones.crear', entidad: 'mensaje', entidad_id: String(r.insertId), detalle: String(req.body?.titulo ?? '') });
res.status(201).json({ ok: true, id: r.insertId });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.patch('/mensajes/:id', exigir('guiones.editar'), async (req, res) => {
const actual = await q1('SELECT titulo FROM guiones WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
try {
const { columnas, valores, resumen } = validar(req.body, CAMPOS_MENSAJE);
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
await exec(
`UPDATE guiones SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...valores, Number(req.params.id)],
);
await registrar(req, { accion: 'guiones.editar', entidad: 'mensaje', entidad_id: req.params.id, detalle: `${actual.titulo} · campos: ${resumen}` });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
rutas.delete('/mensajes/:id', exigir('guiones.editar'), async (req, res) => {
const actual = await q1('SELECT titulo FROM guiones WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await exec('DELETE FROM guiones WHERE id = ?', [Number(req.params.id)]);
await registrar(req, { accion: 'guiones.borrar', entidad: 'mensaje', entidad_id: req.params.id, detalle: actual.titulo });
res.json({ ok: true });
});
// =====================================================================
// Datos del instituto
// =====================================================================
const GRUPOS_INSTITUCIONAL = {
contacto: 'Contacto y ubicación',
identidad: 'Identidad del instituto',
proposito: 'Misión, visión y valores',
};
rutas.get('/contacto', exigir('contacto.ver'), async (_req, res) => {
const filas = await q('SELECT id, grupo, clave, etiqueta, valor FROM institucional ORDER BY orden, id');
res.json({
grupos: Object.entries(GRUPOS_INSTITUCIONAL).map(([id, titulo]) => ({
id, titulo, campos: filas.filter((f) => f.grupo === id),
})),
});
});
rutas.patch('/contacto/:id', exigir('contacto.editar'), async (req, res) => {
const actual = await q1('SELECT clave, etiqueta FROM institucional WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
try {
const { columnas, valores } = validar(req.body, {
etiqueta: texto(96, { requerido: true, etiqueta: 'El nombre del dato' }),
valor: texto(4000, { requerido: true, etiqueta: 'El contenido' }),
});
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
await exec(
`UPDATE institucional SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...valores, Number(req.params.id)],
);
await registrar(req, { accion: 'contacto.editar', entidad: 'dato', entidad_id: actual.clave, detalle: actual.etiqueta });
res.json({ ok: true });
} catch (err) {
if (responderError(res, err)) return;
throw err;
}
});
// =====================================================================
// Datos por confirmar
// =====================================================================
rutas.patch('/datos/:id', exigir('datos.gestionar'), async (req, res) => {
const estado = String(req.body?.estado ?? '');
if (!['abierto', 'en-proceso', 'aclarado'].includes(estado)) {
return res.status(400).json({ error: 'estado-invalido', mensaje: 'Ese estado no existe.' });
}
const actual = await q1('SELECT titulo FROM discrepancias WHERE id = ?', [Number(req.params.id)]);
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
await exec('UPDATE discrepancias SET estado = ? WHERE id = ?', [estado, Number(req.params.id)]);
await registrar(req, { accion: 'datos.editar', entidad: 'dato-por-confirmar', entidad_id: req.params.id, detalle: `${actual.titulo.slice(0, 90)} → ${estado}` });
res.json({ ok: true });
});
// =====================================================================
// Equipo
// =====================================================================
rutas.get('/equipo', exigir('equipo.ver'), async (_req, res) => {
const usuarios = await q(
`SELECT u.id, u.nombre, u.email, u.puesto, u.iniciales, u.extension, u.activo, u.ultimo_acceso,
r.clave AS rol_clave, r.nombre AS rol_nombre, r.nivel AS rol_nivel, r.color AS rol_color
FROM usuarios u JOIN roles r ON r.id = u.rol_id ORDER BY r.nivel DESC, u.nombre`,
);
const roles = await q('SELECT id, clave, nombre, nivel, color, descripcion FROM roles ORDER BY nivel DESC');
const matriz = await q(
`SELECT r.clave AS rol, p.clave AS permiso FROM roles_permisos rp
JOIN roles r ON r.id = rp.rol_id JOIN permisos p ON p.id = rp.permiso_id`,
);
const porRol = {};
for (const r of roles) porRol[r.clave] = matriz.filter((m) => m.rol === r.clave).map((m) => m.permiso);
res.json({
usuarios, roles, porRol,
permisos: await q('SELECT clave, seccion, descripcion FROM permisos ORDER BY seccion, clave'),
secciones: SECCIONES.map(({ id, titulo, permisos }) => ({ id, titulo, permisos })),
});
});
// =====================================================================
// Bitácora
// =====================================================================
rutas.get('/bitacora', exigir('bitacora.ver'), async (req, res) => {
// Math.trunc: sin él, un limite=50.5 se interpola tal cual en el LIMIT y
// MySQL rechaza la consulta con un 500.
const limite = Math.trunc(Math.min(200, Math.max(10, Number(req.query.limite) || 40)));
// Las listas van acotadas a propósito: en una demo que se prueba mucho, las
// sesiones se acumulan y sin tope la página crecería sin control.
const [{ n: sesionesTotal }] = await q(
'SELECT COUNT(*) AS n FROM sesiones WHERE expira_en > UTC_TIMESTAMP()',
);
const [{ n: eventosTotal }] = await q('SELECT COUNT(*) AS n FROM bitacora');
res.json({
eventosTotal,
sesionesTotal,
entradas: await q(
`SELECT id, actor, rol, accion, entidad, entidad_id, resultado, detalle, ip_origen, creado_en
FROM bitacora ORDER BY id DESC LIMIT ${limite}`,
),
sesionesActivas: await q(
`SELECT u.nombre, u.iniciales, r.nombre AS rol, s.creada_en, s.expira_en, s.ip_origen
FROM sesiones s JOIN usuarios u ON u.id = s.usuario_id JOIN roles r ON r.id = u.rol_id
WHERE s.expira_en > UTC_TIMESTAMP() ORDER BY s.creada_en DESC LIMIT 25`,
),
enlaces: await q(
`SELECT u.nombre, t.creado_en, t.expira_en, t.usado_en, t.ip_origen
FROM magic_tokens t JOIN usuarios u ON u.id = t.usuario_id ORDER BY t.id DESC LIMIT 15`,
),
});
});