Anade la URL publica, el diagrama Gitea -> espejo en GitHub -> Coolify, como publicar un cambio, como verificar contra el despliegue con BASE, y como devolver la demo a su estado inicial con SEMBRAR_SIEMPRE. Deja escritas las dos cosas que hicieron fallar los dos primeros despliegues: que las variables de entorno de Coolify van cifradas (hay que crearlas con artisan tinker, no con INSERT), y que en el compose deben ir en lista y por nombre pelado, sin interpolar. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Portal interno INSTA
Base de conocimiento para el personal del Instituto Santiago Tapia: la oferta educativa, los requisitos, las becas y fechas, los links de clase, las preguntas frecuentes y los datos del instituto, todo en un solo lugar y con Magic Login.
El caso de uso es concreto: que quien está atendiendo a un interesado encuentre el dato exacto en segundos y lo diga sin equivocarse. Y que cuando un dato cambie, dirección lo edite desde el portal en lugar de avisar por WhatsApp.
Está pensado para leerse cómodo: texto de 17 px, contrastes altos, botones grandes, tema claro y una sola columna en el teléfono.
En línea: https://instademo.urieljareth.org
Es una demostración. Los tres usuarios de acceso rápido son ficticios y los links de Google Meet son de ejemplo. La oferta educativa, los costos, las becas, los requisitos y los datos de contacto sí son los reales del instituto.
Arrancar
Requisitos: Docker y Node 20+ (probado en Node 26).
cd insta-portal
docker compose up -d # MySQL 8 en el puerto 3307
npm install
npm run db:reset # crea el esquema y siembra los datos
npm start # http://localhost:4180
| Comando | Qué hace |
|---|---|
npm start |
Levanta el portal en el puerto 4180 |
npm run dev |
Igual, recargando al guardar |
npm run db:reset |
Reconstruye el esquema y vuelve a sembrar (borra las ediciones de la demo) |
npm run reiniciar |
Libera el puerto, siembra y arranca de nuevo, en un paso (Windows) |
npm run test:api |
103 comprobaciones de la API, sin navegador |
npm test |
51 pruebas en WebKit escritorio y en iPhone 13 |
npm run capturas |
Genera las capturas de todas las secciones en tests/capturas/ |
npm run codificacion |
Avisa si algún archivo quedó con los acentos rotos |
Variables de entorno
Todas tienen valor por omisión; para correr en local no hace falta ninguna.
| Variable | Por omisión | Para qué |
|---|---|---|
PORT |
4180 |
Puerto del portal |
DB_HOST DB_PORT DB_USER DB_PASSWORD DB_NAME |
127.0.0.1 3307 root insta_root_demo insta_portal |
Conexión a MySQL |
MAGIC_LINK_MINUTOS |
15 |
Vigencia del enlace de acceso |
SESION_MINUTOS |
480 |
Vigencia de la sesión |
MOSTRAR_ENLACE |
true |
Muestra el enlace en pantalla en lugar de enviarlo por correo. Ver la advertencia de abajo. |
COOKIE_SEGURA |
false |
Pon true detrás de HTTPS: añade Secure y SameSite=Strict |
CONFIAR_PROXY |
false |
Pon true detrás de un proxy para que la IP real llegue a la bitácora y al límite de enlaces |
ENLACES_POR_VENTANA VENTANA_MINUTOS |
60 5 |
Cuántos enlaces se pueden pedir desde un mismo origen |
⚠️
MOSTRAR_ENLACE=truedeja la demo abierta a propósito. Con esa opción, quien conozca un correo dado de alta obtiene una sesión sin pasar por el correo. Es lo que permite que cualquiera pruebe la demostración con un clic. Para uso real hay que ponerlo enfalsey conectar el envío por correo.
Accesos de la demostración
| Rol | Correo | Ve | Edita |
|---|---|---|---|
| Dirección | [email protected] |
10 secciones | Todo el contenido |
| Personal administrativo | [email protected] |
9 | Solo los links de clases |
| Empleado · ventas y atención | [email protected] |
8 | Nada, solo consulta |
Hay dos usuarios más en la base ([email protected] y [email protected]) que no
salen en el acceso rápido; se puede entrar con ellos escribiendo su correo.
Cómo funciona el Magic Login
- Escribes tu correo y el servidor crea un token de 32 bytes; en la base solo guarda su SHA-256.
- El enlace se muestra en pantalla (en producción llegaría por correo) y sirve una sola vez.
- Caduca a los 15 minutos. Al canjearlo nace una sesión de 8 horas.
- La cookie es
HttpOnlyySameSite; en la base solo vive el hash del id de sesión.
El canje es atómico: el token se reclama con un solo UPDATE condicional, así que dos canjes
simultáneos del mismo enlace no pueden crear dos sesiones. No hay contraseñas en el sistema.
Quién puede editar qué
Son 19 permisos con la forma sección.acción. El menú se pinta con lo que el servidor autoriza
y cada endpoint exige la misma clave, así que no hay pantallas alcanzables escribiendo la
dirección a mano: la API responde 403 y el intento queda en la bitácora.
La regla es corta: el catálogo lo edita dirección; los links de clases, dirección y administración; el resto del equipo consulta.
| Sección | Dirección | Administración | Empleado |
|---|---|---|---|
| Inicio | ● | ● | ● |
| Oferta educativa | ● edita | ● lee | ● lee |
| Requisitos y titulación | ● edita | ● lee | ● lee |
| Becas y fechas | ● edita | ● lee | ● lee |
| Links de clases | ● edita | ● edita | ● lee |
| Preguntas frecuentes | ● edita | ● lee | ● lee |
| Mensajes listos | ● edita | ● lee | ● lee |
| Datos del instituto | ● edita | ● lee | ● lee |
| Datos por confirmar (dentro de Oferta) | ● gestiona | ● lee | ○ |
| Equipo | ● | ● | ○ |
| Bitácora | ● | ○ | ○ |
Las secciones
| Sección | Qué tiene |
|---|---|
| Inicio | La promoción vigente, atajos a cada sección y los datos que más se dictan por teléfono. Dirección ve además los últimos cambios al contenido. |
| Oferta educativa | Los 8 programas en tarjetas y en tabla (se elige con un botón y la elección se recuerda), con costo con y sin beca, duración, fecha de inicio, horarios por modalidad y estado de la validez oficial. |
| Requisitos y titulación | Las listas de documentos por nivel, con botón para copiarlas, y las 5 formas de titularse. |
| Becas y fechas | Los 5 esquemas de precio con sus condiciones completas, cuándo inicia cada programa, y la escala de referidos con la comparación honesta contra la beca. |
| Links de clases | Los 27 enlaces de Google Meet agrupados por programa, con materia, día y hora, docente y grupo. Botón para entrar y para copiar el link. |
| Preguntas frecuentes | 21 respuestas verificadas, buscables. |
| Mensajes listos | 21 textos ya redactados para copiar y enviar por WhatsApp, filtrables por categoría. |
| Datos del instituto | Contacto, ubicación, horarios, misión, visión y valores. |
| Equipo | El directorio y la matriz completa de qué puede ver y editar cada rol, permiso por permiso. |
| Bitácora | Sesiones abiertas, enlaces generados y el registro de accesos y cambios, con los intentos denegados. |
Editar
Cada sección editable trae un botón de lápiz que abre una ventana con el formulario. Todo lo que se
guarda pasa por una lista blanca de campos validados en el servidor (server/campos.mjs): un
precio vacío no se guarda como cero, un link que no es una dirección se rechaza con un mensaje
claro, y no se puede marcar la validez oficial como confirmada sin número de acuerdo. Cada cambio
queda en la bitácora con quién lo hizo.
De dónde salen los datos
| Fuente | Qué aportó |
|---|---|
INSTA - Base de conocimiento - Oferta Educativa.csv |
Los 8 programas: nivel, duración, fechas, costos, beneficios, horarios y promociones |
Base de conocimiento — Instituto Santiago Tapia (INSTA).md |
Identidad, contacto, modalidades, becas, RVOE, requisitos, titulación y los datos por confirmar |
docs/crm/01-microreporte-atencion.md |
Los errores de atención reales que los mensajes listos corrigen |
Los links de clase, las materias y los docentes son de demostración. Todo lo demás es real.
Lo que el portal se niega a inventar
La fuente tiene huecos, y el portal los trata como huecos en lugar de rellenarlos. En Oferta educativa dirección y administración ven un panel de datos por confirmar:
- Bachillerato y las dos maestrías no tienen RVOE confirmado. Salen marcados, con la nota de por qué el número que traían las listas viejas es incorrecto.
- Trabajo Social figura ante la Secretaría de Educación de Guerrero y no ante Centro Educativo Puebla como el resto: queda marcado como «por revisar».
- ¿La beca del 25% aplica solo el primer ciclo o toda la carrera? La fuente dice «primer ciclo», pero en una conversación se le prometió a una interesada que quedaba congelado 3 años. Sin definir.
- Las fechas de inicio vienen del archivo de oferta y están marcadas como pendientes de confirmar.
Y lo que sí está resuelto se dice resuelto: el programa de referidos no se combina con nada, pero la beca del 25% sí se combina con el descuento de temporada — que es como el equipo ya lo ofrece por WhatsApp.
Cómo está hecho
insta-portal/
├─ docker-compose.yml MySQL 8 en el 3307, con volumen persistente
├─ db/
│ ├─ schema.sql 19 tablas con llaves foráneas e índices
│ ├─ data.mjs todos los datos, con su fuente citada
│ └─ seed.mjs reconstruye y verifica la siembra
├─ server/
│ ├─ index.mjs Express, cabeceras, estáticos y /acceso/:token
│ ├─ config.mjs configuración con valores por omisión
│ ├─ db.mjs pool de mysql2, todo con consultas preparadas
│ ├─ auth.mjs Magic Login y sesiones (en la base solo hashes)
│ ├─ rbac.mjs secciones, permisos y middlewares
│ ├─ campos.mjs validación de lo que se puede editar
│ ├─ bitacora.mjs registro de auditoría
│ └─ rutas.mjs la API, con un permiso declarado por endpoint
├─ public/
│ ├─ index.html entrada · Magic Login
│ ├─ portal.html shell del portal
│ ├─ css/insta.css tokens de marca y todos los estilos
│ └─ js/ ui (incluye el editor), entrada, app y las vistas
├─ scripts/
│ ├─ reiniciar.ps1 libera el puerto, siembra y arranca
│ └─ revisar-codificacion.mjs detecta acentos rotos tras una edición masiva
└─ tests/
├─ api.mjs 103 comprobaciones sin navegador
├─ portal.spec.mjs 51 pruebas en WebKit
└─ capturas.mjs capturas de todas las secciones
Sin framework de frontend y sin paso de compilación: dos dependencias en producción
(express y mysql2). El portal es una sola página con ruteo por hash; cada sección pide sus datos
a la API y se pinta sola.
Base de datos
19 tablas, 217 filas sembradas: roles, permisos, roles_permisos, usuarios, magic_tokens,
sesiones, bitacora, niveles, modalidades, programas, programas_modalidades,
requisitos, titulacion_opciones, promociones, clases, guiones, kb_articulos,
institucional, discrepancias.
Todas las consultas van preparadas. Los nombres de columna de un UPDATE solo pueden venir de la
lista blanca de cada entidad, nunca del cuerpo de la petición.
Diseño
La geometría sale del logo del instituto: una placa con radio grande en la esquina superior izquierda e inferior derecha, y casi recto en las otras dos. Esa forma se repite en cada superficie.
| Token | Valor | De dónde |
|---|---|---|
| Azul | #4275B7 |
muestreado del logo |
| Azul profundo | #30517E |
muestreado del logo |
| Azul panel | #9DC2E8 |
el panel interior del logo |
| Amarillo | #EFEF07 |
el contorno de las letras del logo |
El amarillo se usa solo como marcador sobre el dato más importante de cada pantalla, igual que alguien subraya la lista de precios impresa. Títulos en serif Palatino; monoespaciada para dinero, horas y números de acuerdo, que son datos que se leen dígito por dígito y se dictan por teléfono.
Decisiones tomadas por el público que lo va a usar:
- Texto base de 17 px y ningún texto informativo por debajo de 14 px.
- Contraste AA o mejor: el color de un programa o de un rol nunca se usa como color de texto, solo como fondo tenue, borde o punto.
- Todo lo que se toca mide al menos 44 px de alto.
- En el teléfono, los bloques largos llegan plegados con un resumen, y si los abres se quedan abiertos aunque la sección se repinte al guardar un cambio.
prefers-reduced-motionrespetado, e impresión limpia.
Verificación
npm run test:api # 103 comprobaciones
npm test # 51 pruebas × WebKit escritorio y iPhone 13 = 102 ejecuciones
Todo en verde. Lo que cubren:
- Magic Login: los tres accesos, correo no registrado, enlace inventado, enlace ya usado, y que cinco canjes simultáneos del mismo enlace creen una sola sesión.
- Permisos: el número exacto de secciones por rol; que la API niegue con 403 los diez endpoints de escritura al empleado y los seis del catálogo a administración; que administración sí pueda crear, editar y borrar una clase; que sin sesión responda 401; y que el intento denegado aparezca en la bitácora.
- Contenido contra la fuente: los 8 programas, los precios de Psicología y de Bachillerato, el acuerdo 20110687, los tres programas sin número de acuerdo, las 6 fotografías del bachillerato contra las 4 de licenciatura, las 5 formas de titulación, los 27 links en 8 programas, y que el único esquema no combinable sea el de referidos.
- Edición: que dirección guarde un cambio y quede registrado quién lo hizo; que un precio vacío
se rechace en lugar de guardarse como
$0; que"$2,500"se guarde como 2500 y no como 2.50; que no se pueda confirmar una validez oficial sin acuerdo; que una clase sin hora se rechace; y que editar dos veces seguidas no abra dos ventanas. - Entradas raras: buscar
%,limite=50.5, un texto de 300 000 caracteres, un JSON roto, un id que no es número, una cookie con%mal formado. Ninguna devuelve 500 ni cuelga la petición. - Legibilidad y responsividad: texto base de 17 px; tema claro incluso si el sistema pide
oscuro; alturas mínimas de toque; cero desbordamiento horizontal en las 10 secciones a 390, 768,
1024 y 1440 px; ninguna sección pasa de 12 000 px de alto en el teléfono; el cajón móvil abre,
navega y cierra; la ventana de edición cabe en un teléfono;
aria-current; navegación por teclado; yEscapecierra el editor sin guardar.
Para revisar el portal completo de un vistazo:
npm run capturas # abre tests/capturas/index.html
El despliegue
Vive en https://instademo.urieljareth.org, sobre el Coolify self-hosted. El repositorio principal
está en Gitea y GitHub es solo un espejo, porque Coolify construye desde GitHub.
Gitea (urieljareth/insta-portal) ← repositorio principal
└─ espejo → GitHub (urieljarethbusiness-cpu/insta-portal)
└─ Coolify (app 52, build_pack dockercompose)
├─ mysql (mysql:8.0, volumen con nombre)
└─ web (Dockerfile, puerto 4180) ← Traefik ← Cloudflare Tunnel
Para publicar un cambio:
git push gitea main && git push github main
# y luego, desde el repo del manager:
# Invoke-RestMethod "$env:COOLIFY_API_URL/deploy?uuid=instademo0portal0insta0demo1&force=true" -Headers @{ Authorization = "Bearer $env:COOLIFY_TOKEN" }
Verificar el despliegue con la misma suite de siempre:
$env:BASE="https://instademo.urieljareth.org"
node tests/api.mjs # 103 comprobaciones
npx playwright test # 51 pruebas × 2 = 102
Dos cosas que costaron y conviene recordar
- Las variables de entorno de Coolify se guardan cifradas. Escribirlas con un
INSERTdirecto deja un valor que Coolify no puede descifrar, y al normalizar el compose escribenull. Hay que crearlas con el propio modelo:docker exec coolify php artisan tinker --execute="\App\Models\EnvironmentVariable::create([...])". - En el compose, las variables van en lista y por nombre pelado. Con
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}el valor llegaba vacío, MySQL se negaba a inicializar ywebabortaba condependency failed to start. Por eso el portal acepta los nombres nativosMYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD: un solo juego de variables alimenta a los dos contenedores, sin interpolar nada.
Devolver la demo a su estado inicial
El volumen de MySQL sobrevive a los redespliegues, así que lo que el equipo edite se queda. Para
volver al contenido original: poner la variable SEMBRAR_SIEMPRE en true en Coolify, redesplegar,
y volverla a false.
Lo que falta para producción
- Enviar el enlace por correo en lugar de mostrarlo en pantalla:
MOSTRAR_ENLACE=falseya deja de exponerlo, falta conectar el proveedor de correo. - HTTPS, y con él
COOKIE_SEGURA=trueyCONFIAR_PROXY=true. - Alta y baja de usuarios desde el portal. Hoy el equipo se siembra en
db/data.mjs; el permisoequipo.verexiste pero no hay pantalla para dar de alta a alguien. - Historial de cambios por registro. La bitácora dice quién cambió qué sección y cuándo, pero no guarda el valor anterior, así que no se puede deshacer.
- Aviso de edición simultánea. Si dos personas editan el mismo programa a la vez, gana la última en guardar y la primera no se enteraría.
- Cerrar los datos por confirmar con dirección. Mientras el RVOE del bachillerato y de las maestrías siga sin número, el equipo trabaja con un hueco.