Files
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

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));