Files
insta-portal/server/campos.mjs
T
Uriel JarethandClaude Opus 5 0b9c9c1dc2 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]>
2026-07-29 23:20:34 -06:00

175 lines
7.1 KiB
JavaScript

// =====================================================================
// Validación de campos editables
// ---------------------------------------------------------------------
// Cada entidad editable declara un mapa columna → validador. Solo las
// columnas declaradas llegan al UPDATE, y siempre por consulta preparada.
// Un campo ausente en el cuerpo no se toca; uno presente e inválido
// aborta la petición con un mensaje que se le puede mostrar a la persona.
// =====================================================================
class ErrorCampo extends Error {
constructor(campo, mensaje) {
super(mensaje);
this.campo = campo;
this.esErrorCampo = true;
}
}
const vacioONulo = (v) => v === null || v === undefined || v === '';
/**
* Solo se aceptan primitivos. Un objeto o un arreglo en el JSON acabaría
* guardado como "[object Object]" o como sus elementos unidos por comas.
*/
function comoTexto(v, campo, nombre) {
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
throw new ErrorCampo(campo, `${nombre} tiene que ser texto.`);
}
export const texto = (max, { requerido = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
return '';
}
const s = comoTexto(v, campo, nombre).trim();
if (requerido && !s) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
if (s.length > max) throw new ErrorCampo(campo, `${nombre} no puede pasar de ${max} caracteres. Lleva ${s.length}.`);
return s;
};
/**
* Cantidad en pesos. Un campo vacío NO se toma como cero: eso publicaría un
* programa gratis sin que nadie lo note. Se acepta "$1,500" tal como se
* escribe un precio, porque el formulario lo manda como texto.
*/
export const dinero = ({ min = 0, max = 999999, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío. Si el concepto no se cobra, escribe 0.`);
const limpio = comoTexto(v, campo, nombre).replace(/[$\s,]/g, '');
if (!/^\d+(\.\d{1,2})?$/.test(limpio)) {
throw new ErrorCampo(campo, `${nombre} tiene que ser una cantidad en pesos, por ejemplo 1500 o $1,500.`);
}
const n = Number(limpio);
if (n < min || n > max) {
throw new ErrorCampo(campo, `${nombre} tiene que estar entre ${min} y ${max.toLocaleString('es-MX')} pesos.`);
}
return Math.round(n * 100) / 100;
};
export const entero = ({ min = 0, max = 32767, nulable = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (nulable) return null;
throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
}
const limpio = comoTexto(v, campo, nombre).trim();
if (!/^-?\d+$/.test(limpio)) throw new ErrorCampo(campo, `${nombre} tiene que ser un número entero.`);
const n = Number(limpio);
if (n < min || n > max) throw new ErrorCampo(campo, `${nombre} tiene que estar entre ${min} y ${max}.`);
return n;
};
export const booleano = () => (v) => (v === true || v === 1 || v === '1' || v === 'true' ? 1 : 0);
export const opcion = (valores, { etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
const s = comoTexto(v, campo, nombre);
if (!valores.includes(s)) {
throw new ErrorCampo(campo, `${nombre} solo puede ser: ${valores.join(', ')}.`);
}
return s;
};
export const fecha = ({ etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) return null;
const s = comoTexto(v, campo, nombre).slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw new ErrorCampo(campo, `${nombre} debe tener el formato AAAA-MM-DD.`);
const d = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s) {
throw new ErrorCampo(campo, `${nombre} no es una fecha que exista.`);
}
return s;
};
/** Hora en formato de 24 horas, "20:00". Se normaliza a dos dígitos. */
export const hora = ({ requerido = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacía. Escríbela así: 20:00.`);
return '';
}
const m = /^(\d{1,2}):(\d{2})$/.exec(comoTexto(v, campo, nombre).trim());
if (!m) throw new ErrorCampo(campo, `${nombre} debe verse así: 20:00.`);
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59) throw new ErrorCampo(campo, `${nombre} no es una hora válida.`);
return `${String(h).padStart(2, '0')}:${m[2]}`;
};
/** Enlace de reunión. Solo http/https; se rechaza cualquier otro esquema. */
export const enlace = ({ requerido = false, etiqueta } = {}) => (v, campo) => {
const nombre = etiqueta ?? campo;
if (vacioONulo(v)) {
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
return '';
}
const s = comoTexto(v, campo, nombre).trim();
let u;
try {
u = new URL(s);
} catch {
throw new ErrorCampo(campo, `${nombre} debe ser una dirección completa, empezando con https://`);
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new ErrorCampo(campo, `${nombre} debe empezar con https:// o http://`);
}
// El largo se comprueba sobre el valor normalizado: new URL() puede añadir
// una barra final, y un enlace de justo 500 caracteres se pasaría de la
// columna y reventaría al guardarse.
const normalizado = u.toString();
if (normalizado.length > 500) throw new ErrorCampo(campo, `${nombre} es demasiado largo: no puede pasar de 500 caracteres.`);
return normalizado;
};
/**
* Aplica el mapa de validadores al cuerpo de la petición.
* @returns {{columnas: string[], valores: any[], resumen: string}}
*/
export function validar(cuerpo, mapa, { exigirTodos = false } = {}) {
const columnas = [];
const valores = [];
for (const [columna, validador] of Object.entries(mapa)) {
const presente = Object.prototype.hasOwnProperty.call(cuerpo ?? {}, columna);
if (!presente) {
if (!exigirTodos) continue;
// En una creación, un campo ausente se valida como vacío para que
// el validador decida si era obligatorio.
columnas.push(columna);
valores.push(validador(undefined, columna));
continue;
}
columnas.push(columna);
valores.push(validador(cuerpo[columna], columna));
}
return { columnas, valores, resumen: columnas.join(', ') };
}
/** Convierte un error de validación o de llave foránea en una respuesta 400. */
export function responderError(res, err) {
if (err?.esErrorCampo) {
return res.status(400).json({ error: 'campo-invalido', campo: err.campo, mensaje: err.message });
}
if (err?.code === 'ER_NO_REFERENCED_ROW_2' || err?.code === 'ER_NO_REFERENCED_ROW') {
return res.status(400).json({ error: 'referencia-invalida', mensaje: 'Elegiste una opción que ya no existe. Recarga la página e inténtalo de nuevo.' });
}
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'duplicado', mensaje: 'Ya existe un registro con ese dato.' });
}
return null;
}
export { ErrorCampo };