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]>
346 lines
17 KiB
Markdown
346 lines
17 KiB
Markdown
# 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.
|