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]>
91 lines
3.4 KiB
JavaScript
91 lines
3.4 KiB
JavaScript
// =====================================================================
|
|
// Arranque del contenedor
|
|
// ---------------------------------------------------------------------
|
|
// Espera a que MySQL responda, siembra la base si está vacía y entonces
|
|
// levanta el portal. Así el primer despliegue queda listo para usarse sin
|
|
// que nadie tenga que entrar a correr comandos.
|
|
//
|
|
// SEMBRAR_SIEMPRE=true vuelve a sembrar en cada arranque: sirve para
|
|
// devolver la demostración a su estado inicial después de que la hayan
|
|
// probado. Por omisión NO se hace, para no borrar las ediciones.
|
|
// =====================================================================
|
|
import mysql from 'mysql2/promise';
|
|
import { spawn } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { config } from '../server/config.mjs';
|
|
|
|
const raiz = path.resolve(import.meta.dirname, '..');
|
|
const REINTENTOS = Number(process.env.ESPERA_DB_INTENTOS ?? 60);
|
|
const PAUSA_MS = Number(process.env.ESPERA_DB_PAUSA_MS ?? 2000);
|
|
|
|
const pausa = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
async function esperarBase() {
|
|
for (let i = 1; i <= REINTENTOS; i++) {
|
|
try {
|
|
const cn = await mysql.createConnection({ ...config.db, connectTimeout: 4000 });
|
|
await cn.query('SELECT 1');
|
|
await cn.end();
|
|
console.log(` ✔ MySQL responde (intento ${i})`);
|
|
return true;
|
|
} catch (err) {
|
|
if (i === 1 || i % 5 === 0) {
|
|
console.log(` … esperando a MySQL en ${config.db.host}:${config.db.port} (${i}/${REINTENTOS}) · ${err.code ?? err.message}`);
|
|
}
|
|
await pausa(PAUSA_MS);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function hayDatos() {
|
|
const cn = await mysql.createConnection(config.db);
|
|
try {
|
|
const [filas] = await cn.query(
|
|
`SELECT COUNT(*) AS n FROM information_schema.tables
|
|
WHERE table_schema = ? AND table_name = 'programas'`,
|
|
[config.db.database],
|
|
);
|
|
if (!filas[0].n) return false;
|
|
const [[{ n }]] = await cn.query('SELECT COUNT(*) AS n FROM programas');
|
|
return n > 0;
|
|
} finally {
|
|
await cn.end();
|
|
}
|
|
}
|
|
|
|
function correr(script) {
|
|
return new Promise((resolver, rechazar) => {
|
|
const hijo = spawn(process.execPath, [path.join(raiz, script)], { stdio: 'inherit', cwd: raiz });
|
|
hijo.on('exit', (codigo) => (codigo === 0 ? resolver() : rechazar(new Error(`${script} salió con código ${codigo}`))));
|
|
hijo.on('error', rechazar);
|
|
});
|
|
}
|
|
|
|
console.log('\n▸ Portal INSTA · arranque\n');
|
|
|
|
if (!await esperarBase()) {
|
|
console.error(`\n✖ MySQL no respondió en ${config.db.host}:${config.db.port} después de ${REINTENTOS} intentos.\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const forzar = String(process.env.SEMBRAR_SIEMPRE ?? '').toLowerCase() === 'true';
|
|
if (forzar) {
|
|
console.log(' SEMBRAR_SIEMPRE=true · se reconstruye la base desde cero');
|
|
await correr('db/seed.mjs');
|
|
} else if (await hayDatos()) {
|
|
console.log(' ✔ la base ya tiene datos: se conservan las ediciones');
|
|
} else {
|
|
console.log(' la base está vacía: se siembra por primera vez');
|
|
await correr('db/seed.mjs');
|
|
}
|
|
|
|
console.log('\n▸ Levantando el portal\n');
|
|
// Se reemplaza este proceso por el servidor para que las señales de Docker
|
|
// (SIGTERM al redeplegar) lleguen al proceso que de verdad atiende.
|
|
const servidor = spawn(process.execPath, [path.join(raiz, 'server/index.mjs')], { stdio: 'inherit', cwd: raiz });
|
|
for (const senal of ['SIGTERM', 'SIGINT']) {
|
|
process.on(senal, () => servidor.kill(senal));
|
|
}
|
|
servidor.on('exit', (codigo) => process.exit(codigo ?? 0));
|