// ===================================================================== // 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`, ), }); });