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