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:
@@ -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`,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user