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