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]>
This commit is contained in:
Uriel Jareth
2026-07-29 23:20:34 -06:00
co-authored by Claude Opus 5
commit 0b9c9c1dc2
34 changed files with 8307 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
# 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.
> **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
```
---
## 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.