Files
insta-portal/README.md
T
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

346 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
```bash
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
```bash
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:
```bash
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:
```bash
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:
```powershell
$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.