Files
Uriel JarethandClaude Opus 5 a3a00996e1 README: documentar el despliegue y las dos trampas que costaron
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]>
2026-07-30 00:06:46 -06:00

17 KiB
Raw Permalink Blame History

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=true deja 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 en false y 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

  1. Escribes tu correo y el servidor crea un token de 32 bytes; en la base solo guarda su SHA-256.
  2. El enlace se muestra en pantalla (en producción llegaría por correo) y sirve una sola vez.
  3. Caduca a los 15 minutos. Al canjearlo nace una sesión de 8 horas.
  4. La cookie es HttpOnly y SameSite; 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-motion respetado, 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; y Escape cierra 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

  1. Las variables de entorno de Coolify se guardan cifradas. Escribirlas con un INSERT directo deja un valor que Coolify no puede descifrar, y al normalizar el compose escribe null. Hay que crearlas con el propio modelo: docker exec coolify php artisan tinker --execute="\App\Models\EnvironmentVariable::create([...])".
  2. 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 y web abortaba con dependency failed to start. Por eso el portal acepta los nombres nativos MYSQL_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

  1. Enviar el enlace por correo en lugar de mostrarlo en pantalla: MOSTRAR_ENLACE=false ya deja de exponerlo, falta conectar el proveedor de correo.
  2. HTTPS, y con él COOKIE_SEGURA=true y CONFIAR_PROXY=true.
  3. Alta y baja de usuarios desde el portal. Hoy el equipo se siembra en db/data.mjs; el permiso equipo.ver existe pero no hay pantalla para dar de alta a alguien.
  4. 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.
  5. 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.
  6. 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.