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]>
175 lines
7.1 KiB
JavaScript
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 };
|