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:
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
tests
|
||||
test-results
|
||||
playwright.config.mjs
|
||||
scripts
|
||||
docker-compose.yml
|
||||
docker-compose.coolify.yml
|
||||
README.md
|
||||
server.log
|
||||
server.err.log
|
||||
server.err2.log
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# Salida de las pruebas y las capturas
|
||||
test-results/
|
||||
tests/capturas/
|
||||
tests/reporte/
|
||||
playwright-report/
|
||||
|
||||
# Registros del servidor local
|
||||
*.log
|
||||
|
||||
# Nada de secretos en el repositorio
|
||||
.env
|
||||
.env.local
|
||||
.env.coolify
|
||||
*.local.ps1
|
||||
|
||||
# Basura del sistema
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# =====================================================================
|
||||
# Portal interno INSTA
|
||||
# Node 22 sobre Alpine. Sin paso de compilación: el portal se sirve tal
|
||||
# como está en public/, así que la imagen solo instala las dos
|
||||
# dependencias de producción (express y mysql2).
|
||||
# =====================================================================
|
||||
FROM node:22-alpine
|
||||
|
||||
# curl para el healthcheck; tini para que las señales lleguen bien.
|
||||
RUN apk add --no-cache curl tini
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Las dependencias van primero para aprovechar la caché de capas: solo se
|
||||
# reinstalan cuando cambia el manifiesto, no en cada cambio de código.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY server ./server
|
||||
COPY db ./db
|
||||
COPY public ./public
|
||||
|
||||
# El puerto real del portal. Traefik apunta aquí; nunca se publica 80 ni 443.
|
||||
ENV PORT=4180
|
||||
EXPOSE 4180
|
||||
|
||||
# No corre como root.
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:4180/salud || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
# arranque.mjs espera a MySQL, siembra si hace falta y levanta el servidor.
|
||||
CMD ["node", "db/arranque.mjs"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,90 @@
|
||||
// =====================================================================
|
||||
// Arranque del contenedor
|
||||
// ---------------------------------------------------------------------
|
||||
// Espera a que MySQL responda, siembra la base si está vacía y entonces
|
||||
// levanta el portal. Así el primer despliegue queda listo para usarse sin
|
||||
// que nadie tenga que entrar a correr comandos.
|
||||
//
|
||||
// SEMBRAR_SIEMPRE=true vuelve a sembrar en cada arranque: sirve para
|
||||
// devolver la demostración a su estado inicial después de que la hayan
|
||||
// probado. Por omisión NO se hace, para no borrar las ediciones.
|
||||
// =====================================================================
|
||||
import mysql from 'mysql2/promise';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { config } from '../server/config.mjs';
|
||||
|
||||
const raiz = path.resolve(import.meta.dirname, '..');
|
||||
const REINTENTOS = Number(process.env.ESPERA_DB_INTENTOS ?? 60);
|
||||
const PAUSA_MS = Number(process.env.ESPERA_DB_PAUSA_MS ?? 2000);
|
||||
|
||||
const pausa = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function esperarBase() {
|
||||
for (let i = 1; i <= REINTENTOS; i++) {
|
||||
try {
|
||||
const cn = await mysql.createConnection({ ...config.db, connectTimeout: 4000 });
|
||||
await cn.query('SELECT 1');
|
||||
await cn.end();
|
||||
console.log(` ✔ MySQL responde (intento ${i})`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (i === 1 || i % 5 === 0) {
|
||||
console.log(` … esperando a MySQL en ${config.db.host}:${config.db.port} (${i}/${REINTENTOS}) · ${err.code ?? err.message}`);
|
||||
}
|
||||
await pausa(PAUSA_MS);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hayDatos() {
|
||||
const cn = await mysql.createConnection(config.db);
|
||||
try {
|
||||
const [filas] = await cn.query(
|
||||
`SELECT COUNT(*) AS n FROM information_schema.tables
|
||||
WHERE table_schema = ? AND table_name = 'programas'`,
|
||||
[config.db.database],
|
||||
);
|
||||
if (!filas[0].n) return false;
|
||||
const [[{ n }]] = await cn.query('SELECT COUNT(*) AS n FROM programas');
|
||||
return n > 0;
|
||||
} finally {
|
||||
await cn.end();
|
||||
}
|
||||
}
|
||||
|
||||
function correr(script) {
|
||||
return new Promise((resolver, rechazar) => {
|
||||
const hijo = spawn(process.execPath, [path.join(raiz, script)], { stdio: 'inherit', cwd: raiz });
|
||||
hijo.on('exit', (codigo) => (codigo === 0 ? resolver() : rechazar(new Error(`${script} salió con código ${codigo}`))));
|
||||
hijo.on('error', rechazar);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n▸ Portal INSTA · arranque\n');
|
||||
|
||||
if (!await esperarBase()) {
|
||||
console.error(`\n✖ MySQL no respondió en ${config.db.host}:${config.db.port} después de ${REINTENTOS} intentos.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const forzar = String(process.env.SEMBRAR_SIEMPRE ?? '').toLowerCase() === 'true';
|
||||
if (forzar) {
|
||||
console.log(' SEMBRAR_SIEMPRE=true · se reconstruye la base desde cero');
|
||||
await correr('db/seed.mjs');
|
||||
} else if (await hayDatos()) {
|
||||
console.log(' ✔ la base ya tiene datos: se conservan las ediciones');
|
||||
} else {
|
||||
console.log(' la base está vacía: se siembra por primera vez');
|
||||
await correr('db/seed.mjs');
|
||||
}
|
||||
|
||||
console.log('\n▸ Levantando el portal\n');
|
||||
// Se reemplaza este proceso por el servidor para que las señales de Docker
|
||||
// (SIGTERM al redeplegar) lleguen al proceso que de verdad atiende.
|
||||
const servidor = spawn(process.execPath, [path.join(raiz, 'server/index.mjs')], { stdio: 'inherit', cwd: raiz });
|
||||
for (const senal of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(senal, () => servidor.kill(senal));
|
||||
}
|
||||
servidor.on('exit', (codigo) => process.exit(codigo ?? 0));
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
// =====================================================================
|
||||
// Datos de siembra del Portal Interno INSTA
|
||||
// ---------------------------------------------------------------------
|
||||
// Fuentes (nada de esto se inventó):
|
||||
// · INSTA - Base de conocimiento - Oferta Educativa.csv → la oferta
|
||||
// · Base de conocimiento — Instituto Santiago Tapia (INSTA).md
|
||||
// · docs/crm/01-microreporte-atencion.md (errores de atención reales)
|
||||
//
|
||||
// LOS LINKS DE CLASES SON DE DEMOSTRACIÓN: las materias y los horarios
|
||||
// son plausibles, pero los enlaces de Meet son ficticios. Dirección y
|
||||
// administración los sustituyen por los reales desde el portal.
|
||||
// =====================================================================
|
||||
|
||||
export const FECHA_CORTE = '2026-07-29';
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Roles y permisos
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const roles = [
|
||||
{
|
||||
clave: 'director', nombre: 'Dirección', nivel: 3, color: '#30517E',
|
||||
descripcion: 'Ve todo y edita todo el contenido del portal: oferta, requisitos, becas, links de clases, preguntas frecuentes y contacto. También ve la bitácora.',
|
||||
},
|
||||
{
|
||||
clave: 'administrativo', nombre: 'Personal administrativo', nivel: 2, color: '#4275B7',
|
||||
descripcion: 'Consulta todo el portal y administra los links de clases. El resto del catálogo lo consulta, no lo edita.',
|
||||
},
|
||||
{
|
||||
clave: 'empleado', nombre: 'Empleado · ventas y atención', nivel: 1, color: '#6FA5DC',
|
||||
descripcion: 'Consulta todo lo que necesita para informar y atender: oferta, requisitos, becas, clases, preguntas frecuentes y contacto.',
|
||||
},
|
||||
];
|
||||
|
||||
export const permisos = [
|
||||
['inicio.ver', 'inicio', 'Entrar al portal'],
|
||||
['oferta.ver', 'oferta', 'Consultar la oferta educativa'],
|
||||
['oferta.editar', 'oferta', 'Editar programas, costos, horarios y RVOE'],
|
||||
['requisitos.ver', 'requisitos', 'Consultar requisitos y formas de titulación'],
|
||||
['requisitos.editar', 'requisitos', 'Editar requisitos y formas de titulación'],
|
||||
['becas.ver', 'becas', 'Consultar becas, promociones y fechas de inscripción'],
|
||||
['becas.editar', 'becas', 'Editar becas, promociones y vigencias'],
|
||||
['clases.ver', 'clases', 'Consultar los links de clases'],
|
||||
['clases.editar', 'clases', 'Crear, editar y quitar links de clases'],
|
||||
['kb.ver', 'kb', 'Consultar las preguntas frecuentes'],
|
||||
['kb.editar', 'kb', 'Editar las preguntas frecuentes'],
|
||||
['guiones.ver', 'guiones', 'Consultar los mensajes listos'],
|
||||
['guiones.editar', 'guiones', 'Editar los mensajes listos'],
|
||||
['contacto.ver', 'contacto', 'Consultar los datos del instituto'],
|
||||
['contacto.editar', 'contacto', 'Editar los datos del instituto'],
|
||||
['datos.ver', 'datos', 'Ver los datos pendientes de confirmar'],
|
||||
['datos.gestionar', 'datos', 'Marcar un dato como confirmado'],
|
||||
['equipo.ver', 'equipo', 'Ver el directorio del equipo'],
|
||||
['bitacora.ver', 'bitacora', 'Ver la bitácora de accesos y cambios'],
|
||||
];
|
||||
|
||||
// El catálogo lo edita dirección. Los links de clases, dirección y administración.
|
||||
export const rolesPermisos = {
|
||||
director: '*',
|
||||
administrativo: [
|
||||
'inicio.ver',
|
||||
'oferta.ver', 'requisitos.ver', 'becas.ver',
|
||||
'clases.ver', 'clases.editar',
|
||||
'kb.ver', 'guiones.ver', 'contacto.ver',
|
||||
'datos.ver', 'equipo.ver',
|
||||
],
|
||||
empleado: [
|
||||
'inicio.ver',
|
||||
'oferta.ver', 'requisitos.ver', 'becas.ver',
|
||||
'clases.ver', 'kb.ver', 'guiones.ver', 'contacto.ver',
|
||||
],
|
||||
};
|
||||
|
||||
// Nombres ficticios: usuarios de demostración, no personal real del instituto.
|
||||
export const usuarios = [
|
||||
{ nombre: 'Marisol Aguilar', email: '[email protected]', puesto: 'Dirección General', rol: 'director', iniciales: 'MA', extension: '101', es_demo: 1 },
|
||||
{ nombre: 'Rubén Cázares', email: '[email protected]', puesto: 'Coordinación Administrativa', rol: 'administrativo', iniciales: 'RC', extension: '102', es_demo: 1 },
|
||||
{ nombre: 'Denisse Ochoa', email: '[email protected]', puesto: 'Asesoría Educativa', rol: 'empleado', iniciales: 'DO', extension: '103', es_demo: 1 },
|
||||
{ nombre: 'Paola Herrera', email: '[email protected]', puesto: 'Control Escolar', rol: 'administrativo', iniciales: 'PH', extension: '104', es_demo: 0 },
|
||||
{ nombre: 'Iván Bermúdez', email: '[email protected]', puesto: 'Asesoría Educativa · sabatino', rol: 'empleado', iniciales: 'IB', extension: '105', es_demo: 0 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Oferta educativa — del CSV, reconciliada con la base de conocimiento
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const niveles = [
|
||||
{ clave: 'bachillerato', nombre: 'Bachillerato', orden: 1 },
|
||||
{ clave: 'licenciatura', nombre: 'Licenciatura', orden: 2 },
|
||||
{ clave: 'maestria', nombre: 'Maestría', orden: 3 },
|
||||
];
|
||||
|
||||
export const modalidades = [
|
||||
{ clave: 'mod-online', nombre: 'En línea', color: '#5E35B1', detalle: 'Clases en vivo por Zoom o Google Meet. Todas las sesiones quedan grabadas.' },
|
||||
{ clave: 'mod-sabatina', nombre: 'Sabatina', color: '#7E57C2', detalle: 'Un solo día a la semana, sábado. Presencial o híbrida según el grupo.' },
|
||||
{ clave: 'mod-presencial', nombre: 'Presencial', color: '#9575CD', detalle: 'En la sede: Av. Guadalupe Victoria 1409-d, Prados Verdes, Morelia.' },
|
||||
{ clave: 'mod-hibrida', nombre: 'Híbrida', color: '#B39DDB', detalle: 'Combina asistencia a la sede con sesiones en línea.' },
|
||||
];
|
||||
|
||||
export const programas = [
|
||||
{
|
||||
clave: 'prog-bachillerato', nivel: 'bachillerato',
|
||||
nombre: 'Bachillerato General', nombre_corto: 'Bachillerato',
|
||||
duracion: '1 año y medio', fecha_inicio: 'Mayo 2026',
|
||||
inscripcion_lista: 1125, inscripcion_beca: 1125,
|
||||
mensualidad_lista: 1500, mensualidad_beca: 1125,
|
||||
ciclo_semanas: null,
|
||||
rvoe_acuerdo: '', rvoe_fecha: null, rvoe_autoridad: '', rvoe_estado: 'por-confirmar',
|
||||
rvoe_nota: 'El CSV registró el Acuerdo 20211511, pero ese número es de la Licenciatura en Administración. Falta el número real del bachillerato: no lo cites ante un interesado hasta que dirección lo confirme.',
|
||||
beneficios: 'Diplomado en inglés y en radio y televisión, los dos avalados por la SEP. Beca del 25%.',
|
||||
color: '#0B5E42', orden: 1,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '16:00', hora_fin: '20:00', nota: '' },
|
||||
{ modalidad: 'mod-online', dias: 'Lunes y martes', hora_inicio: '19:30', hora_fin: '21:00', nota: 'Nocturno entre semana' },
|
||||
],
|
||||
},
|
||||
{
|
||||
clave: 'prog-trabajo-social', nivel: 'licenciatura',
|
||||
nombre: 'Licenciatura en Trabajo Social en Recursos Humanos', nombre_corto: 'Trabajo Social en RH',
|
||||
duracion: '3 años', fecha_inicio: 'Mayo 2026',
|
||||
inscripcion_lista: 2000, inscripcion_beca: 1500,
|
||||
mensualidad_lista: 2000, mensualidad_beca: 1500,
|
||||
ciclo_semanas: null,
|
||||
rvoe_acuerdo: 'SEG/0127/2021', rvoe_fecha: '2021-09-30',
|
||||
rvoe_autoridad: 'Secretaría de Educación de Guerrero', rvoe_estado: 'revisar',
|
||||
rvoe_nota: 'Es el único programa cuyo acuerdo no figura a nombre de Centro Educativo Puebla. Confirmar con dirección si se avala por otra vía antes de citarlo por escrito.',
|
||||
beneficios: 'Beca del 25% en inscripción y mensualidad.',
|
||||
color: '#0F8A5F', orden: 2,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-online', dias: 'Lunes a jueves', hora_inicio: '20:00', hora_fin: '21:30', nota: '' },
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '20:00', nota: 'Presencial e híbrida' },
|
||||
],
|
||||
},
|
||||
{
|
||||
clave: 'prog-administracion', nivel: 'licenciatura',
|
||||
nombre: 'Licenciatura en Administración de Empresas', nombre_corto: 'Administración',
|
||||
duracion: '3 años', fecha_inicio: 'Mayo 2026',
|
||||
inscripcion_lista: 2000, inscripcion_beca: 1500,
|
||||
mensualidad_lista: 2000, mensualidad_beca: 1500,
|
||||
ciclo_semanas: 14,
|
||||
rvoe_acuerdo: '20211511', rvoe_fecha: '2021-09-15',
|
||||
rvoe_autoridad: 'Centro Educativo Puebla (titular del RVOE)', rvoe_estado: 'confirmado',
|
||||
rvoe_nota: 'Confirmado por Centro Educativo Puebla. El ciclo dura 14 semanas.',
|
||||
beneficios: 'Beca del 25% en inscripción y mensualidad.',
|
||||
color: '#17A171', orden: 3,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-online', dias: 'Lunes a jueves', hora_inicio: '20:00', hora_fin: '21:30', nota: '' },
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '20:00', nota: 'Presencial e híbrida' },
|
||||
],
|
||||
},
|
||||
{
|
||||
clave: 'prog-derecho', nivel: 'licenciatura',
|
||||
nombre: 'Licenciatura en Derecho', nombre_corto: 'Derecho',
|
||||
duracion: '3 años 4 meses', fecha_inicio: 'Mayo 2026',
|
||||
inscripcion_lista: 2000, inscripcion_beca: 1500,
|
||||
mensualidad_lista: 2000, mensualidad_beca: 1500,
|
||||
ciclo_semanas: 14,
|
||||
rvoe_acuerdo: '20211510', rvoe_fecha: '2021-09-15',
|
||||
rvoe_autoridad: 'Centro Educativo Puebla (titular del RVOE)', rvoe_estado: 'confirmado',
|
||||
rvoe_nota: 'Confirmado por Centro Educativo Puebla. El ciclo dura 14 semanas.',
|
||||
beneficios: 'Beca del 25% en inscripción y mensualidad.',
|
||||
color: '#2FBF8F', orden: 4,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-online', dias: 'Lunes a jueves', hora_inicio: '20:00', hora_fin: '21:30', nota: '' },
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '20:00', nota: 'Presencial e híbrida' },
|
||||
],
|
||||
},
|
||||
{
|
||||
clave: 'prog-pedagogia', nivel: 'licenciatura',
|
||||
nombre: 'Licenciatura en Pedagogía', nombre_corto: 'Pedagogía',
|
||||
duracion: '3 años 4 meses', fecha_inicio: 'Mayo 2026',
|
||||
inscripcion_lista: 2000, inscripcion_beca: 1500,
|
||||
mensualidad_lista: 2000, mensualidad_beca: 1500,
|
||||
ciclo_semanas: 14,
|
||||
rvoe_acuerdo: '20211512', rvoe_fecha: '2021-09-15',
|
||||
rvoe_autoridad: 'Centro Educativo Puebla (titular del RVOE)', rvoe_estado: 'confirmado',
|
||||
rvoe_nota: 'Confirmado por Centro Educativo Puebla. El ciclo dura 14 semanas.',
|
||||
beneficios: 'Beca del 25% en inscripción y mensualidad.',
|
||||
color: '#57CFA5', orden: 5,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-online', dias: 'Lunes a jueves', hora_inicio: '20:00', hora_fin: '21:30', nota: '' },
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '20:00', nota: 'Presencial e híbrida' },
|
||||
],
|
||||
},
|
||||
{
|
||||
clave: 'prog-psicologia', nivel: 'licenciatura',
|
||||
nombre: 'Licenciatura en Psicología', nombre_corto: 'Psicología',
|
||||
duracion: '3 años', fecha_inicio: 'Mayo 2026',
|
||||
inscripcion_lista: 2000, inscripcion_beca: 1500,
|
||||
mensualidad_lista: 2000, mensualidad_beca: 1500,
|
||||
ciclo_semanas: null,
|
||||
rvoe_acuerdo: '20110687', rvoe_fecha: '2011-07-19',
|
||||
rvoe_autoridad: 'Centro Educativo Puebla (titular del RVOE)', rvoe_estado: 'confirmado',
|
||||
rvoe_nota: 'Confirmado por Centro Educativo Puebla. Es el programa con RVOE más antiguo del instituto. El CSV traía 231301, que no corresponde.',
|
||||
beneficios: 'Beca del 25% en inscripción y mensualidad.',
|
||||
color: '#7FDCBB', orden: 6,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-online', dias: 'Lunes a jueves', hora_inicio: '20:00', hora_fin: '21:30', nota: '' },
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '20:00', nota: 'Presencial e híbrida' },
|
||||
],
|
||||
},
|
||||
{
|
||||
clave: 'prog-maestria-educacion', nivel: 'maestria',
|
||||
nombre: 'Maestría en Educación', nombre_corto: 'Maestría en Educación',
|
||||
duracion: '18 meses', fecha_inicio: 'Enero 2026',
|
||||
inscripcion_lista: 2000, inscripcion_beca: 1500,
|
||||
mensualidad_lista: 2000, mensualidad_beca: 1500,
|
||||
ciclo_semanas: null,
|
||||
rvoe_acuerdo: '', rvoe_fecha: null, rvoe_autoridad: '', rvoe_estado: 'por-confirmar',
|
||||
rvoe_nota: 'No especificado en ninguna fuente. Dirección lo está solicitando a Centro Educativo Puebla. No cites ningún número hasta que se confirme.',
|
||||
beneficios: 'Beca del 25% en inscripción y mensualidad.',
|
||||
color: '#A6E8D1', orden: 7,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '16:00', hora_fin: '20:00', nota: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
clave: 'prog-maestria-juicios-orales', nivel: 'maestria',
|
||||
nombre: 'Maestría en Juicios Orales', nombre_corto: 'Maestría en Juicios Orales',
|
||||
duracion: '18 meses', fecha_inicio: 'Enero 2026',
|
||||
inscripcion_lista: 2000, inscripcion_beca: 1500,
|
||||
mensualidad_lista: 2000, mensualidad_beca: 1500,
|
||||
ciclo_semanas: null,
|
||||
rvoe_acuerdo: '', rvoe_fecha: null, rvoe_autoridad: '', rvoe_estado: 'por-confirmar',
|
||||
rvoe_nota: 'No especificado en ninguna fuente. Dirección lo está solicitando a Centro Educativo Puebla. No cites ningún número hasta que se confirme.',
|
||||
beneficios: 'Beca del 25% en inscripción y mensualidad.',
|
||||
color: '#C8F0E3', orden: 8,
|
||||
horarios: [
|
||||
{ modalidad: 'mod-sabatina', dias: 'Sábado', hora_inicio: '16:00', hora_fin: '20:00', nota: '' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const requisitos = {
|
||||
bachillerato: [
|
||||
['Acta de nacimiento', ''],
|
||||
['CURP', ''],
|
||||
['Certificado de secundaria', 'Es el documento que define si puede inscribirse'],
|
||||
['6 fotografías tamaño infantil, blanco y negro', 'Seis, no cuatro: el bachillerato pide más que los otros niveles'],
|
||||
['Comprobante de domicilio', ''],
|
||||
['Identificación', 'Del alumno, o del tutor si es menor de edad'],
|
||||
['Ficha de inscripción', 'Se llena en el instituto o se envía por WhatsApp'],
|
||||
['Comprobante de pago', 'El voucher del depósito o de la transferencia'],
|
||||
],
|
||||
licenciatura: [
|
||||
['Acta de nacimiento', ''],
|
||||
['CURP', ''],
|
||||
['Certificado o constancia de bachillerato', 'La constancia sirve si el certificado aún está en trámite'],
|
||||
['4 fotografías tamaño infantil, blanco y negro', ''],
|
||||
['Comprobante de domicilio', ''],
|
||||
['Ficha de inscripción', 'Se llena en el instituto o se envía por WhatsApp'],
|
||||
['Comprobante de pago', 'El voucher del depósito o de la transferencia'],
|
||||
],
|
||||
maestria: [
|
||||
['Acta de nacimiento', ''],
|
||||
['CURP', ''],
|
||||
['Título y cédula profesional, o constancia de terminación de estudios', 'La constancia sirve si el título está en trámite'],
|
||||
['4 fotografías tamaño infantil, blanco y negro', ''],
|
||||
['Comprobante de domicilio', ''],
|
||||
['Ficha de inscripción', 'Se llena en el instituto o se envía por WhatsApp'],
|
||||
['Comprobante de pago', 'El voucher del depósito o de la transferencia'],
|
||||
],
|
||||
};
|
||||
|
||||
export const titulacionOpciones = [
|
||||
['Por promedio', 'Sin trabajo escrito, si el promedio general lo permite.'],
|
||||
['Por curso', 'Curso de titulación al terminar el plan de estudios.'],
|
||||
['Por tesina', 'Trabajo escrito, más breve que la tesis.'],
|
||||
['Por tesis', 'Trabajo de investigación completo, con réplica.'],
|
||||
['Por experiencia profesional', 'Para quien ya trabaja en el área de la carrera. Suele ser la vía más directa.'],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Becas, promociones y fechas
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// `acumulable` responde una sola pregunta: ¿se puede combinar con otra cosa?
|
||||
// La base de conocimiento solo declara NO acumulable el programa de referidos;
|
||||
// la beca del 25% y los descuentos de temporada sí se combinan entre sí, y de
|
||||
// hecho así se ofrecen en WhatsApp ("inscripción gratis" + "$1,500 con beca").
|
||||
export const promociones = [
|
||||
{
|
||||
clave: 'beca-general', nombre: 'Beca del 25%', aplica_a: 'ambos',
|
||||
resumen: 'Deja la mensualidad de licenciatura y maestría en $1,500, y la de bachillerato en $1,125.',
|
||||
vigencia: 'Permanente, para alumnos nuevos', acumulable: 1, orden: 1,
|
||||
condiciones: 'Es un 25% de descuento y aplica tanto a la inscripción como a la mensualidad del primer ciclo. Solo para alumnos nuevos. Se combina con el descuento de temporada que esté vigente: en julio, inscripción gratis más mensualidad de $1,500. Lo único que NO se junta con la beca es el programa de referidos. La base de conocimiento dice "primer ciclo"; si preguntan si el precio se mantiene toda la carrera, no lo prometas por escrito: está pendiente de definir con dirección.',
|
||||
},
|
||||
{
|
||||
clave: 'julio-inscripcion-gratis', nombre: 'Inscripción gratis en julio', aplica_a: 'inscripcion',
|
||||
resumen: 'Durante julio de 2026 la inscripción no se cobra. El cupo es limitado y vence el 31.',
|
||||
vigencia: 'Del 1 al 31 de julio de 2026', acumulable: 1, orden: 2,
|
||||
condiciones: 'Cupo limitado. Es la promoción más fuerte del año para cerrar una inscripción y se combina con la beca del 25%: inscripción en $0 y mensualidad en $1,500. Lo que no se junta con ella es el programa de referidos.',
|
||||
},
|
||||
{
|
||||
clave: 'agosto-25', nombre: '25% de descuento en la inscripción · agosto', aplica_a: 'inscripcion',
|
||||
resumen: 'En agosto la inscripción baja un 25%. Es la promoción que sigue cuando termina la de julio.',
|
||||
vigencia: 'Todo agosto', acumulable: 1, orden: 3,
|
||||
condiciones: 'Descuento por temporada de inscripción. Se combina con la beca del 25%. Los descuentos de temporada no coinciden entre sí: cada uno vive en su mes.',
|
||||
},
|
||||
{
|
||||
clave: 'marzo-50', nombre: '50% de descuento en la inscripción · marzo', aplica_a: 'inscripcion',
|
||||
resumen: 'En marzo la inscripción baja a la mitad. Es el descuento más grande del año en inscripción.',
|
||||
vigencia: 'Todo marzo', acumulable: 1, orden: 4,
|
||||
condiciones: 'Descuento por temporada de inscripción. Se combina con la beca del 25%. Los descuentos de temporada no coinciden entre sí: cada uno vive en su mes.',
|
||||
},
|
||||
{
|
||||
clave: 'referidos', nombre: 'Programa de referidos', aplica_a: 'mensualidad',
|
||||
resumen: 'Por cada persona que refiera y se inscriba, se le descuenta 20% de su mensualidad. Con 5 referidos la mensualidad queda sin costo.',
|
||||
vigencia: 'Permanente', acumulable: 0, orden: 5,
|
||||
condiciones: '1 referido = 20%, 2 = 40%, 3 = 60%, 4 = 80%, 5 = 100% (mensualidad sin costo). La comisión al referidor se paga desde ese descuento. La inscripción y la reinscripción quedan fijas ($1,500 en licenciatura y maestría, $1,125 en bachillerato) y no cambian por este programa. El descuento de cada nivel se mantiene solo mientras ese referido siga pagando su mensualidad: si causa baja, el nivel se ajusta. Ojo: NO se acumula con la beca del 25% ni con la promoción de julio, y con un solo referido conviene más la beca ($1,500 contra $1,600).',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Links de clases (demostración: los enlaces de Meet son ficticios)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const clases = [
|
||||
// Bachillerato
|
||||
{ programa: 'prog-bachillerato', materia: 'Matemáticas I', docente: 'Prof. Ernesto Valdés', grupo: 'Generación 2026-A', dias: 'Lunes', hora_inicio: '19:30', hora_fin: '21:00', enlace: 'https://meet.google.com/insta-bach-mat', notas: '' },
|
||||
{ programa: 'prog-bachillerato', materia: 'Español I', docente: 'Profa. Lucía Mendoza', grupo: 'Generación 2026-A', dias: 'Martes', hora_inicio: '19:30', hora_fin: '21:00', enlace: 'https://meet.google.com/insta-bach-esp', notas: '' },
|
||||
{ programa: 'prog-bachillerato', materia: 'Inglés (diplomado SEP)', docente: 'Prof. Alan Guzmán', grupo: 'Generación 2026-A', dias: 'Sábado', hora_inicio: '16:00', hora_fin: '18:00', enlace: 'https://meet.google.com/insta-bach-ing', notas: 'Cuenta para el diplomado avalado por la SEP' },
|
||||
{ programa: 'prog-bachillerato', materia: 'Taller de radio y televisión', docente: 'Prof. Alan Guzmán', grupo: 'Generación 2026-A', dias: 'Sábado', hora_inicio: '18:00', hora_fin: '20:00', enlace: 'https://meet.google.com/insta-bach-rtv', notas: 'Cuenta para el diplomado avalado por la SEP' },
|
||||
|
||||
// Trabajo Social en RH
|
||||
{ programa: 'prog-trabajo-social', materia: 'Fundamentos de Trabajo Social', docente: 'Profa. Rosa Elena Núñez', grupo: 'Generación 2026 · en línea', dias: 'Lunes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-ts-fund', notas: '' },
|
||||
{ programa: 'prog-trabajo-social', materia: 'Psicología Organizacional', docente: 'Prof. Hugo Sandoval', grupo: 'Generación 2026 · en línea', dias: 'Martes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-ts-psor', notas: '' },
|
||||
{ programa: 'prog-trabajo-social', materia: 'Reclutamiento y Selección', docente: 'Profa. Rosa Elena Núñez', grupo: 'Generación 2026 · en línea', dias: 'Miércoles', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-ts-recl', notas: '' },
|
||||
{ programa: 'prog-trabajo-social', materia: 'Derecho Laboral', docente: 'Lic. Fernando Ríos', grupo: 'Generación 2026 · sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '17:30', enlace: 'https://meet.google.com/insta-ts-dlab', notas: 'Grupo híbrido: se transmite desde la sede' },
|
||||
|
||||
// Administración
|
||||
{ programa: 'prog-administracion', materia: 'Contabilidad General', docente: 'C.P. Adriana Solórzano', grupo: 'Generación 2026 · en línea', dias: 'Lunes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-adm-cont', notas: '' },
|
||||
{ programa: 'prog-administracion', materia: 'Administración de Empresas I', docente: 'Mtro. Julio Lara', grupo: 'Generación 2026 · en línea', dias: 'Martes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-adm-adm1', notas: '' },
|
||||
{ programa: 'prog-administracion', materia: 'Mercadotecnia', docente: 'Mtra. Karina Fuentes', grupo: 'Generación 2026 · en línea', dias: 'Miércoles', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-adm-merc', notas: '' },
|
||||
{ programa: 'prog-administracion', materia: 'Finanzas', docente: 'C.P. Adriana Solórzano', grupo: 'Generación 2026 · sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '17:30', enlace: 'https://meet.google.com/insta-adm-fina', notas: '' },
|
||||
|
||||
// Derecho
|
||||
{ programa: 'prog-derecho', materia: 'Introducción al Derecho', docente: 'Lic. Fernando Ríos', grupo: 'Generación 2026 · en línea', dias: 'Lunes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-der-intro', notas: '' },
|
||||
{ programa: 'prog-derecho', materia: 'Derecho Civil I', docente: 'Lic. Norma Estrada', grupo: 'Generación 2026 · en línea', dias: 'Martes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-der-civ1', notas: '' },
|
||||
{ programa: 'prog-derecho', materia: 'Derecho Penal I', docente: 'Lic. Norma Estrada', grupo: 'Generación 2026 · en línea', dias: 'Jueves', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-der-pen1', notas: '' },
|
||||
{ programa: 'prog-derecho', materia: 'Amparo', docente: 'Lic. Fernando Ríos', grupo: 'Generación 2026 · sabatina', dias: 'Sábado', hora_inicio: '17:30', hora_fin: '20:00', enlace: 'https://meet.google.com/insta-der-amp', notas: '' },
|
||||
|
||||
// Pedagogía
|
||||
{ programa: 'prog-pedagogia', materia: 'Didáctica General', docente: 'Mtra. Silvia Ocampo', grupo: 'Generación 2026 · en línea', dias: 'Lunes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-ped-dida', notas: '' },
|
||||
{ programa: 'prog-pedagogia', materia: 'Psicología del Aprendizaje', docente: 'Prof. Hugo Sandoval', grupo: 'Generación 2026 · en línea', dias: 'Miércoles', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-ped-apre', notas: '' },
|
||||
{ programa: 'prog-pedagogia', materia: 'Evaluación Educativa', docente: 'Mtra. Silvia Ocampo', grupo: 'Generación 2026 · sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '17:30', enlace: 'https://meet.google.com/insta-ped-eval', notas: '' },
|
||||
|
||||
// Psicología
|
||||
{ programa: 'prog-psicologia', materia: 'Psicología General', docente: 'Prof. Hugo Sandoval', grupo: 'Generación 2026 · en línea', dias: 'Lunes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-psi-gral', notas: '' },
|
||||
{ programa: 'prog-psicologia', materia: 'Psicopatología', docente: 'Psic. Mariana Cortés', grupo: 'Generación 2026 · en línea', dias: 'Martes', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-psi-pato', notas: '' },
|
||||
{ programa: 'prog-psicologia', materia: 'Psicometría', docente: 'Psic. Mariana Cortés', grupo: 'Generación 2026 · en línea', dias: 'Jueves', hora_inicio: '20:00', hora_fin: '21:30', enlace: 'https://meet.google.com/insta-psi-metr', notas: '' },
|
||||
{ programa: 'prog-psicologia', materia: 'Psicología Clínica', docente: 'Psic. Gerardo Ibarra', grupo: 'Generación 2026 · sabatina', dias: 'Sábado', hora_inicio: '15:00', hora_fin: '17:30', enlace: 'https://meet.google.com/insta-psi-clin', notas: '' },
|
||||
|
||||
// Maestrías
|
||||
{ programa: 'prog-maestria-educacion', materia: 'Metodología de la Investigación', docente: 'Dr. Raúl Bañuelos', grupo: 'Generación 2026', dias: 'Sábado', hora_inicio: '16:00', hora_fin: '18:00', enlace: 'https://meet.google.com/insta-mte-meto', notas: '' },
|
||||
{ programa: 'prog-maestria-educacion', materia: 'Gestión Educativa', docente: 'Mtra. Silvia Ocampo', grupo: 'Generación 2026', dias: 'Sábado', hora_inicio: '18:00', hora_fin: '20:00', enlace: 'https://meet.google.com/insta-mte-gest', notas: '' },
|
||||
{ programa: 'prog-maestria-juicios-orales', materia: 'Teoría del Delito', docente: 'Dr. Alfonso Peralta', grupo: 'Generación 2026', dias: 'Sábado', hora_inicio: '16:00', hora_fin: '18:00', enlace: 'https://meet.google.com/insta-mjo-delito', notas: '' },
|
||||
{ programa: 'prog-maestria-juicios-orales', materia: 'Técnicas de Litigación Oral', docente: 'Dr. Alfonso Peralta', grupo: 'Generación 2026', dias: 'Sábado', hora_inicio: '18:00', hora_fin: '20:00', enlace: 'https://meet.google.com/insta-mjo-litig', notas: 'Sesión práctica: se recomienda entrar con cámara' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Preguntas frecuentes
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const kbArticulos = [
|
||||
{ seccion: 'Validez oficial', pregunta: '¿Qué es el RVOE y quién lo otorga?', orden: 1, palabras_clave: 'rvoe acuerdo validez sep oficial reconocimiento',
|
||||
respuesta: 'El RVOE es el Reconocimiento de Validez Oficial de Estudios: el reconocimiento que la autoridad educativa le da a un programa para que sea válido. El Acuerdo es el documento que lo formaliza y trae un número.' },
|
||||
{ seccion: 'Validez oficial', pregunta: '¿Por qué los acuerdos están a nombre de Centro Educativo Puebla y no de INSTA?', orden: 2, palabras_clave: 'centro educativo puebla rvoe titular centro de apoyo',
|
||||
respuesta: 'Porque INSTA opera como centro de apoyo de Centro Educativo Puebla, que es la entidad titular de los RVOE. No es una irregularidad ni un error: es la figura con la que opera el instituto, y explica por qué los números de acuerdo están a su nombre.' },
|
||||
{ seccion: 'Validez oficial', pregunta: '¿Qué hago si me piden un número de acuerdo que no tenemos confirmado?', orden: 3, palabras_clave: 'rvoe por confirmar bachillerato maestria acuerdo',
|
||||
respuesta: 'No lo inventes y no cites el que aparece en fuentes viejas. Falta por confirmar el RVOE del Bachillerato General y los de las dos maestrías. Lo correcto es decir que el dato se confirma con Centro Educativo Puebla y que se entrega por escrito en cuanto se tenga, y avisarle a dirección el mismo día.' },
|
||||
{ seccion: 'Costos', pregunta: '¿Cuánto cuesta una licenciatura?', orden: 4, palabras_clave: 'costo precio licenciatura inscripcion mensualidad beca',
|
||||
respuesta: 'Inscripción $2,000 y mensualidad $2,000 de lista. Con la beca del 25% para alumnos nuevos, los dos conceptos quedan en $1,500. Aplica a las cinco licenciaturas y a las dos maestrías.' },
|
||||
{ seccion: 'Costos', pregunta: '¿Cuánto cuesta el bachillerato?', orden: 5, palabras_clave: 'costo precio bachillerato inscripcion mensualidad',
|
||||
respuesta: 'Inscripción $1,125 y mensualidad $1,500. Con la beca del 25% la mensualidad queda en $1,125. Es el único programa con una lista de precios distinta a la del resto.' },
|
||||
{ seccion: 'Costos', pregunta: '¿El precio se mantiene toda la carrera?', orden: 6, palabras_clave: 'precio congelado aumenta mensualidad vigencia beca',
|
||||
respuesta: 'Es una de las preguntas que más pesa al decidir, y la fuente oficial no lo define: dice que la beca del 25% aplica a la inscripción y la mensualidad del primer ciclo. En una conversación anterior se le dijo a una interesada que los precios quedaban congelados los 3 años. Antes de prometerlo por escrito, confírmalo con dirección. Está registrado como dato pendiente.' },
|
||||
{ seccion: 'Becas', pregunta: '¿Cómo funciona la beca del 25%?', orden: 7, palabras_clave: 'beca 25 descuento nuevo alumno',
|
||||
respuesta: 'Es un 25% de descuento para alumnos nuevos, aplicable a la inscripción y a la mensualidad del primer ciclo. No se acumula con el programa de referidos.' },
|
||||
{ seccion: 'Becas', pregunta: '¿Qué promoción está vigente?', orden: 8, palabras_clave: 'promocion julio inscripcion gratis vigente cupo',
|
||||
respuesta: 'Inscripción gratis durante julio de 2026, con cupo limitado, y vence el 31 de julio. Después sigue el 25% de descuento en la inscripción durante agosto. En marzo el descuento es del 50%.' },
|
||||
{ seccion: 'Becas', pregunta: '¿Cómo funciona el programa de referidos?', orden: 9, palabras_clave: 'referidos descuento mensualidad comision gratis',
|
||||
respuesta: '20% de descuento en la mensualidad por cada persona referida que se inscriba: 1 = 20%, 2 = 40%, 3 = 60%, 4 = 80%, 5 = 100% (mensualidad sin costo). La inscripción y la reinscripción quedan fijas. El descuento de cada nivel se mantiene solo mientras ese referido siga pagando. No es acumulable con la beca del 25% ni con la promoción de julio, y con un solo referido conviene más la beca: $1,500 contra $1,600.' },
|
||||
{ seccion: 'Modalidades', pregunta: '¿Cómo funciona exactamente la modalidad en línea?', orden: 10, palabras_clave: 'online en linea zoom meet vivo grabada plataforma',
|
||||
respuesta: 'Las clases son en vivo por Zoom o Google Meet, de lunes a jueves de 8:00 a 9:30 de la noche en las licenciaturas. Las tareas se entregan por plataforma o por WhatsApp, y todas las sesiones quedan grabadas para reponer la clase que se pierda. Esta respuesta hay que darla completa: en una conversación se contestó "depende del maestro" y esa ambigüedad hizo que una interesada dejara de responder.' },
|
||||
{ seccion: 'Modalidades', pregunta: '¿Las clases se graban?', orden: 11, palabras_clave: 'grabadas grabacion reponer clase perdida',
|
||||
respuesta: 'Sí, todas. Es uno de los diferenciadores del instituto: si el alumno pierde una clase por trabajo o por una guardia, la repone con la grabación.' },
|
||||
{ seccion: 'Modalidades', pregunta: '¿Qué horarios hay?', orden: 12, palabras_clave: 'horario sabatino nocturno online clases',
|
||||
respuesta: 'Licenciaturas: en línea de lunes a jueves de 20:00 a 21:30, o sabatina de 15:00 a 20:00 (presencial e híbrida). Bachillerato: sabatino de 16:00 a 20:00, o en línea los lunes y martes de 19:30 a 21:00. Maestrías: sábado de 16:00 a 20:00.' },
|
||||
{ seccion: 'Modalidades', pregunta: '¿Se puede estudiar desde otro municipio?', orden: 13, palabras_clave: 'municipio zamora uruapan tangancicuaro charo indaparapeo distancia',
|
||||
respuesta: 'Sí, con la modalidad en línea. El instituto ya tiene alumnos en Morelia y en municipios como Zamora, Tangancícuaro, Uruapan, Charo e Indaparapeo.' },
|
||||
{ seccion: 'Inscripción', pregunta: '¿Qué documentos se necesitan?', orden: 14, palabras_clave: 'documentos requisitos acta curp certificado fotos',
|
||||
respuesta: 'Depende del nivel. Bachillerato: acta, CURP, certificado de secundaria, 6 fotos infantiles en blanco y negro, comprobante de domicilio, identificación, ficha y comprobante de pago. Licenciaturas: lo mismo pero con certificado o constancia de bachillerato y 4 fotos. Maestrías: título y cédula (o constancia de terminación) y 4 fotos. El bachillerato es el único que pide 6 fotos e identificación.' },
|
||||
{ seccion: 'Inscripción', pregunta: '¿Cuándo inician las clases?', orden: 15, palabras_clave: 'inicio fecha ingreso mayo enero ciclo',
|
||||
respuesta: 'Bachillerato y licenciaturas: mayo de 2026. Maestrías: enero de 2026. Esas fechas vienen del archivo de oferta educativa y están marcadas como pendientes de confirmar, así que verifica con dirección la próxima fecha de ingreso vigente antes de comprometerla.' },
|
||||
{ seccion: 'Inscripción', pregunta: '¿Se puede revalidar materias?', orden: 16, palabras_clave: 'revalidacion revalidar materias historial academico',
|
||||
respuesta: 'Sí lo han pedido y se atiende caso por caso con control escolar, revisando el historial académico. No hay una regla general escrita: hay que agendar una revisión de documentos.' },
|
||||
{ seccion: 'Titulación', pregunta: '¿Cómo me titulo?', orden: 17, palabras_clave: 'titulacion titulo tesis tesina promedio experiencia curso',
|
||||
respuesta: 'Hay cinco opciones según el programa: por promedio, por curso, por tesina, por tesis o por experiencia profesional. Para quien ya trabaja en el área de la carrera, la de experiencia profesional suele ser la vía más directa.' },
|
||||
{ seccion: 'El instituto', pregunta: '¿Desde cuándo existe el instituto?', orden: 18, palabras_clave: 'antiguedad 2015 certificados trayectoria historia',
|
||||
respuesta: 'Opera desde 2015 en Morelia, Michoacán, con una sede. Ha entregado más de 1,500 certificados y tiene presencia en Morelia, Zamora, Tangancícuaro, Uruapan, Charo e Indaparapeo. Buena parte del alumnado ha sido personal de seguridad pública.' },
|
||||
{ seccion: 'El instituto', pregunta: '¿Cuál es la promesa del instituto?', orden: 19, palabras_clave: 'promesa trabajar estudiar posible mision vision valores',
|
||||
respuesta: '"Trabajar y estudiar es posible": concluir estudios sin dejar el trabajo, con horarios flexibles y clases grabadas. Los valores son compromiso, lealtad y honestidad.' },
|
||||
{ seccion: 'Atención', pregunta: '¿Cuáles son los horarios de atención?', orden: 20, palabras_clave: 'atencion horario oficina whatsapp telefono contacto',
|
||||
respuesta: 'Lunes a viernes de 10:00 a 18:00 y sábados de 8:00 a 20:00. WhatsApp 443 356 0979 y teléfono 443 688 2755.' },
|
||||
{ seccion: 'Atención', pregunta: '¿Qué temas NO se resuelven desde atención?', orden: 21, palabras_clave: 'pagos facturacion expedientes canal oficial administracion control escolar',
|
||||
respuesta: 'Los pagos, la facturación y los expedientes se tratan por el canal oficial con administración y control escolar. Desde atención se canaliza, no se confirma: es la regla que evita dar por bueno un pago que no está registrado.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Datos del instituto
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const institucional = [
|
||||
{ grupo: 'contacto', clave: 'direccion', etiqueta: 'Dirección de la sede', valor: 'Av. Guadalupe Victoria 1409-d, Prados Verdes, 58110 Morelia, Michoacán', orden: 1 },
|
||||
{ grupo: 'contacto', clave: 'whatsapp', etiqueta: 'WhatsApp', valor: '443 356 0979', orden: 2 },
|
||||
{ grupo: 'contacto', clave: 'telefono', etiqueta: 'Teléfono', valor: '443 688 2755', orden: 3 },
|
||||
{ grupo: 'contacto', clave: 'horario', etiqueta: 'Horario de atención', valor: 'Lunes a viernes de 10:00 a 18:00 · Sábados de 8:00 a 20:00', orden: 4 },
|
||||
{ grupo: 'contacto', clave: 'sitio', etiqueta: 'Sitio web', valor: 'institutosantiagotapia.com', orden: 5 },
|
||||
{ grupo: 'contacto', clave: 'redes', etiqueta: 'Redes sociales', valor: 'Facebook e Instagram: Instituto Santiago Tapia', orden: 6 },
|
||||
|
||||
{ grupo: 'identidad', clave: 'nombre', etiqueta: 'Nombre', valor: 'Instituto Santiago Tapia (INSTA)', orden: 7 },
|
||||
{ grupo: 'identidad', clave: 'antiguedad', etiqueta: 'Opera desde', valor: '2015, con una sede en Morelia, Michoacán', orden: 8 },
|
||||
{ grupo: 'identidad', clave: 'trayectoria', etiqueta: 'Trayectoria', valor: 'Más de 1,500 certificados entregados. Presencia en Morelia y en municipios como Zamora, Tangancícuaro, Uruapan, Charo e Indaparapeo. Buena parte del alumnado ha sido personal de seguridad pública.', orden: 9 },
|
||||
{ grupo: 'identidad', clave: 'promesa', etiqueta: 'Promesa', valor: 'Trabajar y estudiar es posible: concluir estudios sin dejar el trabajo, con horarios flexibles y clases grabadas.', orden: 10 },
|
||||
{ grupo: 'identidad', clave: 'naturaleza', etiqueta: 'Validez oficial', valor: 'INSTA opera como centro de apoyo de Centro Educativo Puebla, entidad que tiene los RVOE de los programas ante la autoridad educativa. Por eso los acuerdos figuran a nombre de Centro Educativo Puebla y no de INSTA.', orden: 11 },
|
||||
|
||||
{ grupo: 'proposito', clave: 'mision', etiqueta: 'Misión', valor: 'Contribuir al desarrollo integral de la sociedad mediante formación educativa, con profesionales competentes y cumpliendo la normatividad vigente.', orden: 12 },
|
||||
{ grupo: 'proposito', clave: 'vision', etiqueta: 'Visión', valor: 'Consolidarse como institución de nivel superior con carreras adecuadas a las necesidades de la sociedad.', orden: 13 },
|
||||
{ grupo: 'proposito', clave: 'valores', etiqueta: 'Valores', valor: 'Compromiso · Lealtad · Honestidad', orden: 14 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Mensajes listos para enviar
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const guiones = [
|
||||
{ categoria: 'Primer contacto', titulo: 'Saludo y primeras dos preguntas', canal: 'whatsapp', orden: 1,
|
||||
cuerpo: '¡Hola! Gracias por escribir al Instituto Santiago Tapia 👋\n\nPara mandarte justo lo que necesitas, ¿me confirmas dos cosas?\n\n1. ¿Qué te interesa: bachillerato, licenciatura o maestría?\n2. ¿Te acomoda más el sabatino o el nocturno de lunes a jueves?\n\nCon eso te paso costos, horarios y requisitos del programa exacto.' },
|
||||
{ categoria: 'Primer contacto', titulo: 'Cuando solo mandaron una imagen o un audio', canal: 'whatsapp', orden: 2,
|
||||
cuerpo: 'Hola, buen día. Vi tu mensaje pero quiero asegurarme de entenderte bien. ¿Me confirmas qué información necesitas?\n\nCon gusto te paso costos, horarios y requisitos del programa que te interese.' },
|
||||
{ categoria: 'Costos', titulo: 'Costos de licenciatura con beca', canal: 'whatsapp', orden: 3,
|
||||
cuerpo: 'Te paso los números de la licenciatura:\n\n• Inscripción: $2,000 · con la beca del 25% queda en $1,500\n• Mensualidad: $2,000 · con la beca del 25% queda en $1,500\n\nY este mes tenemos *inscripción gratis durante julio*, con cupo limitado y hasta el día 31.\n\n¿Cuál horario te acomoda más, el sabatino de 3 a 8 de la tarde o el nocturno de lunes a jueves de 8 a 9:30?' },
|
||||
{ categoria: 'Costos', titulo: 'Costos de bachillerato', canal: 'whatsapp', orden: 4,
|
||||
cuerpo: 'Te paso los números del Bachillerato General:\n\n• Inscripción: $1,125\n• Mensualidad: $1,500 · con la beca del 25% queda en $1,125\n• Duración: 1 año y medio\n\nAdemás incluye diplomado en inglés y en radio y televisión, los dos avalados por la SEP.\n\n¿Te interesa el sabatino de 4 a 8 de la tarde o el nocturno de lunes y martes de 7:30 a 9?' },
|
||||
{ categoria: 'Modalidad', titulo: 'Cómo funciona el en línea', canal: 'whatsapp', orden: 5,
|
||||
cuerpo: 'Te aclaro cómo funciona el en línea:\n\n• Las clases son *en vivo* por Google Meet o Zoom, de lunes a jueves de 8:00 a 9:30 de la noche\n• Las tareas se entregan por plataforma o por WhatsApp\n• *Todas las sesiones quedan grabadas*, así que si un día no alcanzas a conectarte puedes reponerla\n\n¿Ese horario te funciona o prefieres el sabatino de 3 a 8 de la tarde?' },
|
||||
{ categoria: 'Modalidad', titulo: 'Para quien trabaja por turnos o guardias', canal: 'whatsapp', orden: 6,
|
||||
cuerpo: 'Justo por eso existe el instituto: *trabajar y estudiar es posible*.\n\nSi un día te toca guardia y no alcanzas la clase, no pierdes nada: *todas las sesiones quedan grabadas* y las repones cuando puedas. Buena parte de nuestros alumnos son personal de seguridad pública, así que el esquema está pensado para turnos.\n\n¿Te late más el sabatino o el nocturno de lunes a jueves?' },
|
||||
{ categoria: 'Becas', titulo: 'Explicar la beca del 25%', canal: 'whatsapp', orden: 7,
|
||||
cuerpo: 'La beca es del *25% para alumnos nuevos* y aplica tanto a la inscripción como a la mensualidad.\n\nEn licenciatura y maestría deja los dos conceptos en $1,500. En bachillerato deja la mensualidad en $1,125.\n\n¿Quieres que te aparte lugar con la beca aplicada?' },
|
||||
{ categoria: 'Becas', titulo: 'Cerrar con la promoción de julio', canal: 'whatsapp', orden: 8,
|
||||
cuerpo: 'Te recuerdo que la *inscripción gratis es solo durante julio* y el cupo es limitado: vence el 31.\n\nCon la beca del 25% la mensualidad queda en $1,500.\n\n¿Cuál horario te acomoda más, el sabatino de 3 a 8 de la tarde o el nocturno de lunes a jueves de 8 a 9:30? Con eso te aparto lugar.' },
|
||||
{ categoria: 'Becas', titulo: 'Programa de referidos', canal: 'whatsapp', orden: 9,
|
||||
cuerpo: 'Tenemos programa de referidos y funciona así: por *cada persona que refieras y se inscriba*, se te descuenta 20% de tu mensualidad.\n\n1 referido = 20% · 2 = 40% · 3 = 60% · 4 = 80% · 5 = *mensualidad sin costo*\n\nDos cosas para que quede claro:\n• La inscripción y la reinscripción no cambian, se quedan fijas\n• El descuento se mantiene mientras tu referido siga pagando su mensualidad\n\nEste programa no se acumula con la beca del 25% ni con la promoción de julio: se elige el que más te convenga.' },
|
||||
{ categoria: 'Requisitos', titulo: 'Requisitos de licenciatura', canal: 'whatsapp', orden: 10,
|
||||
cuerpo: 'Estos son los documentos para inscribirte a la licenciatura:\n\n• Acta de nacimiento\n• CURP\n• Certificado o constancia de bachillerato\n• 4 fotografías tamaño infantil, blanco y negro\n• Comprobante de domicilio\n• Ficha de inscripción\n• Comprobante de pago\n\nSi el certificado todavía está en trámite, la constancia sirve para empezar. ¿Te mando la ficha de inscripción?' },
|
||||
{ categoria: 'Requisitos', titulo: 'Requisitos de bachillerato', canal: 'whatsapp', orden: 11,
|
||||
cuerpo: 'Estos son los documentos para inscribirte al bachillerato:\n\n• Acta de nacimiento\n• CURP\n• Certificado de secundaria\n• 6 fotografías tamaño infantil, blanco y negro\n• Comprobante de domicilio\n• Identificación\n• Ficha de inscripción\n• Comprobante de pago\n\nOjo: el bachillerato pide 6 fotos, no 4. ¿Te mando la ficha de inscripción?' },
|
||||
{ categoria: 'Requisitos', titulo: 'Requisitos de maestría', canal: 'whatsapp', orden: 12,
|
||||
cuerpo: 'Estos son los documentos para inscribirte a la maestría:\n\n• Acta de nacimiento\n• CURP\n• Título y cédula profesional, o constancia de terminación de estudios\n• 4 fotografías tamaño infantil, blanco y negro\n• Comprobante de domicilio\n• Ficha de inscripción\n• Comprobante de pago\n\nSi tu título está en trámite, la constancia de terminación sirve. ¿Te mando la ficha?' },
|
||||
{ categoria: 'Titulación', titulo: 'Opciones de titulación', canal: 'whatsapp', orden: 13,
|
||||
cuerpo: 'Hay varias opciones de titulación y se elige según tu caso:\n\n• Por promedio\n• Por curso de titulación\n• Por tesina\n• Por tesis\n• Por experiencia profesional\n\nSi ya trabajas en el área de la carrera, la de experiencia profesional suele ser la más directa. ¿Quieres que te explique la que te convenga?' },
|
||||
{ categoria: 'Validez oficial', titulo: 'Explicar el RVOE', canal: 'whatsapp', orden: 14,
|
||||
cuerpo: 'Buena pregunta, te lo explico claro.\n\nEl *RVOE* es el reconocimiento oficial que la autoridad educativa le da a un programa para que sea válido, y el *Acuerdo* es el documento que lo formaliza.\n\nEl Instituto Santiago Tapia opera como *centro de apoyo de Centro Educativo Puebla*, que es la entidad titular de los RVOE. Por eso los acuerdos están a nombre de Centro Educativo Puebla y no del instituto: no es una irregularidad, es la figura con la que operamos.\n\n¿De qué programa quieres el número de acuerdo?' },
|
||||
{ categoria: 'Validez oficial', titulo: 'Cuando el acuerdo aún no está confirmado', canal: 'whatsapp', orden: 15,
|
||||
cuerpo: 'Prefiero darte el dato exacto y no uno aproximado: ese número de acuerdo lo estoy confirmando directamente con Centro Educativo Puebla, que es la entidad titular del RVOE.\n\nEn cuanto lo tenga te lo paso por escrito. ¿Te sirve si te lo confirmo hoy mismo?\n\n⚠️ Nota interna: no cites números de acuerdo marcados como "por confirmar" en la oferta educativa. Aplica al bachillerato y a las dos maestrías.' },
|
||||
{ categoria: 'Objeciones', titulo: 'Cuando dicen que está caro', canal: 'whatsapp', orden: 16,
|
||||
cuerpo: 'Te entiendo. Déjame poner los números completos, porque suele verse distinto:\n\n• Con la beca del 25%, la mensualidad de licenciatura queda en $1,500\n• Este mes la inscripción es gratis, así que el primer pago es solo la mensualidad\n\nY si prefieres, existe otra opción en lugar de la beca: el programa de referidos. Por cada persona que refieras y se inscriba, tu mensualidad baja 20% desde los $2,000 de lista, y con 5 referidos queda sin costo. Se elige uno de los dos esquemas, no se juntan.\n\n¿Te aparto lugar mientras lo decides? No compromete nada y te asegura el cupo.' },
|
||||
{ categoria: 'Objeciones', titulo: 'Cuando dicen que no tienen tiempo', canal: 'whatsapp', orden: 17,
|
||||
cuerpo: 'Es literalmente la razón por la que existimos: *trabajar y estudiar es posible*.\n\n• Clases en vivo de lunes a jueves de 8 a 9:30 de la noche, o sabatino de 3 a 8 de la tarde\n• Todas las sesiones quedan grabadas: si te pierdes una, la repones\n• Llevamos desde 2015 y más de 1,500 certificados entregados, la mayoría de gente que trabaja\n\n¿Cuál de los dos horarios se acomoda mejor a tu jornada?' },
|
||||
{ categoria: 'Objeciones', titulo: 'Cuando no viven en Morelia', canal: 'whatsapp', orden: 18,
|
||||
cuerpo: 'No hay problema, la modalidad en línea está pensada justo para eso.\n\nTenemos alumnos de Zamora, Tangancícuaro, Uruapan, Charo e Indaparapeo. Las clases son en vivo por Meet y quedan grabadas, así que no necesitas trasladarte.\n\n¿Te interesa el en línea de lunes a jueves de 8 a 9:30 de la noche?' },
|
||||
{ categoria: 'Seguimiento', titulo: 'Retomar a alguien que se quedó callado', canal: 'whatsapp', orden: 19,
|
||||
cuerpo: 'Hola, ¿pudiste revisar la información que te mandé?\n\nTe recuerdo que la inscripción gratis es solo este mes y con la beca del 25% la mensualidad queda en $1,500.\n\n¿Cuál horario te acomoda más, el sabatino de 3 a 8 de la tarde o el nocturno de lunes a jueves?' },
|
||||
{ categoria: 'Servicio', titulo: 'Mandar la grabación de una clase perdida', canal: 'whatsapp', orden: 20,
|
||||
cuerpo: 'Hola, ya avisé al maestro de tu ausencia y te comparto la grabación de la clase para que puedas reponerla.\n\nSi te queda alguna duda del tema, avísame y la canalizo con el docente.' },
|
||||
{ categoria: 'Servicio', titulo: 'Canalizar pagos y facturación', canal: 'whatsapp', orden: 21,
|
||||
cuerpo: 'Para pagos, facturación y estados de cuenta te canalizo directo con administración, que es quien lleva ese registro y te lo puede confirmar por escrito.\n\n¿Te comunico ahora o prefieres que te marquen? Nuestro horario es lunes a viernes de 10:00 a 18:00 y sábados de 8:00 a 20:00.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Datos pendientes de confirmar (§9 de la base de conocimiento)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export const discrepancias = [
|
||||
{ titulo: 'Falta el número de RVOE del Bachillerato General', ambito: 'RVOE', prioridad: 'alta', estado: 'abierto', responsable: 'Dirección', orden: 1,
|
||||
detalle: 'El archivo de oferta lo registró con el Acuerdo 20211511, pero ese acuerdo es de la Licenciatura en Administración, confirmado por Centro Educativo Puebla. Falta el número real. Mientras no se tenga, no citar ningún acuerdo del bachillerato.' },
|
||||
{ titulo: 'Falta el RVOE de las dos maestrías', ambito: 'RVOE', prioridad: 'alta', estado: 'abierto', responsable: 'Dirección', orden: 2,
|
||||
detalle: 'Ni la Maestría en Educación ni la de Juicios Orales tienen acuerdo especificado en ninguna fuente. Solicitarlo a Centro Educativo Puebla: ya hubo una docente pidiendo el número por escrito para un trámite de beca en su escuela.' },
|
||||
{ titulo: 'Trabajo Social: el acuerdo figura ante otra autoridad', ambito: 'RVOE', prioridad: 'media', estado: 'abierto', responsable: 'Dirección', orden: 3,
|
||||
detalle: 'Su acuerdo SEG/0127/2021, del 30 de septiembre de 2021, está a nombre de la Secretaría de Educación de Guerrero y no de Centro Educativo Puebla como el resto. Confirmar si este programa se avala por otra vía.' },
|
||||
{ titulo: '¿La beca del 25% aplica solo el primer ciclo o toda la carrera?', ambito: 'Costos', prioridad: 'alta', estado: 'abierto', responsable: 'Dirección', orden: 4,
|
||||
detalle: 'La base de conocimiento dice "primer ciclo". En una conversación se le dijo a una interesada de Trabajo Social que el precio quedaba congelado los 3 años completos. Es la pregunta que más pesa al decidir una carrera de 3 años y hoy se contesta distinto según quién atienda. Definirlo por escrito.' },
|
||||
{ titulo: 'Confirmar la próxima fecha de inscripción vigente', ambito: 'Fechas', prioridad: 'alta', estado: 'abierto', responsable: 'Dirección', orden: 5,
|
||||
detalle: 'El archivo de oferta marca mayo de 2026 para bachillerato y licenciaturas, y enero de 2026 para maestrías. Ambas fechas ya pasaron respecto a la operación actual, así que hay que confirmar cuál es la próxima fecha real de ingreso antes de comprometerla con un interesado.' },
|
||||
{ titulo: 'Revisar el precio de inscripción del volante antes de imprimir', ambito: 'Costos', prioridad: 'media', estado: 'aclarado', responsable: 'Dirección', orden: 6,
|
||||
detalle: 'Ya está aclarado y se deja registrado para que no se vuelva a confundir: los $1,500 fijos de inscripción y reinscripción son los de licenciatura y maestría con beca; en bachillerato la inscripción es de $1,125. No son dos cifras para lo mismo, son dos niveles distintos, y ambas se pueden citar. Lo que sí hay que hacer es comprobar que el volante diga la que corresponde al programa que anuncia.' },
|
||||
{ titulo: 'Identidad "UPAV" en el volante de referidos', ambito: 'Marca', prioridad: 'media', estado: 'abierto', responsable: 'Dirección', orden: 7,
|
||||
detalle: 'El volante impreso del programa de referidos se titula "Instituto Santiago Tapia UPAV" y trae la dirección 1409-A con CP 58114. Ni la identidad ni la dirección coinciden con la sede real (1409-d, CP 58110) ni con Centro Educativo Puebla. Confirmar si es un error de imprenta o si existe además una relación con la UPAV.' },
|
||||
];
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
-- =====================================================================
|
||||
-- Portal Interno INSTA — esquema
|
||||
-- Instituto Santiago Tapia · Morelia, Michoacán
|
||||
-- MySQL 8.0 · utf8mb4
|
||||
-- =====================================================================
|
||||
-- El portal es una base de conocimiento: oferta educativa, requisitos,
|
||||
-- becas y fechas, links de clases, preguntas frecuentes y contacto.
|
||||
-- El catálogo lo edita dirección; los links de clases, dirección y
|
||||
-- administración. Toda edición queda en la bitácora.
|
||||
--
|
||||
-- El esquema se reconstruye completo en cada `npm run db:reset`.
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS bitacora;
|
||||
DROP TABLE IF EXISTS sesiones;
|
||||
DROP TABLE IF EXISTS magic_tokens;
|
||||
DROP TABLE IF EXISTS clases;
|
||||
DROP TABLE IF EXISTS programas_modalidades;
|
||||
DROP TABLE IF EXISTS requisitos;
|
||||
DROP TABLE IF EXISTS programas;
|
||||
DROP TABLE IF EXISTS modalidades;
|
||||
DROP TABLE IF EXISTS niveles;
|
||||
DROP TABLE IF EXISTS promociones;
|
||||
DROP TABLE IF EXISTS guiones;
|
||||
DROP TABLE IF EXISTS kb_articulos;
|
||||
DROP TABLE IF EXISTS titulacion_opciones;
|
||||
DROP TABLE IF EXISTS institucional;
|
||||
DROP TABLE IF EXISTS discrepancias;
|
||||
DROP TABLE IF EXISTS roles_permisos;
|
||||
DROP TABLE IF EXISTS permisos;
|
||||
DROP TABLE IF EXISTS usuarios;
|
||||
DROP TABLE IF EXISTS roles;
|
||||
|
||||
-- Tablas de la versión anterior del portal, ya retiradas.
|
||||
DROP TABLE IF EXISTS prospectos_etiquetas;
|
||||
DROP TABLE IF EXISTS prospectos;
|
||||
DROP TABLE IF EXISTS pendientes;
|
||||
DROP TABLE IF EXISTS cotizaciones;
|
||||
DROP TABLE IF EXISTS metricas_crm;
|
||||
DROP TABLE IF EXISTS etiquetas;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1. Identidad, roles y permisos
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clave VARCHAR(32) NOT NULL UNIQUE,
|
||||
nombre VARCHAR(64) NOT NULL,
|
||||
nivel TINYINT NOT NULL COMMENT '3 dirección · 2 administración · 1 empleado',
|
||||
color CHAR(7) NOT NULL DEFAULT '#4275B7',
|
||||
descripcion VARCHAR(255) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE permisos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clave VARCHAR(48) NOT NULL UNIQUE COMMENT 'seccion.accion',
|
||||
seccion VARCHAR(32) NOT NULL,
|
||||
descripcion VARCHAR(255) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE roles_permisos (
|
||||
rol_id INT NOT NULL,
|
||||
permiso_id INT NOT NULL,
|
||||
PRIMARY KEY (rol_id, permiso_id),
|
||||
CONSTRAINT fk_rp_rol FOREIGN KEY (rol_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_rp_permiso FOREIGN KEY (permiso_id) REFERENCES permisos(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE usuarios (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nombre VARCHAR(96) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL UNIQUE,
|
||||
puesto VARCHAR(96) NOT NULL DEFAULT '',
|
||||
rol_id INT NOT NULL,
|
||||
iniciales CHAR(2) NOT NULL DEFAULT '??',
|
||||
extension VARCHAR(16) NOT NULL DEFAULT '',
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
es_demo TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'visible en el acceso rápido de la demo',
|
||||
ultimo_acceso DATETIME NULL,
|
||||
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_usuario_rol FOREIGN KEY (rol_id) REFERENCES roles(id),
|
||||
INDEX idx_usuarios_rol (rol_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Magic Login: el token nunca se guarda en claro, solo su SHA-256.
|
||||
CREATE TABLE magic_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
usuario_id INT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
proposito VARCHAR(32) NOT NULL DEFAULT 'login',
|
||||
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expira_en DATETIME NOT NULL,
|
||||
usado_en DATETIME NULL,
|
||||
ip_origen VARCHAR(64) NOT NULL DEFAULT '',
|
||||
CONSTRAINT fk_token_usuario FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||
INDEX idx_tokens_usuario (usuario_id),
|
||||
INDEX idx_tokens_expira (expira_en)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE sesiones (
|
||||
id CHAR(64) PRIMARY KEY COMMENT 'SHA-256 del id que viaja en la cookie',
|
||||
usuario_id INT NOT NULL,
|
||||
creada_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expira_en DATETIME NOT NULL,
|
||||
user_agent VARCHAR(255) NOT NULL DEFAULT '',
|
||||
ip_origen VARCHAR(64) NOT NULL DEFAULT '',
|
||||
CONSTRAINT fk_sesion_usuario FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||
INDEX idx_sesiones_usuario (usuario_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE bitacora (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
usuario_id INT NULL,
|
||||
actor VARCHAR(96) NOT NULL DEFAULT 'sistema',
|
||||
rol VARCHAR(32) NOT NULL DEFAULT '',
|
||||
accion VARCHAR(64) NOT NULL,
|
||||
entidad VARCHAR(48) NOT NULL DEFAULT '',
|
||||
entidad_id VARCHAR(48) NOT NULL DEFAULT '',
|
||||
resultado VARCHAR(16) NOT NULL DEFAULT 'ok' COMMENT 'ok · denegado · error',
|
||||
detalle VARCHAR(500) NOT NULL DEFAULT '',
|
||||
ip_origen VARCHAR(64) NOT NULL DEFAULT '',
|
||||
creado_en DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_bitacora_usuario FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE SET NULL,
|
||||
INDEX idx_bitacora_creado (creado_en),
|
||||
INDEX idx_bitacora_accion (accion)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2. Oferta educativa
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE niveles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clave VARCHAR(32) NOT NULL UNIQUE,
|
||||
nombre VARCHAR(64) NOT NULL,
|
||||
orden TINYINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE modalidades (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clave VARCHAR(32) NOT NULL UNIQUE,
|
||||
nombre VARCHAR(64) NOT NULL,
|
||||
color CHAR(7) NOT NULL DEFAULT '#5E35B1',
|
||||
detalle VARCHAR(200) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE programas (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nivel_id INT NOT NULL,
|
||||
clave VARCHAR(48) NOT NULL UNIQUE,
|
||||
nombre VARCHAR(120) NOT NULL,
|
||||
nombre_corto VARCHAR(48) NOT NULL,
|
||||
duracion VARCHAR(48) NOT NULL,
|
||||
fecha_inicio VARCHAR(32) NOT NULL,
|
||||
inscripcion_lista DECIMAL(10,2) NOT NULL,
|
||||
inscripcion_beca DECIMAL(10,2) NOT NULL,
|
||||
mensualidad_lista DECIMAL(10,2) NOT NULL,
|
||||
mensualidad_beca DECIMAL(10,2) NOT NULL,
|
||||
ciclo_semanas SMALLINT NULL,
|
||||
rvoe_acuerdo VARCHAR(48) NOT NULL DEFAULT '',
|
||||
rvoe_fecha DATE NULL,
|
||||
rvoe_autoridad VARCHAR(120) NOT NULL DEFAULT '',
|
||||
rvoe_estado VARCHAR(24) NOT NULL DEFAULT 'confirmado' COMMENT 'confirmado · revisar · por-confirmar',
|
||||
rvoe_nota VARCHAR(500) NOT NULL DEFAULT '',
|
||||
beneficios VARCHAR(500) NOT NULL DEFAULT '',
|
||||
color CHAR(7) NOT NULL DEFAULT '#17A171',
|
||||
orden TINYINT NOT NULL DEFAULT 0,
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
actualizado_por INT NULL,
|
||||
actualizado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_programa_nivel FOREIGN KEY (nivel_id) REFERENCES niveles(id),
|
||||
CONSTRAINT fk_programa_usuario FOREIGN KEY (actualizado_por) REFERENCES usuarios(id) ON DELETE SET NULL,
|
||||
INDEX idx_programas_nivel (nivel_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE programas_modalidades (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
programa_id INT NOT NULL,
|
||||
modalidad_id INT NOT NULL,
|
||||
dias VARCHAR(64) NOT NULL DEFAULT '',
|
||||
hora_inicio VARCHAR(8) NOT NULL DEFAULT '',
|
||||
hora_fin VARCHAR(8) NOT NULL DEFAULT '',
|
||||
nota VARCHAR(160) NOT NULL DEFAULT '',
|
||||
CONSTRAINT fk_pm_programa FOREIGN KEY (programa_id) REFERENCES programas(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pm_modalidad FOREIGN KEY (modalidad_id) REFERENCES modalidades(id) ON DELETE CASCADE,
|
||||
INDEX idx_pm_programa (programa_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE requisitos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nivel_id INT NOT NULL,
|
||||
texto VARCHAR(160) NOT NULL,
|
||||
nota VARCHAR(200) NOT NULL DEFAULT '',
|
||||
orden TINYINT NOT NULL DEFAULT 0,
|
||||
CONSTRAINT fk_requisito_nivel FOREIGN KEY (nivel_id) REFERENCES niveles(id) ON DELETE CASCADE,
|
||||
INDEX idx_requisitos_nivel (nivel_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE titulacion_opciones (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nombre VARCHAR(64) NOT NULL,
|
||||
descripcion VARCHAR(255) NOT NULL DEFAULT '',
|
||||
orden TINYINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 3. Becas, promociones y fechas de inscripción
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE promociones (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clave VARCHAR(48) NOT NULL UNIQUE,
|
||||
nombre VARCHAR(120) NOT NULL,
|
||||
resumen VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'la frase que se le dice al interesado',
|
||||
aplica_a VARCHAR(24) NOT NULL COMMENT 'inscripcion · mensualidad · ambos',
|
||||
vigencia VARCHAR(120) NOT NULL DEFAULT '',
|
||||
acumulable TINYINT(1) NOT NULL DEFAULT 0,
|
||||
condiciones VARCHAR(900) NOT NULL DEFAULT '',
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
orden TINYINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. Links de clases (Google Meet)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE clases (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
programa_id INT NOT NULL,
|
||||
materia VARCHAR(120) NOT NULL,
|
||||
docente VARCHAR(96) NOT NULL DEFAULT '',
|
||||
grupo VARCHAR(48) NOT NULL DEFAULT '' COMMENT 'generación o grupo',
|
||||
dias VARCHAR(64) NOT NULL DEFAULT '',
|
||||
hora_inicio VARCHAR(8) NOT NULL DEFAULT '',
|
||||
hora_fin VARCHAR(8) NOT NULL DEFAULT '',
|
||||
enlace VARCHAR(500) NOT NULL DEFAULT '',
|
||||
notas VARCHAR(300) NOT NULL DEFAULT '',
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
orden SMALLINT NOT NULL DEFAULT 0,
|
||||
actualizado_por INT NULL,
|
||||
actualizado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_clase_programa FOREIGN KEY (programa_id) REFERENCES programas(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_clase_usuario FOREIGN KEY (actualizado_por) REFERENCES usuarios(id) ON DELETE SET NULL,
|
||||
INDEX idx_clases_programa (programa_id),
|
||||
INDEX idx_clases_materia (materia)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. Conocimiento, contacto y datos por confirmar
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE guiones (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
categoria VARCHAR(48) NOT NULL,
|
||||
titulo VARCHAR(160) NOT NULL,
|
||||
cuerpo TEXT NOT NULL,
|
||||
canal VARCHAR(24) NOT NULL DEFAULT 'whatsapp',
|
||||
orden SMALLINT NOT NULL DEFAULT 0,
|
||||
INDEX idx_guiones_categoria (categoria)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE kb_articulos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
seccion VARCHAR(48) NOT NULL,
|
||||
pregunta VARCHAR(255) NOT NULL,
|
||||
respuesta TEXT NOT NULL,
|
||||
palabras_clave VARCHAR(255) NOT NULL DEFAULT '',
|
||||
fuente VARCHAR(120) NOT NULL DEFAULT 'Base de conocimiento INSTA',
|
||||
orden SMALLINT NOT NULL DEFAULT 0,
|
||||
INDEX idx_kb_seccion (seccion)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE institucional (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
grupo VARCHAR(32) NOT NULL,
|
||||
clave VARCHAR(48) NOT NULL UNIQUE,
|
||||
etiqueta VARCHAR(96) NOT NULL,
|
||||
valor TEXT NOT NULL,
|
||||
orden SMALLINT NOT NULL DEFAULT 0,
|
||||
INDEX idx_institucional_grupo (grupo)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE discrepancias (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
titulo VARCHAR(180) NOT NULL,
|
||||
detalle VARCHAR(900) NOT NULL DEFAULT '',
|
||||
ambito VARCHAR(48) NOT NULL DEFAULT 'rvoe',
|
||||
prioridad VARCHAR(16) NOT NULL DEFAULT 'media',
|
||||
estado VARCHAR(24) NOT NULL DEFAULT 'abierto' COMMENT 'abierto · en-proceso · aclarado',
|
||||
responsable VARCHAR(96) NOT NULL DEFAULT 'Dirección',
|
||||
orden SMALLINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// =====================================================================
|
||||
// Reconstruye la base del portal desde cero: esquema + datos.
|
||||
// node db/seed.mjs (o npm run db:reset)
|
||||
// =====================================================================
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { config } from '../server/config.mjs';
|
||||
import * as D from './data.mjs';
|
||||
|
||||
const raiz = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function log(paso, detalle = '') {
|
||||
console.log(` ${paso.padEnd(34, '.')} ${detalle}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cn = await mysql.createConnection({ ...config.db, multipleStatements: true, charset: 'utf8mb4' });
|
||||
console.log(`\n▸ Portal INSTA · siembra de "${config.db.database}" en ${config.db.host}:${config.db.port}\n`);
|
||||
|
||||
const schema = await readFile(path.join(raiz, 'schema.sql'), 'utf8');
|
||||
await cn.query(schema);
|
||||
log('esquema reconstruido', `${schema.split('CREATE TABLE').length - 1} tablas`);
|
||||
|
||||
// ---- roles, permisos y usuarios -------------------------------------
|
||||
const rolId = {};
|
||||
for (const r of D.roles) {
|
||||
const [res] = await cn.execute(
|
||||
'INSERT INTO roles (clave, nombre, nivel, color, descripcion) VALUES (?,?,?,?,?)',
|
||||
[r.clave, r.nombre, r.nivel, r.color, r.descripcion],
|
||||
);
|
||||
rolId[r.clave] = res.insertId;
|
||||
}
|
||||
|
||||
const permisoId = {};
|
||||
for (const [clave, seccion, descripcion] of D.permisos) {
|
||||
const [res] = await cn.execute(
|
||||
'INSERT INTO permisos (clave, seccion, descripcion) VALUES (?,?,?)',
|
||||
[clave, seccion, descripcion],
|
||||
);
|
||||
permisoId[clave] = res.insertId;
|
||||
}
|
||||
|
||||
let asignaciones = 0;
|
||||
for (const [rolClave, lista] of Object.entries(D.rolesPermisos)) {
|
||||
const claves = lista === '*' ? Object.keys(permisoId) : lista;
|
||||
for (const c of claves) {
|
||||
if (!permisoId[c]) throw new Error(`Permiso desconocido en rolesPermisos.${rolClave}: ${c}`);
|
||||
await cn.execute('INSERT INTO roles_permisos (rol_id, permiso_id) VALUES (?,?)', [rolId[rolClave], permisoId[c]]);
|
||||
asignaciones++;
|
||||
}
|
||||
}
|
||||
log('roles y permisos', `${D.roles.length} roles · ${D.permisos.length} permisos · ${asignaciones} asignaciones`);
|
||||
|
||||
const usuarioId = {};
|
||||
for (const u of D.usuarios) {
|
||||
const [res] = await cn.execute(
|
||||
'INSERT INTO usuarios (nombre, email, puesto, rol_id, iniciales, extension, es_demo) VALUES (?,?,?,?,?,?,?)',
|
||||
[u.nombre, u.email, u.puesto, rolId[u.rol], u.iniciales, u.extension, u.es_demo],
|
||||
);
|
||||
usuarioId[u.email] = res.insertId;
|
||||
}
|
||||
log('usuarios', `${Object.keys(usuarioId).length} (3 en el acceso rápido)`);
|
||||
|
||||
// ---- oferta educativa -----------------------------------------------
|
||||
const nivelId = {};
|
||||
for (const n of D.niveles) {
|
||||
const [res] = await cn.execute('INSERT INTO niveles (clave, nombre, orden) VALUES (?,?,?)', [n.clave, n.nombre, n.orden]);
|
||||
nivelId[n.clave] = res.insertId;
|
||||
}
|
||||
|
||||
const modalidadId = {};
|
||||
for (const m of D.modalidades) {
|
||||
const [res] = await cn.execute(
|
||||
'INSERT INTO modalidades (clave, nombre, color, detalle) VALUES (?,?,?,?)',
|
||||
[m.clave, m.nombre, m.color, m.detalle],
|
||||
);
|
||||
modalidadId[m.clave] = res.insertId;
|
||||
}
|
||||
|
||||
const programaId = {};
|
||||
let horarios = 0;
|
||||
for (const p of D.programas) {
|
||||
const [res] = await cn.execute(
|
||||
`INSERT INTO programas
|
||||
(nivel_id, clave, nombre, nombre_corto, duracion, fecha_inicio,
|
||||
inscripcion_lista, inscripcion_beca, mensualidad_lista, mensualidad_beca, ciclo_semanas,
|
||||
rvoe_acuerdo, rvoe_fecha, rvoe_autoridad, rvoe_estado, rvoe_nota, beneficios, color, orden)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[nivelId[p.nivel], p.clave, p.nombre, p.nombre_corto, p.duracion, p.fecha_inicio,
|
||||
p.inscripcion_lista, p.inscripcion_beca, p.mensualidad_lista, p.mensualidad_beca, p.ciclo_semanas,
|
||||
p.rvoe_acuerdo, p.rvoe_fecha, p.rvoe_autoridad, p.rvoe_estado, p.rvoe_nota, p.beneficios, p.color, p.orden],
|
||||
);
|
||||
programaId[p.clave] = res.insertId;
|
||||
for (const h of p.horarios) {
|
||||
await cn.execute(
|
||||
'INSERT INTO programas_modalidades (programa_id, modalidad_id, dias, hora_inicio, hora_fin, nota) VALUES (?,?,?,?,?,?)',
|
||||
[res.insertId, modalidadId[h.modalidad], h.dias, h.hora_inicio, h.hora_fin, h.nota],
|
||||
);
|
||||
horarios++;
|
||||
}
|
||||
}
|
||||
log('programas', `${Object.keys(programaId).length} programas · ${horarios} horarios`);
|
||||
|
||||
let reqs = 0;
|
||||
for (const [nivelClave, lista] of Object.entries(D.requisitos)) {
|
||||
let orden = 1;
|
||||
for (const [texto, nota] of lista) {
|
||||
await cn.execute('INSERT INTO requisitos (nivel_id, texto, nota, orden) VALUES (?,?,?,?)', [nivelId[nivelClave], texto, nota, orden++]);
|
||||
reqs++;
|
||||
}
|
||||
}
|
||||
let ordenTit = 1;
|
||||
for (const [nombre, descripcion] of D.titulacionOpciones) {
|
||||
await cn.execute('INSERT INTO titulacion_opciones (nombre, descripcion, orden) VALUES (?,?,?)', [nombre, descripcion, ordenTit++]);
|
||||
}
|
||||
log('requisitos y titulación', `${reqs} requisitos · ${D.titulacionOpciones.length} formas de titulación`);
|
||||
|
||||
// ---- becas y promociones --------------------------------------------
|
||||
for (const p of D.promociones) {
|
||||
await cn.execute(
|
||||
'INSERT INTO promociones (clave, nombre, resumen, aplica_a, vigencia, acumulable, condiciones, orden) VALUES (?,?,?,?,?,?,?,?)',
|
||||
[p.clave, p.nombre, p.resumen, p.aplica_a, p.vigencia, p.acumulable, p.condiciones, p.orden],
|
||||
);
|
||||
}
|
||||
log('becas y promociones', `${D.promociones.length}`);
|
||||
|
||||
// ---- links de clases -------------------------------------------------
|
||||
let ordenClase = 1;
|
||||
for (const c of D.clases) {
|
||||
if (!programaId[c.programa]) throw new Error(`Programa desconocido en clases: ${c.programa}`);
|
||||
await cn.execute(
|
||||
`INSERT INTO clases (programa_id, materia, docente, grupo, dias, hora_inicio, hora_fin, enlace, notas, orden)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
[programaId[c.programa], c.materia, c.docente, c.grupo, c.dias, c.hora_inicio, c.hora_fin, c.enlace, c.notas, ordenClase++],
|
||||
);
|
||||
}
|
||||
log('links de clases', `${D.clases.length} en ${new Set(D.clases.map((c) => c.programa)).size} programas`);
|
||||
|
||||
// ---- conocimiento y contacto -----------------------------------------
|
||||
for (const g of D.guiones) {
|
||||
await cn.execute(
|
||||
'INSERT INTO guiones (categoria, titulo, cuerpo, canal, orden) VALUES (?,?,?,?,?)',
|
||||
[g.categoria, g.titulo, g.cuerpo, g.canal, g.orden],
|
||||
);
|
||||
}
|
||||
for (const a of D.kbArticulos) {
|
||||
await cn.execute(
|
||||
'INSERT INTO kb_articulos (seccion, pregunta, respuesta, palabras_clave, orden) VALUES (?,?,?,?,?)',
|
||||
[a.seccion, a.pregunta, a.respuesta, a.palabras_clave, a.orden],
|
||||
);
|
||||
}
|
||||
for (const i of D.institucional) {
|
||||
await cn.execute(
|
||||
'INSERT INTO institucional (grupo, clave, etiqueta, valor, orden) VALUES (?,?,?,?,?)',
|
||||
[i.grupo, i.clave, i.etiqueta, i.valor, i.orden],
|
||||
);
|
||||
}
|
||||
for (const d of D.discrepancias) {
|
||||
await cn.execute(
|
||||
'INSERT INTO discrepancias (titulo, detalle, ambito, prioridad, estado, responsable, orden) VALUES (?,?,?,?,?,?,?)',
|
||||
[d.titulo, d.detalle, d.ambito, d.prioridad, d.estado, d.responsable, d.orden],
|
||||
);
|
||||
}
|
||||
log('conocimiento y contacto', `${D.guiones.length} mensajes · ${D.kbArticulos.length} preguntas · ${D.institucional.length} datos · ${D.discrepancias.length} por confirmar`);
|
||||
|
||||
await cn.execute(
|
||||
"INSERT INTO bitacora (actor, rol, accion, entidad, detalle) VALUES ('sistema','','db.siembra','base','Base reconstruida desde la base de conocimiento INSTA')",
|
||||
);
|
||||
|
||||
// ---- verificación ----------------------------------------------------
|
||||
const [tablas] = await cn.query(
|
||||
'SELECT TABLE_NAME AS t FROM information_schema.tables WHERE table_schema = ? ORDER BY t',
|
||||
[config.db.database],
|
||||
);
|
||||
let total = 0;
|
||||
const vacias = [];
|
||||
for (const { t } of tablas) {
|
||||
const [[{ n }]] = await cn.query(`SELECT COUNT(*) AS n FROM \`${t}\``);
|
||||
total += n;
|
||||
if (n === 0) vacias.push(t);
|
||||
}
|
||||
console.log(`\n ✔ ${tablas.length} tablas · ${total} filas`);
|
||||
console.log(` ✔ vacías por diseño: ${vacias.join(', ') || 'ninguna'}`);
|
||||
console.log('\n Accesos demo: [email protected] · [email protected] · [email protected]\n');
|
||||
|
||||
await cn.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n✖ La siembra falló:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
# =====================================================================
|
||||
# Portal interno INSTA — despliegue en Coolify
|
||||
# ---------------------------------------------------------------------
|
||||
# Reglas de la instancia que este archivo cumple:
|
||||
# · La app llega a MySQL por el NOMBRE DEL SERVICIO (mysql), no localhost.
|
||||
# · No se publica ningún puerto del host: Traefik enruta al 4180 interno.
|
||||
# · SERVICE_FQDN_WEB_4180 le dice a Coolify qué servicio y puerto exponer.
|
||||
# · Los datos de MySQL viven en un volumen con nombre, así que sobreviven
|
||||
# a un redespliegue.
|
||||
# · Ninguna contraseña está aquí: todas llegan como variables de entorno.
|
||||
#
|
||||
# El compose de desarrollo es docker-compose.yml (solo MySQL). Este es el
|
||||
# de producción y levanta también el portal.
|
||||
# =====================================================================
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME:-insta_portal}
|
||||
MYSQL_USER: ${DB_USER:-insta}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- 'insta_mysql_data:/var/lib/mysql'
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- 'mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent'
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 40s
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Coolify sustituye esto por el dominio real y arma las rutas de Traefik.
|
||||
- SERVICE_FQDN_WEB_4180
|
||||
- PORT=4180
|
||||
# A MySQL por nombre de servicio, nunca localhost.
|
||||
- DB_HOST=mysql
|
||||
- DB_PORT=3306
|
||||
- 'DB_NAME=${DB_NAME:-insta_portal}'
|
||||
- 'DB_USER=${DB_USER:-insta}'
|
||||
- 'DB_PASSWORD=${DB_PASSWORD}'
|
||||
# Detrás de Traefik: cookie Secure y la IP real para la bitácora.
|
||||
- COOKIE_SEGURA=true
|
||||
- CONFIAR_PROXY=true
|
||||
# La demostración muestra el enlace de acceso en pantalla a propósito,
|
||||
# para que cualquiera pueda entrar a probarla con un clic.
|
||||
- 'MOSTRAR_ENLACE=${MOSTRAR_ENLACE:-true}'
|
||||
# true en un redespliegue devuelve la demo a su estado inicial.
|
||||
- 'SEMBRAR_SIEMPRE=${SEMBRAR_SIEMPRE:-false}'
|
||||
- 'ENLACES_POR_VENTANA=${ENLACES_POR_VENTANA:-60}'
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- curl
|
||||
- '-fsS'
|
||||
- 'http://127.0.0.1:4180/salud'
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 45s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
insta_mysql_data: null
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: insta_portal_mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: insta_root_demo
|
||||
MYSQL_DATABASE: insta_portal
|
||||
MYSQL_USER: insta
|
||||
MYSQL_PASSWORD: insta_demo_2026
|
||||
command: >
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
ports:
|
||||
- "3307:3306"
|
||||
volumes:
|
||||
- insta_portal_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-pinsta_root_demo"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
insta_portal_data:
|
||||
Generated
+1031
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "insta-portal",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Portal interno del Instituto Santiago Tapia — base de conocimiento con Magic Login y permisos por rol",
|
||||
"scripts": {
|
||||
"start": "node server/index.mjs",
|
||||
"dev": "node --watch server/index.mjs",
|
||||
"db:reset": "node db/seed.mjs",
|
||||
"reiniciar": "powershell -ExecutionPolicy Bypass -File scripts/reiniciar.ps1",
|
||||
"test": "playwright test",
|
||||
"test:webkit": "playwright test --project=webkit",
|
||||
"test:api": "node tests/api.mjs",
|
||||
"capturas": "node tests/capturas.mjs",
|
||||
"codificacion": "node scripts/revisar-codificacion.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"mysql2": "^3.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// La demo se verifica en WebKit, en escritorio y en un iPhone real de perfil.
|
||||
// Un solo worker: las pruebas comparten la base de la demo y algunas escriben.
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/reporte' }]],
|
||||
use: {
|
||||
baseURL: 'http://localhost:4180',
|
||||
locale: 'es-MX',
|
||||
timezoneId: 'America/Mexico_City',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'], viewport: { width: 1440, height: 900 } } },
|
||||
{ name: 'webkit-movil', use: { ...devices['iPhone 13'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'node server/index.mjs',
|
||||
url: 'http://localhost:4180/api/auth/demo',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,774 @@
|
||||
/* =====================================================================
|
||||
Portal Interno INSTA — hoja de estilo
|
||||
---------------------------------------------------------------------
|
||||
Tema claro, texto grande y contrastes altos: el portal lo usa gente
|
||||
que lleva años en el instituto y lo consulta a media conversación,
|
||||
muchas veces desde el teléfono. Se prefiere una línea de más antes que
|
||||
una letra más chica.
|
||||
|
||||
La geometría sale del logo: una placa con radio grande en la esquina
|
||||
superior izquierda e inferior derecha, y casi recto en las otras dos.
|
||||
El amarillo se usa solo como marcador sobre el dato importante.
|
||||
===================================================================== */
|
||||
|
||||
:root {
|
||||
/* --- marca (muestreada del logo oficial) --- */
|
||||
--azul: #4275b7;
|
||||
--azul-profundo: #30517e;
|
||||
--azul-panel: #9dc2e8;
|
||||
--azul-niebla: #e7effa;
|
||||
--azul-humo: #f3f7fc;
|
||||
--marca: #efef07;
|
||||
|
||||
/* --- texto y superficies (contraste alto sobre papel) --- */
|
||||
--carbon: #14181d; /* 15.6:1 sobre papel */
|
||||
--tinta: #16283f; /* títulos */
|
||||
--gris: #45535f; /* 8.4:1 · texto secundario */
|
||||
--gris-claro: #66737f; /* 5.4:1 · etiquetas pequeñas */
|
||||
--papel: #fafbfc;
|
||||
--papel-hueco: #ffffff;
|
||||
--linea: #cfdce9;
|
||||
--linea-suave: #e5edf5;
|
||||
|
||||
/* --- estado --- */
|
||||
--alerta: #b32222; /* 6.5:1 */
|
||||
--atencion: #a95c00; /* 4.9:1 */
|
||||
--bien: #0c7350; /* 5.1:1 */
|
||||
|
||||
/* --- tipografía --- */
|
||||
--serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
|
||||
--sans: 'Segoe UI Variable Text', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', system-ui, sans-serif;
|
||||
--mono: 'Cascadia Mono', Consolas, 'SF Mono', 'Roboto Mono', ui-monospace, monospace;
|
||||
|
||||
/* --- forma --- */
|
||||
--radio-placa: 20px 4px 20px 4px;
|
||||
--radio-chico: 10px 2px 10px 2px;
|
||||
--radio-pill: 999px;
|
||||
|
||||
--sombra: 0 1px 2px rgba(22, 40, 63, .07), 0 8px 24px -14px rgba(22, 40, 63, .2);
|
||||
--sombra-alta: 0 2px 6px rgba(22, 40, 63, .1), 0 22px 50px -20px rgba(22, 40, 63, .3);
|
||||
|
||||
--ancho: 1120px;
|
||||
--barra: 282px;
|
||||
--toque: 44px; /* alto mínimo de cualquier cosa que se pueda tocar */
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; color-scheme: light; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--papel);
|
||||
color: var(--carbon);
|
||||
font-family: var(--sans);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 { font-family: var(--serif); font-weight: 600; line-height: 1.2; margin: 0; }
|
||||
p { margin: 0; }
|
||||
a { color: var(--azul-profundo); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
||||
a:hover { color: var(--azul); }
|
||||
|
||||
:focus-visible { outline: 3px solid var(--azul); outline-offset: 2px; border-radius: 3px; }
|
||||
::selection { background: var(--marca); color: #14181d; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Rótulo
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.rotulo {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
text-decoration: none; color: inherit;
|
||||
min-height: var(--toque); /* es un enlace: tiene que poder tocarse */
|
||||
}
|
||||
.rotulo img { width: 48px; height: auto; flex: none; border-radius: var(--radio-chico); }
|
||||
.rotulo__texto { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.rotulo__nombre { font-family: var(--serif); font-size: 17px; font-weight: 600; color: var(--tinta); }
|
||||
.rotulo__sub { font-size: 13px; letter-spacing: .05em; text-transform: uppercase; color: var(--gris); }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Etiquetas y datos
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
/* 14 px y en gris oscuro: en mayúsculas y con tracking, cualquier cosa más
|
||||
chica se vuelve difícil de leer, y en teléfono esta etiqueta es lo único
|
||||
que nombra el valor de cada celda. */
|
||||
.ceja {
|
||||
font-size: 14px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .07em; color: var(--gris);
|
||||
}
|
||||
.dato { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Marcador amarillo: el único uso del amarillo sobre texto. La banda se
|
||||
queda por debajo de la línea base para no comerse los descendentes de la
|
||||
g, la j o la p en texto blanco sobre el azul. */
|
||||
.marcado > * {
|
||||
padding: 0 .1em;
|
||||
background-image: linear-gradient(to top, rgba(239, 239, 7, .92) 12%, transparent 12%);
|
||||
background-size: 100% .34em;
|
||||
background-position: 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Entrada · Magic Login
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.entrada { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-columns: 1fr 1fr; }
|
||||
|
||||
.entrada__lienzo {
|
||||
background: var(--azul-profundo); color: #eef4fb;
|
||||
padding: 48px 44px;
|
||||
display: flex; flex-direction: column; justify-content: space-between; gap: 30px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.entrada__lienzo::before {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(120% 90% at 78% -12%, rgba(157, 194, 232, .26), transparent 60%),
|
||||
radial-gradient(90% 70% at 8% 108%, rgba(239, 239, 7, .06), transparent 62%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.entrada__lienzo > * { position: relative; }
|
||||
.entrada__lienzo .rotulo__nombre { color: #fff; }
|
||||
.entrada__lienzo .rotulo__sub { color: rgba(238, 244, 251, .88); }
|
||||
|
||||
.entrada__promesa { max-width: 33rem; }
|
||||
.entrada__promesa h1 { font-size: clamp(30px, 4.2vw, 48px); color: #fff; }
|
||||
.entrada__promesa h1 em { font-style: normal; color: var(--marca); }
|
||||
.entrada__promesa p { margin-top: 18px; color: rgba(238, 244, 251, .85); font-size: 17px; max-width: 30rem; }
|
||||
|
||||
.entrada__hechos { display: flex; flex-wrap: wrap; gap: 14px 32px; }
|
||||
.entrada__hecho { display: flex; flex-direction: column; }
|
||||
.entrada__hecho b { font-family: var(--mono); font-size: 22px; color: #fff; font-variant-numeric: tabular-nums; }
|
||||
.entrada__hecho span { font-size: 13px; text-transform: uppercase; letter-spacing: .07em; color: rgba(238, 244, 251, .88); }
|
||||
|
||||
.entrada__acceso { padding: 44px; display: flex; flex-direction: column; justify-content: center; background: var(--papel-hueco); }
|
||||
.acceso { width: 100%; max-width: 460px; margin-inline: auto; }
|
||||
.acceso > .ceja { display: block; margin-bottom: 8px; }
|
||||
.acceso h2 { font-size: 29px; color: var(--tinta); }
|
||||
.acceso__nota { margin-top: 10px; color: var(--gris); }
|
||||
|
||||
.acceso__roles { margin-top: 26px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tarjeta-rol {
|
||||
display: grid; grid-template-columns: 44px 1fr; align-items: center; gap: 14px;
|
||||
width: 100%; text-align: left; cursor: pointer;
|
||||
padding: 14px 16px; min-height: var(--toque);
|
||||
background: var(--papel-hueco);
|
||||
border: 2px solid var(--linea);
|
||||
border-left: 5px solid var(--rol, var(--azul));
|
||||
border-radius: var(--radio-chico);
|
||||
font: inherit; color: inherit;
|
||||
transition: border-color .16s, box-shadow .16s;
|
||||
}
|
||||
.tarjeta-rol:hover:not(:disabled) { border-color: var(--azul); box-shadow: var(--sombra); }
|
||||
.tarjeta-rol:disabled { opacity: .6; cursor: progress; }
|
||||
.tarjeta-rol__txt { min-width: 0; }
|
||||
.tarjeta-rol__txt b { display: block; font-size: 17px; color: var(--tinta); font-weight: 600; }
|
||||
.tarjeta-rol__txt span { display: block; font-size: 14.5px; color: var(--gris); line-height: 1.45; }
|
||||
|
||||
.ficha {
|
||||
width: 44px; height: 44px; border-radius: var(--radio-chico); flex: none;
|
||||
display: grid; place-items: center;
|
||||
background: var(--azul-niebla); color: var(--azul-profundo);
|
||||
border: 2px solid var(--azul-panel);
|
||||
font-family: var(--mono); font-size: 15px; font-weight: 700;
|
||||
}
|
||||
.ficha--chica { width: 36px; height: 36px; font-size: 13px; }
|
||||
|
||||
.separador {
|
||||
display: flex; align-items: center; gap: 12px; margin: 26px 0 18px;
|
||||
font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--gris-claro);
|
||||
}
|
||||
.separador::before, .separador::after { content: ''; height: 1px; background: var(--linea); flex: 1; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Formularios
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.campo { display: flex; flex-direction: column; gap: 7px; }
|
||||
.campo__ayuda { font-size: 15.5px; color: var(--gris); }
|
||||
label { font-size: 16px; font-weight: 600; color: var(--tinta); }
|
||||
|
||||
.campo-casilla {
|
||||
display: flex; align-items: center; gap: 13px; cursor: pointer;
|
||||
min-height: var(--toque); padding: 8px 12px;
|
||||
border: 2px solid var(--linea); border-radius: var(--radio-chico);
|
||||
}
|
||||
.campo-casilla:hover { border-color: var(--azul); background: var(--azul-humo); }
|
||||
.campo-casilla:has(input:checked) { border-color: var(--azul); background: var(--azul-niebla); }
|
||||
.campo-casilla input {
|
||||
width: 26px; height: 26px; min-height: auto; flex: none;
|
||||
accent-color: var(--azul-profundo); margin: 0;
|
||||
}
|
||||
.campo-casilla span { font-weight: 600; }
|
||||
|
||||
input[type=email], input[type=text], input[type=search], input[type=url], input[type=number], select, textarea {
|
||||
font: inherit; font-size: 17px;
|
||||
padding: 11px 13px; min-height: var(--toque);
|
||||
border: 2px solid var(--linea);
|
||||
border-radius: var(--radio-chico);
|
||||
background: var(--papel-hueco); color: var(--carbon);
|
||||
width: 100%;
|
||||
}
|
||||
textarea { min-height: 120px; resize: vertical; line-height: 1.55; }
|
||||
input::placeholder, textarea::placeholder { color: var(--gris-claro); }
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none; border-color: var(--azul); box-shadow: 0 0 0 3px rgba(66, 117, 183, .22);
|
||||
}
|
||||
input[aria-invalid=true], textarea[aria-invalid=true] { border-color: var(--alerta); }
|
||||
|
||||
.boton {
|
||||
font: inherit; font-size: 16px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 10px 20px; min-height: var(--toque); cursor: pointer;
|
||||
border-radius: var(--radio-chico);
|
||||
border: 2px solid var(--azul-profundo);
|
||||
background: var(--azul-profundo); color: #fff;
|
||||
text-decoration: none;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.boton:hover:not(:disabled) { background: var(--azul); border-color: var(--azul); color: #fff; }
|
||||
.boton:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.boton svg { width: 19px; height: 19px; flex: none; }
|
||||
.boton--fantasma { background: transparent; color: var(--azul-profundo); border-color: var(--linea); }
|
||||
.boton--fantasma:hover:not(:disabled) { background: var(--azul-niebla); color: var(--azul-profundo); border-color: var(--azul); }
|
||||
.boton--peligro { background: transparent; color: var(--alerta); border-color: color-mix(in srgb, var(--alerta) 40%, transparent); }
|
||||
.boton--peligro:hover:not(:disabled) { background: color-mix(in srgb, var(--alerta) 10%, transparent); color: var(--alerta); border-color: var(--alerta); }
|
||||
.boton--chico { font-size: 14.5px; padding: 7px 13px; min-height: 38px; }
|
||||
.boton--bloque { width: 100%; }
|
||||
|
||||
.aviso {
|
||||
padding: 13px 16px;
|
||||
border-radius: var(--radio-chico);
|
||||
border: 1px solid var(--linea);
|
||||
border-left: 5px solid var(--gris-claro);
|
||||
background: var(--azul-humo);
|
||||
font-size: 16px; color: var(--carbon);
|
||||
}
|
||||
.aviso--error { border-left-color: var(--alerta); background: color-mix(in srgb, var(--alerta) 8%, transparent); }
|
||||
.aviso--ok { border-left-color: var(--bien); background: color-mix(in srgb, var(--bien) 9%, transparent); }
|
||||
.aviso--atencion { border-left-color: var(--atencion); background: color-mix(in srgb, var(--atencion) 9%, transparent); }
|
||||
.aviso--nota { border-left-color: var(--azul-panel); }
|
||||
.aviso + .aviso { margin-top: 10px; }
|
||||
|
||||
.enlace-magico code {
|
||||
display: block; margin-top: 8px; padding: 10px 12px;
|
||||
font-family: var(--mono); font-size: 13px; word-break: break-all;
|
||||
background: var(--azul-niebla); border-radius: var(--radio-chico); color: var(--azul-profundo);
|
||||
}
|
||||
|
||||
.pie-demo { margin-top: 26px; font-size: 14.5px; color: var(--gris); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.entrada { grid-template-columns: 1fr; }
|
||||
.entrada__lienzo { padding: 28px 22px 32px; }
|
||||
.entrada__acceso { padding: 30px 22px 46px; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Estructura del portal
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.portal { display: grid; grid-template-columns: var(--barra) minmax(0, 1fr); min-height: 100vh; min-height: 100dvh; }
|
||||
|
||||
.barra {
|
||||
position: sticky; top: 0; height: 100vh; height: 100dvh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--papel-hueco);
|
||||
border-right: 1px solid var(--linea);
|
||||
padding: 18px 14px 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.barra__cabeza { padding: 0 6px 16px; border-bottom: 1px solid var(--linea-suave); }
|
||||
.barra__nav { flex: 1; padding-top: 12px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.barra__grupo { margin: 16px 0 5px; padding: 0 8px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; min-height: var(--toque);
|
||||
border-radius: var(--radio-chico);
|
||||
border: 2px solid transparent;
|
||||
background: none; font: inherit; font-size: 16px; color: var(--gris);
|
||||
cursor: pointer; text-align: left; width: 100%;
|
||||
transition: background .14s, color .14s;
|
||||
}
|
||||
.nav-item:hover { background: var(--azul-humo); color: var(--tinta); }
|
||||
.nav-item[aria-current='page'] {
|
||||
background: var(--azul-niebla); color: var(--azul-profundo); font-weight: 700;
|
||||
border-color: var(--azul-panel);
|
||||
}
|
||||
.nav-item svg { width: 21px; height: 21px; flex: none; stroke-width: 1.7; }
|
||||
|
||||
.barra__pie { border-top: 1px solid var(--linea-suave); padding-top: 12px; }
|
||||
.usuario { display: grid; grid-template-columns: 44px 1fr; gap: 11px; align-items: center; padding: 0 6px 10px; }
|
||||
.usuario__txt { min-width: 0; }
|
||||
.usuario__txt b { display: block; font-size: 15.5px; color: var(--tinta); font-weight: 600; }
|
||||
/* El color del rol iría por debajo del contraste mínimo como texto, así que
|
||||
va en un punto al lado y el nombre del rol en gris legible. */
|
||||
.usuario__txt span { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; color: var(--gris); }
|
||||
.usuario__txt span::before {
|
||||
content: ''; width: 9px; height: 9px; border-radius: 50%; flex: none;
|
||||
background: var(--rol, var(--azul-profundo));
|
||||
}
|
||||
|
||||
.tope {
|
||||
display: none;
|
||||
position: sticky; top: 0; z-index: 40;
|
||||
align-items: center; gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--papel-hueco);
|
||||
border-bottom: 1px solid var(--linea);
|
||||
}
|
||||
.tope__menu {
|
||||
width: var(--toque); height: var(--toque); display: grid; place-items: center;
|
||||
border: 2px solid var(--linea); border-radius: var(--radio-chico);
|
||||
background: var(--papel-hueco); cursor: pointer; color: var(--tinta);
|
||||
}
|
||||
.tope__menu svg { width: 22px; height: 22px; }
|
||||
.tope .ficha { margin-left: auto; }
|
||||
|
||||
.lienzo { min-width: 0; padding: 30px 34px 80px; }
|
||||
.lienzo__interno { max-width: var(--ancho); margin-inline: auto; }
|
||||
|
||||
.cabecera-seccion { margin-bottom: 24px; }
|
||||
.cabecera-seccion h1 { font-size: clamp(27px, 3vw, 36px); color: var(--tinta); margin-top: 5px; }
|
||||
.cabecera-seccion p { color: var(--gris); margin-top: 8px; max-width: 62ch; }
|
||||
.cabecera-seccion__acciones { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.portal { grid-template-columns: 1fr; }
|
||||
.tope { display: flex; }
|
||||
.barra {
|
||||
position: fixed; inset: 0 auto 0 0; z-index: 60; width: min(310px, 86vw);
|
||||
transform: translateX(-102%); transition: transform .22s ease;
|
||||
box-shadow: var(--sombra-alta);
|
||||
}
|
||||
.barra[data-abierta='1'] { transform: none; }
|
||||
.velo { position: fixed; inset: 0; z-index: 50; background: rgba(22, 40, 63, .45); border: 0; }
|
||||
.lienzo { padding: 20px 18px 70px; }
|
||||
}
|
||||
@media (max-width: 520px) { .lienzo { padding: 16px 14px 64px; } }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
La placa
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.placa {
|
||||
background: var(--papel-hueco);
|
||||
border: 1px solid var(--linea);
|
||||
border-radius: var(--radio-placa);
|
||||
padding: 20px 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
.placa--acento { border-left: 5px solid var(--acento, var(--azul)); }
|
||||
.placa__ceja { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.placa h3 { font-size: 21px; color: var(--tinta); }
|
||||
.placa h4 { font-size: 18px; color: var(--tinta); }
|
||||
|
||||
/* La placa de respuesta: lo más importante de cada pantalla. */
|
||||
.respuesta {
|
||||
background: var(--azul-profundo); color: #eef4fb;
|
||||
border-radius: var(--radio-placa);
|
||||
padding: 24px 26px;
|
||||
display: grid; gap: 16px;
|
||||
}
|
||||
.respuesta .ceja { color: rgba(238, 244, 251, .72); }
|
||||
.respuesta h2 { color: #fff; font-size: clamp(21px, 2.4vw, 28px); max-width: 46ch; }
|
||||
.respuesta p { color: rgba(238, 244, 251, .88); max-width: 66ch; }
|
||||
.respuesta__fila { display: flex; align-items: flex-end; gap: 30px; flex-wrap: wrap; }
|
||||
.respuesta__cifra { display: flex; flex-direction: column; gap: 3px; }
|
||||
.respuesta__cifra b { font-family: var(--mono); font-size: clamp(26px, 3.4vw, 36px); color: #fff; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.respuesta__cifra span { font-size: 12.5px; text-transform: uppercase; letter-spacing: .09em; color: rgba(238, 244, 251, .7); }
|
||||
.respuesta__acciones { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.respuesta .boton { border-color: rgba(238, 244, 251, .45); background: transparent; color: #fff; }
|
||||
.respuesta .boton:hover:not(:disabled) { background: rgba(238, 244, 251, .16); border-color: #fff; color: #fff; }
|
||||
.respuesta .boton--marca { background: var(--marca); border-color: var(--marca); color: #14181d; }
|
||||
.respuesta .boton--marca:hover:not(:disabled) { background: #f7f735; border-color: #f7f735; color: #14181d; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Rejillas
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.rejilla { display: grid; gap: 16px; }
|
||||
.rejilla--2 { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
|
||||
.rejilla--3 { grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); }
|
||||
/* Las fichas de programa llevan nombres largos y filas de horario con la hora
|
||||
a la derecha: a menos de 430 px se parten en tres renglones y se leen mal. */
|
||||
.rejilla--fichas { grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr)); }
|
||||
.rejilla > *, .pila > * { min-width: 0; }
|
||||
.rejilla--arriba { align-items: start; }
|
||||
.pila { display: grid; gap: 16px; }
|
||||
.pila--ancha { gap: 24px; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Insignias
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
/* Dos clases de insignia, y la diferencia es de contraste, no de gusto:
|
||||
- Etiqueta (por omisión): el tono puede ser un color claro del catálogo
|
||||
(el verde pálido de una maestría), así que el texto va en tinta y el
|
||||
color solo tiñe el fondo y el borde. Así nunca queda ilegible.
|
||||
- Estado (--fuerte): el tono es uno de los semánticos, que sí tienen
|
||||
contraste suficiente, y ahí el color del texto es la información. */
|
||||
.insignia {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 14px; font-weight: 700;
|
||||
padding: 4px 12px; border-radius: var(--radio-pill);
|
||||
background: color-mix(in srgb, var(--tono, var(--gris)) 16%, #fff);
|
||||
color: var(--carbon);
|
||||
border: 1px solid color-mix(in srgb, var(--tono, var(--gris)) 50%, #fff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.insignia--fuerte {
|
||||
color: var(--tono, var(--gris));
|
||||
background: color-mix(in srgb, var(--tono, var(--gris)) 11%, #fff);
|
||||
border-color: color-mix(in srgb, var(--tono, var(--gris)) 38%, #fff);
|
||||
}
|
||||
.insignia__punto {
|
||||
width: 9px; height: 9px; border-radius: 50%; flex: none;
|
||||
background: var(--tono, var(--gris));
|
||||
}
|
||||
.etiquetas { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Tablas: en escritorio son tabla, abajo de 780 px se apilan
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.envoltura-tabla { overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; }
|
||||
.tabla { width: 100%; border-collapse: collapse; font-size: 16px; }
|
||||
.tabla th {
|
||||
text-align: left; padding: 10px 14px;
|
||||
font-size: 14px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .05em; color: var(--gris);
|
||||
border-bottom: 2px solid var(--linea);
|
||||
white-space: nowrap; background: var(--azul-humo);
|
||||
}
|
||||
.tabla td { padding: 13px 14px; border-bottom: 1px solid var(--linea-suave); vertical-align: top; }
|
||||
.tabla tbody tr:last-child td { border-bottom: 0; }
|
||||
.tabla tbody tr:hover { background: var(--azul-humo); }
|
||||
.tabla__num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.tabla__acciones { white-space: nowrap; text-align: right; }
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.tabla--apila thead { display: none; }
|
||||
.tabla--apila tbody tr {
|
||||
display: grid; gap: 4px; padding: 15px 0;
|
||||
border-bottom: 1px solid var(--linea);
|
||||
}
|
||||
.tabla--apila tbody tr:hover { background: none; }
|
||||
.tabla--apila td { display: grid; grid-template-columns: 38% 1fr; gap: 12px; padding: 3px 0; border: 0; }
|
||||
/* En teléfono esta etiqueta es el único encabezado que ve la persona, así
|
||||
que va al mismo tamaño y contraste que el resto del texto. */
|
||||
.tabla--apila td::before {
|
||||
content: attr(data-col);
|
||||
font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--gris);
|
||||
}
|
||||
.tabla--apila td:empty { display: none; }
|
||||
.tabla__num, .tabla__acciones { text-align: left; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Oferta educativa: la ficha de programa
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.vista-alterna { display: inline-flex; border: 2px solid var(--linea); border-radius: var(--radio-chico); overflow: hidden; }
|
||||
.vista-alterna button {
|
||||
font: inherit; font-size: 15.5px; font-weight: 600;
|
||||
padding: 9px 18px; min-height: 42px; cursor: pointer;
|
||||
border: 0; background: var(--papel-hueco); color: var(--gris);
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.vista-alterna button + button { border-left: 2px solid var(--linea); }
|
||||
.vista-alterna button:hover { background: var(--azul-humo); color: var(--tinta); }
|
||||
.vista-alterna button[aria-pressed='true'] { background: var(--azul-profundo); color: #fff; }
|
||||
.vista-alterna svg { width: 18px; height: 18px; }
|
||||
|
||||
.programa { border-left: 5px solid var(--acento); display: grid; gap: 14px; }
|
||||
.programa__cabeza { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.programa h3 { font-size: 21px; max-width: 26ch; }
|
||||
|
||||
.precio { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.precio b { font-family: var(--mono); font-size: 30px; color: var(--tinta); font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.precio s { font-family: var(--mono); font-size: 17px; color: var(--gris-claro); }
|
||||
.precio small { font-size: 14.5px; color: var(--gris); }
|
||||
|
||||
.detalles { display: grid; gap: 0; }
|
||||
.detalle { display: grid; grid-template-columns: 34% 1fr; gap: 14px; padding: 9px 0; align-items: baseline; }
|
||||
.detalle:not(:last-child) { border-bottom: 1px solid var(--linea-suave); }
|
||||
.detalle > dt { font-size: 15px; font-weight: 600; color: var(--gris); }
|
||||
.detalle > dd { margin: 0; font-size: 16.5px; color: var(--carbon); }
|
||||
@media (max-width: 560px) { .detalle { grid-template-columns: 1fr; gap: 2px; } }
|
||||
|
||||
.horario { display: flex; align-items: center; gap: 11px; padding: 9px 0; flex-wrap: wrap; }
|
||||
.horario:not(:last-child) { border-bottom: 1px solid var(--linea-suave); }
|
||||
.horario__punto { width: 12px; height: 12px; border-radius: 3px 0 3px 0; flex: none; }
|
||||
.horario b { color: var(--tinta); font-weight: 600; }
|
||||
.horario__hora { margin-left: auto; font-family: var(--mono); font-size: 16px; color: var(--carbon); white-space: nowrap; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Links de clases
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.clase-enlace {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-size: 15.5px; font-weight: 600; text-decoration: none;
|
||||
padding: 8px 14px; min-height: 40px;
|
||||
border-radius: var(--radio-chico);
|
||||
background: var(--bien); color: #fff;
|
||||
border: 2px solid var(--bien);
|
||||
}
|
||||
.clase-enlace:hover { background: #0a6144; border-color: #0a6144; color: #fff; }
|
||||
.clase-enlace svg { width: 17px; height: 17px; }
|
||||
.clase-inactiva { opacity: .55; }
|
||||
|
||||
/* La materia es lo que se busca con la vista, así que se lleva el ancho;
|
||||
la hora nunca se parte en varias líneas. */
|
||||
.tabla--clases { min-width: 880px; }
|
||||
.tabla--clases th:nth-child(1), .tabla--clases td:nth-child(1) { width: 27%; }
|
||||
.tabla--clases th:nth-child(2), .tabla--clases td:nth-child(2) { width: 14%; }
|
||||
.tabla--clases th:nth-child(3), .tabla--clases td:nth-child(3) { width: 16%; }
|
||||
.tabla--clases th:nth-child(4), .tabla--clases td:nth-child(4) { width: 15%; }
|
||||
.tabla--clases td:nth-child(5) { white-space: nowrap; }
|
||||
.tabla--clases td:nth-child(2) { white-space: nowrap; }
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.tabla--clases { min-width: 0; }
|
||||
.tabla--clases th, .tabla--clases td { width: auto !important; }
|
||||
.tabla--clases td:nth-child(5) { white-space: normal; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Filtros y acordeón
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.filtros { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.filtro {
|
||||
font: inherit; font-size: 15px; font-weight: 600;
|
||||
padding: 8px 16px; min-height: 40px; cursor: pointer;
|
||||
border: 2px solid var(--linea); border-radius: var(--radio-pill);
|
||||
background: var(--papel-hueco); color: var(--gris);
|
||||
}
|
||||
.filtro:hover { border-color: var(--azul); color: var(--tinta); }
|
||||
.filtro[aria-pressed='true'] { background: var(--azul-profundo); border-color: var(--azul-profundo); color: #fff; }
|
||||
|
||||
details.acordeon { border-bottom: 1px solid var(--linea-suave); }
|
||||
details.acordeon:last-child { border-bottom: 0; }
|
||||
details.acordeon > summary {
|
||||
cursor: pointer; padding: 15px 0; list-style: none;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
font-size: 18px; font-weight: 600; color: var(--tinta); min-height: var(--toque);
|
||||
}
|
||||
details.acordeon > summary::-webkit-details-marker { display: none; }
|
||||
details.acordeon > summary::after {
|
||||
content: '+'; margin-left: auto; flex: none;
|
||||
font-family: var(--mono); font-size: 22px; color: var(--azul-profundo); line-height: 1;
|
||||
}
|
||||
details.acordeon[open] > summary::after { content: '–'; }
|
||||
details.acordeon > summary:hover { color: var(--azul-profundo); }
|
||||
.acordeon__cuerpo { padding: 0 0 18px; color: var(--carbon); max-width: 78ch; }
|
||||
|
||||
/* Lo que se escribe en una caja de texto se muestra con sus renglones. Sin
|
||||
esto, una respuesta con viñetas en líneas separadas salía en un solo
|
||||
párrafo corrido y se perdía el trabajo de quien la redactó. */
|
||||
.respeta-renglones { white-space: pre-wrap; }
|
||||
|
||||
/* Plegable solo en teléfono: en escritorio el contenido va abierto y el
|
||||
resumen desaparece, así que la tarjeta se ve completa. En pantalla chica
|
||||
se cierra para que la sección no se convierta en un scroll infinito.
|
||||
El atributo open lo pone el JS según el ancho al pintar. */
|
||||
details.plegable > summary {
|
||||
cursor: pointer; list-style: none;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 11px 0; min-height: var(--toque);
|
||||
font-size: 16px; font-weight: 600; color: var(--azul-profundo);
|
||||
border-top: 1px solid var(--linea-suave);
|
||||
}
|
||||
details.plegable > summary::-webkit-details-marker { display: none; }
|
||||
details.plegable > summary::after { content: '+'; margin-left: auto; font-family: var(--mono); font-size: 22px; line-height: 1; }
|
||||
details.plegable[open] > summary::after { content: '–'; }
|
||||
details.plegable > summary:hover { color: var(--azul); }
|
||||
details.plegable > .plegable__cuerpo { display: grid; gap: 14px; }
|
||||
@media (min-width: 781px) { details.plegable > summary { display: none; } }
|
||||
|
||||
.mensaje-listo {
|
||||
font-size: 16px; line-height: 1.65;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
padding: 15px 17px; border-radius: var(--radio-chico);
|
||||
background: var(--azul-humo); border: 1px solid var(--linea-suave);
|
||||
color: var(--carbon);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Matriz de permisos
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.matriz { width: 100%; border-collapse: collapse; font-size: 16px; min-width: 520px; }
|
||||
/* position:relative para que cualquier hijo absoluto quede recortado por
|
||||
el contenedor con scroll y no estire la página. */
|
||||
.matriz th, .matriz td { padding: 11px 12px; border-bottom: 1px solid var(--linea-suave); text-align: left; position: relative; }
|
||||
.matriz th { font-size: 13px; text-transform: uppercase; letter-spacing: .07em; color: var(--gris); border-bottom: 2px solid var(--linea); background: var(--azul-humo); }
|
||||
.matriz td:not(:first-child), .matriz th:not(:first-child) { text-align: center; width: 118px; }
|
||||
.marca-si { color: var(--bien); font-size: 19px; font-weight: 700; }
|
||||
.marca-no { color: var(--gris-claro); font-size: 19px; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Ventana de edición
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
dialog.editor {
|
||||
padding: 0; border: 0; max-width: 640px; width: calc(100% - 28px);
|
||||
border-radius: var(--radio-placa);
|
||||
background: var(--papel-hueco); color: var(--carbon);
|
||||
box-shadow: var(--sombra-alta);
|
||||
}
|
||||
dialog.editor::backdrop { background: rgba(22, 40, 63, .52); }
|
||||
.editor__cabeza {
|
||||
padding: 20px 24px 14px; border-bottom: 1px solid var(--linea);
|
||||
display: flex; align-items: flex-start; gap: 14px;
|
||||
}
|
||||
.editor__cabeza h2 { font-size: 23px; color: var(--tinta); }
|
||||
.editor__cabeza p { font-size: 15px; color: var(--gris); margin-top: 4px; }
|
||||
.editor__cerrar {
|
||||
margin-left: auto; flex: none;
|
||||
width: 40px; height: 40px; display: grid; place-items: center;
|
||||
border: 2px solid var(--linea); border-radius: var(--radio-chico);
|
||||
background: var(--papel-hueco); color: var(--gris); cursor: pointer; font-size: 20px; line-height: 1;
|
||||
}
|
||||
.editor__cerrar:hover { border-color: var(--azul); color: var(--tinta); }
|
||||
.editor__cuerpo { padding: 20px 24px; display: grid; gap: 18px; overflow-y: auto; }
|
||||
.editor__pie {
|
||||
padding: 16px 24px 20px; border-top: 1px solid var(--linea);
|
||||
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
|
||||
}
|
||||
.editor__pie .boton--peligro { margin-right: auto; }
|
||||
.editor__error { padding: 14px 24px 0; }
|
||||
.editor__par { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
|
||||
/* La ventana se estructura como una columna: cabeza y pie fijos, y el
|
||||
formulario ocupa lo que queda y rueda. Con una altura máxima fija en el
|
||||
cuerpo, en un teléfono se veía media forma y no había manera de saber que
|
||||
faltaba lo demás. */
|
||||
dialog.editor > form { display: flex; flex-direction: column; max-height: 100%; }
|
||||
dialog.editor { max-height: calc(100dvh - 32px); }
|
||||
dialog.editor .editor__cuerpo { flex: 1; min-height: 0; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.editor__par { grid-template-columns: 1fr; }
|
||||
dialog.editor {
|
||||
width: 100%; max-width: none;
|
||||
max-height: 92dvh;
|
||||
margin: auto auto 0; /* hoja pegada al borde inferior, como en el móvil */
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.editor__cabeza, .editor__cuerpo, .editor__pie, .editor__error { padding-inline: 18px; }
|
||||
.editor__pie { position: sticky; bottom: 0; background: var(--papel-hueco); }
|
||||
.editor__pie .boton { flex: 1 1 46%; }
|
||||
.editor__pie .boton--peligro { flex-basis: 100%; margin-right: 0; order: 3; }
|
||||
}
|
||||
|
||||
/* Un pie de tarjeta con las acciones de edición. */
|
||||
.acciones-edicion {
|
||||
display: flex; gap: 9px; flex-wrap: wrap; align-items: center;
|
||||
padding-top: 14px; margin-top: 2px; border-top: 1px dashed var(--linea);
|
||||
}
|
||||
.acciones-edicion .ceja { margin-right: auto; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Estados vacíos y de carga
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.vacio {
|
||||
padding: 44px 24px; text-align: center;
|
||||
border: 2px dashed var(--linea); border-radius: var(--radio-placa);
|
||||
color: var(--gris);
|
||||
}
|
||||
.vacio h3 { font-size: 20px; color: var(--tinta); margin-bottom: 8px; }
|
||||
.vacio p { max-width: 46ch; margin-inline: auto; }
|
||||
|
||||
.cargando { display: grid; gap: 14px; }
|
||||
.hueso {
|
||||
height: 86px; border-radius: var(--radio-placa);
|
||||
background: linear-gradient(100deg, var(--linea-suave) 30%, var(--azul-humo) 50%, var(--linea-suave) 70%);
|
||||
background-size: 220% 100%;
|
||||
animation: barrido 1.3s linear infinite;
|
||||
}
|
||||
@keyframes barrido { from { background-position: 130% 0; } to { background-position: -30% 0; } }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Aviso flotante
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.brindis {
|
||||
position: fixed; left: 50%; bottom: 26px; z-index: 90;
|
||||
transform: translate(-50%, 14px);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 13px 20px; max-width: calc(100% - 32px);
|
||||
background: var(--tinta); color: #fff;
|
||||
border-radius: var(--radio-chico);
|
||||
font-size: 16px; box-shadow: var(--sombra-alta);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.brindis[data-visible='1'] { opacity: 1; transform: translate(-50%, 0); }
|
||||
.brindis__marca { width: 18px; height: 4px; background: var(--marca); border-radius: 1px; flex: none; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Entrada en escena
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.aparece { animation: sube .32s cubic-bezier(.22, .7, .3, 1) backwards; }
|
||||
@keyframes sube { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Utilidades
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
.prosa { max-width: 76ch; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.fila { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.fila--sep { justify-content: space-between; }
|
||||
/* Botones que van juntos en una celda estrecha: no se parten en dos renglones. */
|
||||
.fila--junta { flex-wrap: nowrap; gap: 7px; }
|
||||
.crece { flex: 1; min-width: 0; }
|
||||
.tenue { color: var(--gris); }
|
||||
.mini { font-size: 15px; }
|
||||
.recorte { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Texto solo para lectores de pantalla. Sin position:absolute a propósito:
|
||||
dentro de una tabla con scroll horizontal, un absoluto sin ancestro
|
||||
posicionado se cuelga del bloque inicial, se escapa del contenedor y
|
||||
estira la página entera. Esta versión se queda en el flujo. */
|
||||
.solo-lectores {
|
||||
display: inline-block;
|
||||
width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
|
||||
overflow: hidden; white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
/* Una pista que solo tiene sentido cuando la tabla se desliza. */
|
||||
.solo-movil { display: none; }
|
||||
@media (max-width: 780px) { .solo-movil { display: block; } }
|
||||
.tira-demo {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em;
|
||||
padding: 4px 12px; border-radius: var(--radio-pill);
|
||||
background: color-mix(in srgb, var(--marca) 32%, transparent);
|
||||
color: #4a4300; border: 1px solid color-mix(in srgb, var(--marca) 60%, transparent);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.barra, .tope, .cabecera-seccion__acciones, .brindis, .acciones-edicion, .vista-alterna { display: none !important; }
|
||||
.portal { grid-template-columns: 1fr; }
|
||||
.placa, .respuesta { break-inside: avoid; box-shadow: none; }
|
||||
.respuesta { background: #fff; color: #000; border: 2px solid #000; }
|
||||
.respuesta h2, .respuesta__cifra b, .respuesta p, .respuesta .ceja { color: #000; }
|
||||
details.acordeon > summary::after { display: none; }
|
||||
.acordeon__cuerpo { display: block !important; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<!doctype html>
|
||||
<html lang="es-MX">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Entrar · Portal interno INSTA</title>
|
||||
<meta name="description" content="Portal interno del Instituto Santiago Tapia. Acceso con enlace de un solo uso.">
|
||||
<meta name="color-scheme" content="light">
|
||||
<link rel="icon" href="/assets/insta-logo.webp">
|
||||
<link rel="stylesheet" href="/css/insta.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="entrada">
|
||||
<!-- Lienzo izquierdo: la promesa del instituto, que es también la del portal -->
|
||||
<section class="entrada__lienzo">
|
||||
<a class="rotulo" href="https://institutosantiagotapia.com" rel="noreferrer">
|
||||
<img src="/assets/insta-logo.webp" width="44" height="28" alt="">
|
||||
<span class="rotulo__texto">
|
||||
<span class="rotulo__nombre">Instituto Santiago Tapia</span>
|
||||
<span class="rotulo__sub">Portal interno</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="entrada__promesa">
|
||||
<h1>Trabajar y estudiar<br><em>es posible</em>.</h1>
|
||||
<p>
|
||||
Informar bien también. Aquí está la oferta educativa, los costos, las becas, las
|
||||
fechas y los links de clase en un solo lugar, para que nadie tenga que adivinar un
|
||||
dato mientras está atendiendo a alguien.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="entrada__hechos">
|
||||
<span class="entrada__hecho"><b>2015</b><span>Desde</span></span>
|
||||
<span class="entrada__hecho"><b>1,500+</b><span>Certificados</span></span>
|
||||
<span class="entrada__hecho"><b>8</b><span>Programas</span></span>
|
||||
<span class="entrada__hecho"><b>Morelia</b><span>Michoacán</span></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Acceso: Magic Login -->
|
||||
<section class="entrada__acceso">
|
||||
<div class="acceso">
|
||||
<span class="ceja">Acceso al portal</span>
|
||||
<h2>Entra con un enlace de un solo uso</h2>
|
||||
<p class="acceso__nota">
|
||||
No hay contraseñas que recordar ni compartir. Escribe tu correo institucional y
|
||||
te llega un enlace que sirve una vez y caduca en <span id="vigencia">15</span> minutos.
|
||||
</p>
|
||||
|
||||
<div id="error-entrada" hidden></div>
|
||||
|
||||
<span class="separador" id="titulo-roles">Acceso rápido de la demo</span>
|
||||
<div class="acceso__roles" id="roles" role="group" aria-labelledby="titulo-roles">
|
||||
<div class="cargando"><div class="hueso" style="height:62px"></div><div class="hueso" style="height:62px"></div><div class="hueso" style="height:62px"></div></div>
|
||||
</div>
|
||||
|
||||
<span class="separador">o con tu correo</span>
|
||||
|
||||
<form id="forma" novalidate>
|
||||
<div class="campo">
|
||||
<label for="email">Correo institucional</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email"
|
||||
placeholder="[email protected]" required>
|
||||
</div>
|
||||
<button class="boton boton--bloque" type="submit" id="enviar" style="margin-top:12px">
|
||||
Enviar mi enlace de acceso
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="resultado"></div>
|
||||
|
||||
<p class="pie-demo">
|
||||
<strong>Esto es una demo.</strong> Los tres accesos rápidos son usuarios de
|
||||
demostración, no personal real del instituto. En producción el enlace llega por
|
||||
correo; aquí se muestra en pantalla para que puedas verlo funcionar.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="brindis" id="brindis" role="status" aria-live="polite"><span class="brindis__marca"></span><span id="brindis-txt"></span></div>
|
||||
|
||||
<script type="module" src="/js/entrada.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,211 @@
|
||||
// =====================================================================
|
||||
// Shell del portal: sesión, menú según permisos y ruteo por hash.
|
||||
// El menú se pinta con lo que el servidor autoriza, no con una lista
|
||||
// escrita a mano: así no puede quedar desfasado de la API.
|
||||
// =====================================================================
|
||||
import { api, esc, icono, ficha, escalonar, activarCopiado, cargando } from './ui.mjs';
|
||||
import * as V from './vistas.mjs';
|
||||
|
||||
const VISTAS = {
|
||||
inicio: V.inicio,
|
||||
oferta: V.oferta,
|
||||
requisitos: V.requisitos,
|
||||
becas: V.becas,
|
||||
clases: V.clases,
|
||||
preguntas: V.preguntas,
|
||||
mensajes: V.mensajes,
|
||||
contacto: V.contacto,
|
||||
equipo: V.equipo,
|
||||
bitacora: V.bitacora,
|
||||
};
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
// El contenedor de la vista se reemplaza por uno nuevo en cada render.
|
||||
// Si se reusara el mismo nodo, cada montar() volvería a registrar sus
|
||||
// listeners sobre él y al recargar una sección se acumularían: un clic en
|
||||
// "Editar" abriría tantas ventanas como veces se hubiera recargado.
|
||||
let vista = $('#vista');
|
||||
|
||||
function lienzoNuevo() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lienzo__interno';
|
||||
el.id = 'vista';
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
vista.replaceWith(el);
|
||||
vista = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
// Qué bloques plegables dejó abiertos la persona. Sin esto, al guardar un
|
||||
// cambio la sección se repinta y el bloque en el que estaba trabajando se
|
||||
// vuelve a plegar, así que pierde su lugar.
|
||||
const plegablesAbiertos = new Map();
|
||||
|
||||
function recordarPlegables(caja) {
|
||||
caja.querySelectorAll('details.plegable[data-plegable]').forEach((d) => {
|
||||
const guardado = plegablesAbiertos.get(d.dataset.plegable);
|
||||
if (guardado !== undefined) d.open = guardado;
|
||||
});
|
||||
// `toggle` no burbujea, pero un listener en captura sí lo recibe.
|
||||
caja.addEventListener('toggle', (ev) => {
|
||||
const d = ev.target.closest?.('details.plegable[data-plegable]');
|
||||
if (d) plegablesAbiertos.set(d.dataset.plegable, d.open);
|
||||
}, true);
|
||||
}
|
||||
|
||||
// ---- sesión ----------------------------------------------------------
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
const yo = await api('/auth/yo');
|
||||
ctx = {
|
||||
usuario: yo.usuario,
|
||||
permisos: new Set(yo.permisos),
|
||||
navegacion: yo.navegacion,
|
||||
grupos: yo.grupos,
|
||||
recargar: () => pintar(actual, { forzar: true }),
|
||||
};
|
||||
} catch {
|
||||
location.href = '/';
|
||||
throw new Error('sin sesión');
|
||||
}
|
||||
|
||||
// ---- menú ------------------------------------------------------------
|
||||
|
||||
function botonNav(s) {
|
||||
return `<button class="nav-item" type="button" data-seccion="${esc(s.id)}" title="${esc(s.resumen)}">
|
||||
${icono(s.icono)}<span class="crece recorte">${esc(s.titulo)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function pintarMenu() {
|
||||
const sueltas = ctx.navegacion.filter((s) => !s.grupo);
|
||||
const bloques = ctx.grupos
|
||||
.map((g) => ({ ...g, items: ctx.navegacion.filter((s) => s.grupo === g.id) }))
|
||||
.filter((g) => g.items.length);
|
||||
|
||||
$('#nav').innerHTML = [
|
||||
sueltas.map(botonNav).join(''),
|
||||
...bloques.map((g) => `<div class="barra__grupo"><span class="ceja">${esc(g.titulo)}</span></div>${g.items.map(botonNav).join('')}`),
|
||||
].join('');
|
||||
|
||||
$('#pie-barra').innerHTML = `
|
||||
<div class="usuario" style="--rol:${esc(ctx.usuario.rol_color)}">
|
||||
${ficha(ctx.usuario.iniciales, ctx.usuario.rol_color)}
|
||||
<span class="usuario__txt">
|
||||
<b class="recorte">${esc(ctx.usuario.nombre)}</b>
|
||||
<span>${esc(ctx.usuario.rol_nombre)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="nav-item" id="salir" type="button">${icono('salir')}<span>Cerrar sesión</span></button>`;
|
||||
|
||||
$('#ficha-movil').innerHTML = ficha(ctx.usuario.iniciales, ctx.usuario.rol_color);
|
||||
|
||||
$('#salir').addEventListener('click', async () => {
|
||||
await api('/auth/salir', { method: 'POST' }).catch(() => {});
|
||||
location.href = '/';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ruteo -----------------------------------------------------------
|
||||
|
||||
let actual = null;
|
||||
// Cada render lleva su número. Si la persona cambia de sección mientras una
|
||||
// carga, el render viejo se descarta al llegar en lugar de bloquear el menú:
|
||||
// antes, con una bandera de "ocupado", el clic se ignoraba y el menú parecía
|
||||
// muerto hasta ir a otra sección y volver.
|
||||
let renderActual = 0;
|
||||
|
||||
function seccionValida(id) {
|
||||
return ctx.navegacion.some((s) => s.id === id) && VISTAS[id];
|
||||
}
|
||||
|
||||
async function pintar(id, { forzar = false } = {}) {
|
||||
if (!seccionValida(id)) id = ctx.navegacion[0]?.id ?? 'inicio';
|
||||
if (id === actual && !forzar) return;
|
||||
const miRender = ++renderActual;
|
||||
actual = id;
|
||||
|
||||
document.querySelectorAll('.nav-item[data-seccion]').forEach((b) => {
|
||||
if (b.dataset.seccion === id) b.setAttribute('aria-current', 'page');
|
||||
else b.removeAttribute('aria-current');
|
||||
});
|
||||
cerrarCajon();
|
||||
|
||||
lienzoNuevo().innerHTML = cargando(3, 120);
|
||||
try {
|
||||
const v = await VISTAS[id](ctx);
|
||||
if (miRender !== renderActual) return; // llegó tarde: ya se pidió otra sección
|
||||
const caja = lienzoNuevo();
|
||||
caja.innerHTML = `
|
||||
<header class="cabecera-seccion">
|
||||
<span class="ceja">${esc(v.ceja ?? '')}</span>
|
||||
<h1>${esc(v.titulo)}</h1>
|
||||
${v.resumen ? `<p>${esc(v.resumen)}</p>` : ''}
|
||||
${v.acciones ? `<div class="cabecera-seccion__acciones">${v.acciones}</div>` : ''}
|
||||
</header>
|
||||
${v.cuerpo}`;
|
||||
const contenido = caja.querySelector('.pila, .rejilla');
|
||||
if (contenido) escalonar(contenido);
|
||||
recordarPlegables(caja);
|
||||
v.montar?.(caja);
|
||||
document.title = `${v.titulo} · Portal INSTA`;
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
} catch (err) {
|
||||
if (miRender !== renderActual) return;
|
||||
vista.innerHTML = `
|
||||
<header class="cabecera-seccion"><span class="ceja">Algo falló</span><h1>No se pudo abrir esta sección</h1></header>
|
||||
<div class="aviso aviso--error">
|
||||
${esc(err.message)}
|
||||
${err.estado === 403 ? '<br>Tu rol no tiene acceso a esto. Si lo necesitas, pídeselo a dirección.' : ''}
|
||||
</div>
|
||||
<div style="margin-top:16px"><button class="boton boton--fantasma" type="button" data-ir="inicio">Volver al inicio</button></div>`;
|
||||
if (err.estado === 401) location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
const nav = ev.target.closest('.nav-item[data-seccion]');
|
||||
if (nav) { location.hash = `#/${nav.dataset.seccion}`; return; }
|
||||
const ir = ev.target.closest('[data-ir]');
|
||||
if (ir) { location.hash = `#/${ir.dataset.ir}`; }
|
||||
});
|
||||
|
||||
addEventListener('hashchange', () => pintar(location.hash.replace(/^#\/?/, '') || 'inicio'));
|
||||
|
||||
// ---- cajón móvil -----------------------------------------------------
|
||||
|
||||
function abrirCajon() {
|
||||
$('#barra').dataset.abierta = '1';
|
||||
$('#abrir-menu').setAttribute('aria-expanded', 'true');
|
||||
if (!document.querySelector('.velo')) {
|
||||
const velo = document.createElement('button');
|
||||
velo.className = 'velo';
|
||||
velo.type = 'button';
|
||||
velo.setAttribute('aria-label', 'Cerrar el menú');
|
||||
velo.addEventListener('click', cerrarCajon);
|
||||
document.body.appendChild(velo);
|
||||
}
|
||||
}
|
||||
|
||||
function cerrarCajon() {
|
||||
$('#barra').dataset.abierta = '0';
|
||||
$('#abrir-menu')?.setAttribute('aria-expanded', 'false');
|
||||
document.querySelector('.velo')?.remove();
|
||||
}
|
||||
|
||||
$('#abrir-menu').innerHTML = icono('menu');
|
||||
$('#abrir-menu').addEventListener('click', () => {
|
||||
($('#barra').dataset.abierta === '1' ? cerrarCajon : abrirCajon)();
|
||||
});
|
||||
// Escape cierra el cajón, pero no si hay una ventana de edición abierta.
|
||||
addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape' && !document.querySelector('dialog.editor[open]')) cerrarCajon();
|
||||
});
|
||||
|
||||
// ---- arranque --------------------------------------------------------
|
||||
|
||||
pintarMenu();
|
||||
activarCopiado();
|
||||
await pintar(location.hash.replace(/^#\/?/, '') || 'inicio');
|
||||
@@ -0,0 +1,89 @@
|
||||
// =====================================================================
|
||||
// Pantalla de entrada · Magic Login
|
||||
// Dos caminos al mismo sitio: acceso rápido por rol (demo) o correo.
|
||||
// =====================================================================
|
||||
import { api, esc, ficha, brindis, activarCopiado } from './ui.mjs';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
const MOTIVOS = {
|
||||
invalido: 'Ese enlace no es válido. Genera uno nuevo.',
|
||||
'ya-usado': 'Ese enlace ya se usó. Los enlaces de acceso sirven una sola vez.',
|
||||
vencido: 'El enlace venció. Genera uno nuevo.',
|
||||
'usuario-inactivo': 'Tu cuenta está desactivada. Habla con dirección.',
|
||||
};
|
||||
|
||||
function mostrarError(texto) {
|
||||
const caja = $('#error-entrada');
|
||||
caja.hidden = false;
|
||||
caja.className = 'aviso aviso--error';
|
||||
caja.textContent = texto;
|
||||
}
|
||||
|
||||
// Si venimos rebotados de /acceso/:token con un problema, decirlo aquí.
|
||||
const motivo = new URLSearchParams(location.search).get('error');
|
||||
if (motivo) mostrarError(MOTIVOS[motivo] ?? 'No se pudo validar el enlace.');
|
||||
|
||||
/** Genera el enlace y lo canjea de inmediato: es el flujo real, sin el correo. */
|
||||
async function entrar(email, boton) {
|
||||
const previo = boton?.textContent;
|
||||
if (boton) { boton.disabled = true; boton.textContent = 'Generando enlace…'; }
|
||||
$('#error-entrada').hidden = true;
|
||||
$('#resultado').innerHTML = '';
|
||||
|
||||
try {
|
||||
const r = await api('/auth/enlace', { method: 'POST', body: { email } });
|
||||
|
||||
$('#resultado').innerHTML = `
|
||||
<div class="aviso aviso--ok enlace-magico">
|
||||
<strong>Enlace listo para ${esc(r.usuario.nombre)}</strong> · ${esc(r.usuario.rol_nombre)}.
|
||||
Vence en ${esc(r.expiraEnMinutos)} minutos y sirve una sola vez.
|
||||
${r.enlace ? `<code>${esc(location.origin + r.enlace)}</code>` : ''}
|
||||
</div>`;
|
||||
|
||||
if (!r.token) {
|
||||
brindis('Enlace enviado a tu correo');
|
||||
return;
|
||||
}
|
||||
// En producción esto lo haría la persona al abrir su correo.
|
||||
await api('/auth/consumir', { method: 'POST', body: { token: r.token } });
|
||||
location.href = '/portal';
|
||||
} catch (err) {
|
||||
mostrarError(err.message);
|
||||
if (boton) { boton.disabled = false; boton.textContent = previo; }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- accesos rápidos por rol ----------------------------------------
|
||||
|
||||
try {
|
||||
const { usuarios, vigenciaMinutos } = await api('/auth/demo');
|
||||
$('#vigencia').textContent = vigenciaMinutos;
|
||||
$('#roles').innerHTML = usuarios.map((u) => `
|
||||
<button class="tarjeta-rol" type="button" data-email="${esc(u.email)}" style="--rol:${esc(u.rol_color)}">
|
||||
${ficha(u.iniciales, u.rol_color)}
|
||||
<span class="tarjeta-rol__txt">
|
||||
<b>${esc(u.rol_nombre)}</b>
|
||||
<span>${esc(u.nombre)} · ${esc(u.puesto)}</span>
|
||||
</span>
|
||||
</button>`).join('');
|
||||
|
||||
$('#roles').addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('.tarjeta-rol');
|
||||
if (!btn) return;
|
||||
entrar(btn.dataset.email, btn);
|
||||
});
|
||||
} catch {
|
||||
$('#roles').innerHTML = `<div class="aviso aviso--error">
|
||||
No se pudo hablar con el servidor. Revisa que esté corriendo <code>npm start</code>
|
||||
y que el contenedor de MySQL esté arriba.</div>`;
|
||||
}
|
||||
|
||||
$('#forma').addEventListener('submit', (ev) => {
|
||||
ev.preventDefault();
|
||||
const email = $('#email').value.trim();
|
||||
if (!email) return mostrarError('Escribe tu correo institucional.');
|
||||
entrar(email, $('#enviar'));
|
||||
});
|
||||
|
||||
activarCopiado();
|
||||
@@ -0,0 +1,400 @@
|
||||
// =====================================================================
|
||||
// Utilidades compartidas: red, formato, iconos, avisos y el editor.
|
||||
// Todo lo que se interpola en HTML pasa por esc().
|
||||
// =====================================================================
|
||||
|
||||
export const FECHA_CORTE = '2026-07-29';
|
||||
|
||||
/** Escapa texto antes de meterlo en una plantilla. */
|
||||
export function esc(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
// ---- red -------------------------------------------------------------
|
||||
|
||||
export async function api(ruta, opciones = {}) {
|
||||
const res = await fetch(`/api${ruta}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
...opciones,
|
||||
body: opciones.body ? JSON.stringify(opciones.body) : undefined,
|
||||
});
|
||||
let datos = null;
|
||||
try { datos = await res.json(); } catch { /* sin cuerpo */ }
|
||||
if (!res.ok) {
|
||||
const err = new Error(datos?.mensaje ?? `Error ${res.status}`);
|
||||
err.estado = res.status;
|
||||
err.codigo = datos?.error;
|
||||
err.campo = datos?.campo;
|
||||
throw err;
|
||||
}
|
||||
return datos;
|
||||
}
|
||||
|
||||
// ---- formato ---------------------------------------------------------
|
||||
|
||||
export const pesos = (n) => `$${Number(n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: 0 })}`;
|
||||
export const num = (n) => Number(n ?? 0).toLocaleString('es-MX');
|
||||
|
||||
const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
|
||||
|
||||
/** '2021-09-15' → '15 de septiembre de 2021' */
|
||||
export function fecha(v) {
|
||||
if (!v) return '—';
|
||||
const [a, m, d] = String(v).slice(0, 10).split('-').map(Number);
|
||||
if (!a || !m || !d) return esc(v);
|
||||
return `${d} de ${MESES[m - 1]} de ${a}`;
|
||||
}
|
||||
|
||||
/** Marca de tiempo corta para la bitácora. */
|
||||
export function fechaHora(v) {
|
||||
if (!v) return '—';
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return esc(v);
|
||||
return `${d.getDate()} ${MESES[d.getMonth()].slice(0, 3)} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Una hora de 24 h a 12 h, o null si no es una hora. */
|
||||
function aDoce(h24) {
|
||||
const [h, m] = String(h24 ?? '').split(':').map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
|
||||
return { txt: `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, '0')}`, sufijo: h >= 12 ? 'pm' : 'am' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rango de horas legible: '20:00' y '21:30' → '8:00 a 9:30 pm'.
|
||||
* Si falta o viene mal una de las dos, se muestra lo que sí se pueda leer en
|
||||
* lugar de un "12:undefined am".
|
||||
*/
|
||||
export function rangoHora(inicio, fin) {
|
||||
const a = aDoce(inicio);
|
||||
const b = aDoce(fin);
|
||||
if (!a && !b) return 'Sin horario';
|
||||
if (!b) return `desde las ${a.txt} ${a.sufijo}`;
|
||||
if (!a) return `hasta las ${b.txt} ${b.sufijo}`;
|
||||
return a.sufijo === b.sufijo ? `${a.txt} a ${b.txt} ${b.sufijo}` : `${a.txt} ${a.sufijo} a ${b.txt} ${b.sufijo}`;
|
||||
}
|
||||
|
||||
/** Traduce la acción registrada en la bitácora a algo que se entienda. */
|
||||
const ACCIONES = {
|
||||
'auth.enlace': 'Pidió un enlace de acceso',
|
||||
'auth.entrada': 'Entró al portal',
|
||||
'auth.salida': 'Cerró sesión',
|
||||
'auth.canje': 'Intentó usar un enlace',
|
||||
'acceso.denegado': 'Intentó abrir algo sin permiso',
|
||||
'db.siembra': 'Se reconstruyó la base',
|
||||
};
|
||||
const ENTIDADES = {
|
||||
oferta: 'la oferta educativa', requisitos: 'los requisitos', becas: 'las becas',
|
||||
clases: 'los links de clases', kb: 'las preguntas frecuentes', guiones: 'los mensajes listos',
|
||||
contacto: 'los datos del instituto', datos: 'los datos por confirmar',
|
||||
};
|
||||
const VERBOS = { editar: 'Editó', crear: 'Agregó algo en', borrar: 'Quitó algo de' };
|
||||
|
||||
export function accionLegible(accion) {
|
||||
if (ACCIONES[accion]) return ACCIONES[accion];
|
||||
const [seccion, verbo] = String(accion).split('.');
|
||||
if (VERBOS[verbo] && ENTIDADES[seccion]) return `${VERBOS[verbo]} ${ENTIDADES[seccion]}`;
|
||||
return accion;
|
||||
}
|
||||
|
||||
export const TONO_RVOE = { confirmado: 'var(--bien)', revisar: 'var(--atencion)', 'por-confirmar': 'var(--alerta)' };
|
||||
export const TONO_ESTADO = { abierto: 'var(--alerta)', 'en-proceso': 'var(--atencion)', aclarado: 'var(--bien)' };
|
||||
export const TONO_PRIORIDAD = { alta: 'var(--alerta)', media: 'var(--atencion)', baja: 'var(--gris-claro)' };
|
||||
|
||||
export const ETQ_RVOE = {
|
||||
confirmado: 'Validez confirmada',
|
||||
revisar: 'Por revisar',
|
||||
'por-confirmar': 'Sin confirmar',
|
||||
};
|
||||
|
||||
// ---- piezas reutilizables -------------------------------------------
|
||||
|
||||
/** Solo se aceptan tonos que este archivo controla: una variable CSS o un
|
||||
* hexadecimal. Cualquier otra cosa iría a parar al atributo style. */
|
||||
function tonoSeguro(tono) {
|
||||
const t = String(tono ?? '').trim();
|
||||
return /^(var\(--[a-z-]+\)|#[0-9a-fA-F]{3,8})$/.test(t) ? t : 'var(--gris)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Con punto es un ESTADO (el tono lleva la información y va también en el
|
||||
* texto); sin punto es una ETIQUETA (el tono puede ser claro, así que el
|
||||
* texto va en tinta para que siempre se lea).
|
||||
*/
|
||||
export function insignia(txt, tono = 'var(--gris)', { punto = false } = {}) {
|
||||
const t = tonoSeguro(tono);
|
||||
return `<span class="insignia${punto ? ' insignia--fuerte' : ''}" style="--tono:${t}">${punto ? '<span class="insignia__punto"></span>' : ''}${esc(txt)}</span>`;
|
||||
}
|
||||
|
||||
/** El color del rol va en el borde, nunca en el texto: los tonos claros
|
||||
* dejaban las iniciales por debajo del contraste mínimo. */
|
||||
export function ficha(iniciales, tono, { chica = false } = {}) {
|
||||
const estilo = tono ? ` style="border-color:${tonoSeguro(tono)}"` : '';
|
||||
return `<span class="ficha${chica ? ' ficha--chica' : ''}"${estilo}>${esc(iniciales ?? '??')}</span>`;
|
||||
}
|
||||
|
||||
export function vacio(titulo, texto) {
|
||||
return `<div class="vacio"><h3>${esc(titulo)}</h3><p>${esc(texto)}</p></div>`;
|
||||
}
|
||||
|
||||
export function cargando(n = 3, alto = 96) {
|
||||
return `<div class="cargando">${Array.from({ length: n }, () => `<div class="hueso" style="height:${alto}px"></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
export function escalonar(contenedor, retardo = 45) {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
[...contenedor.children].forEach((hijo, i) => {
|
||||
hijo.classList.add('aparece');
|
||||
hijo.style.animationDelay = `${Math.min(i * retardo, 300)}ms`;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- avisos y portapapeles -------------------------------------------
|
||||
|
||||
let temporizador;
|
||||
|
||||
export function brindis(texto) {
|
||||
const caja = document.getElementById('brindis');
|
||||
const txt = document.getElementById('brindis-txt');
|
||||
if (!caja || !txt) return;
|
||||
txt.textContent = texto;
|
||||
caja.dataset.visible = '1';
|
||||
clearTimeout(temporizador);
|
||||
temporizador = setTimeout(() => { caja.dataset.visible = '0'; }, 2600);
|
||||
}
|
||||
|
||||
export async function copiar(texto, etiqueta = 'Copiado') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(texto);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = texto;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch { /* sin respaldo */ }
|
||||
ta.remove();
|
||||
}
|
||||
brindis(etiqueta);
|
||||
}
|
||||
|
||||
/** Delegación global: cualquier elemento con data-copiar copia su texto. */
|
||||
export function activarCopiado(raiz = document) {
|
||||
raiz.addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('[data-copiar], [data-copiar-de]');
|
||||
if (!btn) return;
|
||||
const de = btn.dataset.copiarDe;
|
||||
const texto = de ? (document.getElementById(de)?.innerText ?? '') : btn.dataset.copiar;
|
||||
if (!texto) return;
|
||||
copiar(texto, btn.dataset.copiarAviso ?? 'Copiado');
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Editor: una ventana con un formulario armado desde una lista de campos
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* @param {object} cfg
|
||||
* @param {string} cfg.titulo
|
||||
* @param {string} [cfg.subtitulo]
|
||||
* @param {Array} cfg.campos lista de campos, o {par:[a,b]} para dos en una fila
|
||||
* @param {object} [cfg.valores] valores iniciales por nombre de campo
|
||||
* @param {Function} cfg.guardar async (datos) => void · puede lanzar {campo, message}
|
||||
* @param {Function} [cfg.borrar] async () => void · si viene, aparece "Eliminar"
|
||||
* @param {string} [cfg.textoGuardar]
|
||||
* @param {string} [cfg.confirmarBorrado]
|
||||
* @returns {Promise<boolean>} true si se guardó o se eliminó
|
||||
*/
|
||||
export function abrirEditor(cfg) {
|
||||
return new Promise((resolver) => {
|
||||
const dlg = document.createElement('dialog');
|
||||
dlg.className = 'editor';
|
||||
|
||||
const lista = cfg.campos.flatMap((c) => (c.par ? c.par : [c]));
|
||||
|
||||
dlg.innerHTML = `
|
||||
<form method="dialog" novalidate>
|
||||
<div class="editor__cabeza">
|
||||
<div>
|
||||
<h2>${esc(cfg.titulo)}</h2>
|
||||
${cfg.subtitulo ? `<p>${esc(cfg.subtitulo)}</p>` : ''}
|
||||
</div>
|
||||
<button type="button" class="editor__cerrar" data-cerrar aria-label="Cerrar sin guardar">×</button>
|
||||
</div>
|
||||
<div class="editor__error" hidden><div class="aviso aviso--error" role="alert"></div></div>
|
||||
<div class="editor__cuerpo">
|
||||
${cfg.campos.map((c) => (c.par
|
||||
? `<div class="editor__par">${c.par.map(pintarCampo).join('')}</div>`
|
||||
: pintarCampo(c))).join('')}
|
||||
</div>
|
||||
<div class="editor__pie">
|
||||
${cfg.borrar ? '<button type="button" class="boton boton--peligro" data-borrar>Eliminar</button>' : ''}
|
||||
<button type="button" class="boton boton--fantasma" data-cerrar>Cancelar</button>
|
||||
<button type="submit" class="boton" data-guardar>${esc(cfg.textoGuardar ?? 'Guardar cambios')}</button>
|
||||
</div>
|
||||
</form>`;
|
||||
|
||||
document.body.appendChild(dlg);
|
||||
|
||||
// Valores iniciales
|
||||
for (const c of lista) {
|
||||
const el = dlg.querySelector(`[name="${c.nombre}"]`);
|
||||
if (!el) continue;
|
||||
const v = cfg.valores?.[c.nombre];
|
||||
if (c.tipo === 'casilla') el.checked = Boolean(Number(v ?? c.valorPorOmision ?? 0));
|
||||
else el.value = v ?? c.valorPorOmision ?? '';
|
||||
}
|
||||
|
||||
const cajaError = dlg.querySelector('.editor__error');
|
||||
const avisoError = cajaError.querySelector('.aviso');
|
||||
const btnGuardar = dlg.querySelector('[data-guardar]');
|
||||
|
||||
function mostrarError(mensaje, campo) {
|
||||
avisoError.textContent = mensaje;
|
||||
cajaError.hidden = false;
|
||||
dlg.querySelectorAll('[aria-invalid]').forEach((el) => el.removeAttribute('aria-invalid'));
|
||||
if (campo) {
|
||||
const el = dlg.querySelector(`[name="${campo}"]`);
|
||||
if (el) { el.setAttribute('aria-invalid', 'true'); el.focus(); }
|
||||
}
|
||||
}
|
||||
|
||||
function cerrar(resultado) {
|
||||
dlg.close();
|
||||
dlg.remove();
|
||||
resolver(resultado);
|
||||
}
|
||||
|
||||
dlg.querySelectorAll('[data-cerrar]').forEach((b) => b.addEventListener('click', () => cerrar(false)));
|
||||
dlg.addEventListener('cancel', (ev) => { ev.preventDefault(); cerrar(false); });
|
||||
|
||||
dlg.querySelector('[data-borrar]')?.addEventListener('click', async () => {
|
||||
const texto = cfg.confirmarBorrado ?? '¿Seguro que quieres eliminarlo? Esta acción no se puede deshacer.';
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(texto)) return;
|
||||
const btn = dlg.querySelector('[data-borrar]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eliminando…';
|
||||
try {
|
||||
await cfg.borrar();
|
||||
cerrar(true);
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eliminar';
|
||||
mostrarError(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
dlg.querySelector('form').addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const datos = {};
|
||||
for (const c of lista) {
|
||||
const el = dlg.querySelector(`[name="${c.nombre}"]`);
|
||||
if (!el) continue;
|
||||
datos[c.nombre] = c.tipo === 'casilla' ? (el.checked ? 1 : 0) : el.value;
|
||||
}
|
||||
|
||||
// Validación mínima del lado del navegador; el servidor revalida todo.
|
||||
const faltante = lista.find((c) => c.requerido && !String(datos[c.nombre] ?? '').trim());
|
||||
if (faltante) return mostrarError(`Falta llenar: ${faltante.etiqueta}.`, faltante.nombre);
|
||||
|
||||
btnGuardar.disabled = true;
|
||||
const previo = btnGuardar.textContent;
|
||||
btnGuardar.textContent = 'Guardando…';
|
||||
try {
|
||||
await cfg.guardar(datos);
|
||||
cerrar(true);
|
||||
} catch (err) {
|
||||
btnGuardar.disabled = false;
|
||||
btnGuardar.textContent = previo;
|
||||
mostrarError(err.message, err.campo);
|
||||
}
|
||||
});
|
||||
|
||||
dlg.showModal();
|
||||
dlg.querySelector('.editor__cuerpo input, .editor__cuerpo select, .editor__cuerpo textarea')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function pintarCampo(c) {
|
||||
const id = `campo-${c.nombre}`;
|
||||
const ayuda = c.ayuda ? `<span class="campo__ayuda" id="${id}-ayuda">${esc(c.ayuda)}</span>` : '';
|
||||
const desc = c.ayuda ? ` aria-describedby="${id}-ayuda"` : '';
|
||||
const req = c.requerido ? ' aria-required="true"' : '';
|
||||
|
||||
if (c.tipo === 'casilla') {
|
||||
// La etiqueta envuelve la casilla y llega al alto mínimo de toque, así
|
||||
// que se puede marcar tocando el texto y no solo el cuadrito.
|
||||
return `<div class="campo">
|
||||
<label class="campo-casilla" for="${id}">
|
||||
<input type="checkbox" name="${esc(c.nombre)}" id="${id}"${desc}>
|
||||
<span>${esc(c.etiqueta)}</span>
|
||||
</label>${ayuda}
|
||||
</div>`;
|
||||
}
|
||||
if (c.tipo === 'area') {
|
||||
return `<div class="campo">
|
||||
<label for="${id}">${esc(c.etiqueta)}</label>
|
||||
${ayuda}
|
||||
<textarea name="${esc(c.nombre)}" id="${id}"${req}${desc} rows="${c.filas ?? 6}"></textarea>
|
||||
</div>`;
|
||||
}
|
||||
if (c.tipo === 'select') {
|
||||
return `<div class="campo">
|
||||
<label for="${id}">${esc(c.etiqueta)}</label>
|
||||
${ayuda}
|
||||
<select name="${esc(c.nombre)}" id="${id}"${req}${desc}>
|
||||
${(c.opciones ?? []).map((o) => `<option value="${esc(o.valor)}">${esc(o.texto)}</option>`).join('')}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
// Los campos de dinero son de texto con teclado numérico, no type=number:
|
||||
// así "$1,500" llega entero al servidor, que sabe limpiarlo. Con
|
||||
// type=number el navegador reinterpreta la coma como decimal y $1,500 se
|
||||
// guardaba como $1.50, o se descartaba el valor y quedaba en cero.
|
||||
const tipoHtml = { numero: 'text', url: 'url', fecha: 'date' }[c.tipo] ?? 'text';
|
||||
const extra = c.tipo === 'numero' ? ' inputmode="decimal" autocomplete="off"' : '';
|
||||
return `<div class="campo">
|
||||
<label for="${id}">${esc(c.etiqueta)}</label>
|
||||
${ayuda}
|
||||
<input type="${tipoHtml}" name="${esc(c.nombre)}" id="${id}"
|
||||
${c.marcador ? `placeholder="${esc(c.marcador)}"` : ''}${req}${desc}${extra}
|
||||
${c.tipo === 'url' ? 'inputmode="url" autocapitalize="off" spellcheck="false"' : ''}>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- iconos ----------------------------------------------------------
|
||||
|
||||
const TRAZOS = {
|
||||
inicio: '<path d="M3 10.5 10 4l7 6.5"/><path d="M5 9.5V16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"/>',
|
||||
birrete: '<path d="M10 4 2.5 7.5 10 11l7.5-3.5L10 4Z"/><path d="M5.5 9v4c0 1.4 2 2.5 4.5 2.5s4.5-1.1 4.5-2.5V9"/>',
|
||||
documento: '<path d="M11.5 2.5H6a1.5 1.5 0 0 0-1.5 1.5v12A1.5 1.5 0 0 0 6 17.5h8a1.5 1.5 0 0 0 1.5-1.5V6.5l-4-4Z"/><path d="M11.5 2.5v4h4M7.5 11h5M7.5 14h3"/>',
|
||||
etiqueta: '<path d="M10.5 3H16a1 1 0 0 1 1 1v5.5a1 1 0 0 1-.3.7l-6.5 6.5a1 1 0 0 1-1.4 0l-5.5-5.5a1 1 0 0 1 0-1.4l6.5-6.5a1 1 0 0 1 .7-.3Z"/><circle cx="13" cy="7" r="1.1"/>',
|
||||
video: '<rect x="2.5" y="5" width="10.5" height="10" rx="2"/><path d="m13 9 4.5-2.5v7L13 11z"/>',
|
||||
libro: '<path d="M3.5 4.5c2.5-1 4.5-1 6.5.5 2-1.5 4-1.5 6.5-.5v10c-2.5-1-4.5-1-6.5.5-2-1.5-4-1.5-6.5-.5v-10Z"/><path d="M10 5v10"/>',
|
||||
mensaje: '<path d="M17 10.5c0 3.3-3.1 6-7 6a8 8 0 0 1-2.3-.3L4 17.5l1-2.7A5.7 5.7 0 0 1 3 10.5c0-3.3 3.1-6 7-6s7 2.7 7 6Z"/>',
|
||||
edificio: '<path d="M4.5 17.5V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v13.5M12.5 8h2.5a1 1 0 0 1 1 1v8.5M3 17.5h14"/><path d="M7 6.5h2M7 9.5h2M7 12.5h2"/>',
|
||||
personas: '<circle cx="7.5" cy="7" r="2.6"/><path d="M2.8 16.5c0-2.6 2.1-4.6 4.7-4.6s4.7 2 4.7 4.6"/><path d="M13.2 5.1a2.6 2.6 0 0 1 0 5M14 12.2c1.9.4 3.2 2.1 3.2 4.3"/>',
|
||||
reloj: '<circle cx="10" cy="10" r="7"/><path d="M10 6v4.3l3 1.8"/>',
|
||||
menu: '<path d="M3 6h14M3 10h14M3 14h14"/>',
|
||||
salir: '<path d="M8 17H5a1.5 1.5 0 0 1-1.5-1.5v-11A1.5 1.5 0 0 1 5 3h3"/><path d="M12.5 13.5 16 10l-3.5-3.5M7.5 10H16"/>',
|
||||
copiar: '<rect x="7" y="7" width="9.5" height="9.5" rx="1.5"/><path d="M13 4.5H5a1.5 1.5 0 0 0-1.5 1.5v8"/>',
|
||||
buscar: '<circle cx="9" cy="9" r="5.3"/><path d="m13 13 4 4"/>',
|
||||
lapiz: '<path d="M13.6 3.9a1.6 1.6 0 0 1 2.3 2.3L7 15l-3.5 1L4.5 12.5Z"/><path d="m12 5.5 2.5 2.5"/>',
|
||||
mas: '<path d="M10 4v12M4 10h12"/>',
|
||||
abrir: '<path d="M11 3h6v6"/><path d="M17 3 9.5 10.5"/><path d="M15 12v3.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 3 15.5v-9A1.5 1.5 0 0 1 4.5 5H8"/>',
|
||||
tarjetas: '<rect x="3" y="3.5" width="6" height="6" rx="1.4"/><rect x="11" y="3.5" width="6" height="6" rx="1.4"/><rect x="3" y="10.5" width="6" height="6" rx="1.4"/><rect x="11" y="10.5" width="6" height="6" rx="1.4"/>',
|
||||
tabla: '<rect x="3" y="4" width="14" height="12" rx="1.5"/><path d="M3 8h14M3 12h14M8 8v8"/>',
|
||||
alerta: '<path d="M10 3.5 17 16H3l7-12.5Z"/><path d="M10 8v3.6M10 14h.01"/>',
|
||||
check: '<circle cx="10" cy="10" r="7"/><path d="m6.8 10.2 2.2 2.2 4.2-4.6"/>',
|
||||
};
|
||||
|
||||
export function icono(nombre) {
|
||||
const trazo = TRAZOS[nombre] ?? TRAZOS.check;
|
||||
return `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${trazo}</svg>`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="es-MX">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Portal interno · Instituto Santiago Tapia</title>
|
||||
<meta name="color-scheme" content="light">
|
||||
<link rel="icon" href="/assets/insta-logo.webp">
|
||||
<link rel="stylesheet" href="/css/insta.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="portal">
|
||||
<!-- Barra lateral: en móvil se vuelve cajón deslizable -->
|
||||
<aside class="barra" id="barra" aria-label="Secciones del portal">
|
||||
<div class="barra__cabeza">
|
||||
<a class="rotulo" href="/portal">
|
||||
<img src="/assets/insta-logo.webp" width="44" height="28" alt="">
|
||||
<span class="rotulo__texto">
|
||||
<span class="rotulo__nombre">INSTA</span>
|
||||
<span class="rotulo__sub">Portal interno</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="barra__nav" id="nav"></nav>
|
||||
<div class="barra__pie" id="pie-barra"></div>
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
<!-- Tope móvil -->
|
||||
<header class="tope">
|
||||
<button class="tope__menu" id="abrir-menu" type="button" aria-label="Abrir menú" aria-expanded="false" aria-controls="barra"></button>
|
||||
<a class="rotulo" href="/portal">
|
||||
<img src="/assets/insta-logo.webp" width="38" height="24" alt="">
|
||||
<span class="rotulo__texto"><span class="rotulo__nombre">INSTA</span></span>
|
||||
</a>
|
||||
<span id="ficha-movil"></span>
|
||||
</header>
|
||||
|
||||
<main class="lienzo">
|
||||
<div class="lienzo__interno" id="vista" aria-live="polite">
|
||||
<div class="cargando">
|
||||
<div class="hueso" style="height:120px"></div>
|
||||
<div class="hueso"></div>
|
||||
<div class="hueso"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brindis" id="brindis" role="status" aria-live="polite"><span class="brindis__marca"></span><span id="brindis-txt"></span></div>
|
||||
|
||||
<script type="module" src="/js/app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
# =====================================================================
|
||||
# Reinicia el portal en limpio: mata lo que ocupe el puerto, siembra la
|
||||
# base y arranca el servidor. Sembrar mientras un servidor viejo sigue
|
||||
# vivo deja errores raros ("Table doesn't exist"), así que primero se
|
||||
# libera el puerto de verdad, por PID y no por nombre de proceso.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\reiniciar.ps1
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\reiniciar.ps1 -SinSembrar
|
||||
# =====================================================================
|
||||
param(
|
||||
[int]$Puerto = 4180,
|
||||
[switch]$SinSembrar
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$raiz = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $raiz
|
||||
|
||||
Write-Host "Liberando el puerto $Puerto..."
|
||||
$ocupantes = @()
|
||||
try {
|
||||
$ocupantes = Get-NetTCPConnection -LocalPort $Puerto -State Listen -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty OwningProcess -Unique
|
||||
} catch {}
|
||||
# $PID es de solo lectura en PowerShell, así que la variable va con otro nombre.
|
||||
foreach ($idProceso in $ocupantes) {
|
||||
try {
|
||||
$p = Get-Process -Id $idProceso -ErrorAction Stop
|
||||
Write-Host " cerrando $($p.ProcessName) (PID $idProceso)"
|
||||
Stop-Process -Id $idProceso -Force
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# Espera a que el puerto quede realmente libre antes de seguir.
|
||||
for ($i = 0; $i -lt 20; $i++) {
|
||||
$sigue = Get-NetTCPConnection -LocalPort $Puerto -State Listen -ErrorAction SilentlyContinue
|
||||
if (-not $sigue) { break }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$sigue = Get-NetTCPConnection -LocalPort $Puerto -State Listen -ErrorAction SilentlyContinue
|
||||
if ($sigue) { throw "El puerto $Puerto sigue ocupado. Ciérralo a mano antes de seguir." }
|
||||
|
||||
if (-not $SinSembrar) {
|
||||
Write-Host "Sembrando la base..."
|
||||
& node db/seed.mjs
|
||||
if ($LASTEXITCODE -ne 0) { throw "La siembra falló." }
|
||||
}
|
||||
|
||||
Write-Host "Arrancando el servidor..."
|
||||
Start-Process -FilePath 'node' -ArgumentList 'server/index.mjs' `
|
||||
-RedirectStandardOutput 'server.log' -RedirectStandardError 'server.err.log' `
|
||||
-WindowStyle Hidden | Out-Null
|
||||
|
||||
for ($i = 0; $i -lt 40; $i++) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri "http://localhost:$Puerto/api/auth/demo" -UseBasicParsing -TimeoutSec 2
|
||||
if ($r.StatusCode -eq 200) {
|
||||
Write-Host "`nListo: http://localhost:$Puerto`n" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Write-Host "`nEl servidor no respondió. Revisa server.err.log:`n" -ForegroundColor Red
|
||||
Get-Content server.err.log -ErrorAction SilentlyContinue | Select-Object -First 20
|
||||
exit 1
|
||||
@@ -0,0 +1,52 @@
|
||||
// =====================================================================
|
||||
// Detecta mojibake (UTF-8 leído como Latin-1) en los archivos del proyecto.
|
||||
// node scripts/revisar-codificacion.mjs
|
||||
// Se corre después de cualquier edición masiva: reemplazar con expresiones
|
||||
// regulares desde PowerShell rompe los acentos y el daño es difícil de ver.
|
||||
// =====================================================================
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const RAIZ = path.resolve(import.meta.dirname, '..');
|
||||
const EXTENSIONES = new Set(['.mjs', '.js', '.css', '.html', '.sql', '.md', '.json', '.yml', '.ps1']);
|
||||
const IGNORAR = new Set(['node_modules', 'tests', '.git', 'capturas', 'test-results', 'reporte']);
|
||||
|
||||
// Secuencias típicas del doble encodeo: Ã seguida de otra letra acentuada, etc.
|
||||
const SOSPECHAS = [/Ã[-¿]/, /Â[ -¿]/, /â€/, /Â/];
|
||||
|
||||
async function archivos(dir) {
|
||||
const salida = [];
|
||||
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||
if (IGNORAR.has(e.name)) continue;
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) salida.push(...await archivos(p));
|
||||
else if (EXTENSIONES.has(path.extname(e.name))) salida.push(p);
|
||||
}
|
||||
return salida;
|
||||
}
|
||||
|
||||
const sospechosos = [];
|
||||
for (const p of await archivos(RAIZ)) {
|
||||
if (p === import.meta.filename) continue; // este archivo contiene los patrones a propósito
|
||||
const texto = await readFile(p, 'utf8');
|
||||
const lineas = texto.split('\n');
|
||||
const malas = [];
|
||||
lineas.forEach((l, i) => {
|
||||
if (SOSPECHAS.some((re) => re.test(l))) malas.push({ n: i + 1, l: l.trim().slice(0, 90) });
|
||||
});
|
||||
if (malas.length) sospechosos.push({ archivo: path.relative(RAIZ, p), malas });
|
||||
}
|
||||
|
||||
if (!sospechosos.length) {
|
||||
console.log('\n ✔ Sin mojibake: los acentos están bien en todos los archivos.\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('\n ✖ Se encontró texto mal codificado:\n');
|
||||
for (const s of sospechosos) {
|
||||
console.log(` ${s.archivo}`);
|
||||
for (const m of s.malas.slice(0, 6)) console.log(` línea ${m.n}: ${m.l}`);
|
||||
if (s.malas.length > 6) console.log(` …y ${s.malas.length - 6} líneas más`);
|
||||
}
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// =====================================================================
|
||||
// Magic Login y sesiones
|
||||
// ---------------------------------------------------------------------
|
||||
// El token del enlace y el id de sesión viajan en claro al cliente, pero
|
||||
// en la base solo se guarda su SHA-256. Así un dump de la base no permite
|
||||
// suplantar a nadie. El token es de un solo uso y expira a los 15 minutos.
|
||||
// =====================================================================
|
||||
import crypto from 'node:crypto';
|
||||
import { q1, exec } from './db.mjs';
|
||||
import { config } from './config.mjs';
|
||||
import { ip } from './bitacora.mjs';
|
||||
|
||||
const sha = (v) => crypto.createHash('sha256').update(v).digest('hex');
|
||||
|
||||
/** Fecha MySQL (UTC) a N minutos de ahora. */
|
||||
function enMinutos(min) {
|
||||
return new Date(Date.now() + min * 60_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
export async function crearEnlaceMagico(usuarioId, origenIp = '') {
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
await exec(
|
||||
'INSERT INTO magic_tokens (usuario_id, token_hash, expira_en, ip_origen) VALUES (?,?,?,?)',
|
||||
[usuarioId, sha(token), enMinutos(config.magicLinkMinutos), origenIp],
|
||||
);
|
||||
return { token, expiraEnMinutos: config.magicLinkMinutos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Canjea un token por una sesión. Devuelve { ok, motivo?, sesionId?, usuarioId? }.
|
||||
*
|
||||
* El token se reclama con UN SOLO UPDATE condicional. Si se leyera primero y
|
||||
* se marcara después, dos canjes simultáneos del mismo enlace pasarían los dos
|
||||
* la validación y crearían dos sesiones: el enlace dejaría de ser de un solo
|
||||
* uso justo cuando más importa, que es cuando alguien lo comparte.
|
||||
*/
|
||||
export async function canjearEnlaceMagico(token, req) {
|
||||
const hash = sha(token);
|
||||
|
||||
const reclamo = await exec(
|
||||
`UPDATE magic_tokens SET usado_en = UTC_TIMESTAMP()
|
||||
WHERE token_hash = ? AND usado_en IS NULL AND expira_en > UTC_TIMESTAMP()`,
|
||||
[hash],
|
||||
);
|
||||
|
||||
if (reclamo.affectedRows !== 1) {
|
||||
// No se pudo reclamar: se lee solo para poder decir por qué.
|
||||
const fila = await q1(
|
||||
`SELECT usado_en, expira_en < UTC_TIMESTAMP() AS vencido
|
||||
FROM magic_tokens WHERE token_hash = ?`,
|
||||
[hash],
|
||||
);
|
||||
if (!fila) return { ok: false, motivo: 'invalido' };
|
||||
if (fila.usado_en) return { ok: false, motivo: 'ya-usado' };
|
||||
return { ok: false, motivo: 'vencido' };
|
||||
}
|
||||
|
||||
const fila = await q1(
|
||||
`SELECT t.usuario_id, u.activo FROM magic_tokens t
|
||||
JOIN usuarios u ON u.id = t.usuario_id WHERE t.token_hash = ?`,
|
||||
[hash],
|
||||
);
|
||||
if (!fila) return { ok: false, motivo: 'invalido' };
|
||||
if (!fila.activo) return { ok: false, motivo: 'usuario-inactivo' };
|
||||
|
||||
const sesionId = crypto.randomBytes(32).toString('base64url');
|
||||
await exec(
|
||||
'INSERT INTO sesiones (id, usuario_id, expira_en, user_agent, ip_origen) VALUES (?,?,?,?,?)',
|
||||
[sha(sesionId), fila.usuario_id, enMinutos(config.sesionMinutos),
|
||||
String(req.headers['user-agent'] ?? '').slice(0, 255), ip(req)],
|
||||
);
|
||||
await exec('UPDATE usuarios SET ultimo_acceso = UTC_TIMESTAMP() WHERE id = ?', [fila.usuario_id]);
|
||||
|
||||
return { ok: true, sesionId, usuarioId: fila.usuario_id };
|
||||
}
|
||||
|
||||
export async function cerrarSesion(sesionId) {
|
||||
if (!sesionId) return;
|
||||
await exec('DELETE FROM sesiones WHERE id = ?', [sha(sesionId)]);
|
||||
}
|
||||
|
||||
export function leerCookie(req, nombre) {
|
||||
const cabecera = req.headers.cookie;
|
||||
if (!cabecera) return null;
|
||||
for (const parte of cabecera.split(';')) {
|
||||
const i = parte.indexOf('=');
|
||||
if (i < 0) continue;
|
||||
if (parte.slice(0, i).trim() !== nombre) continue;
|
||||
const cruda = parte.slice(i + 1).trim();
|
||||
try {
|
||||
return decodeURIComponent(cruda);
|
||||
} catch {
|
||||
// Una cookie con un % mal formado hace que decodeURIComponent lance.
|
||||
// Se trata como "sin sesión" en lugar de dejar caer la petición.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function atributosCookie() {
|
||||
const partes = ['Path=/', 'HttpOnly', `SameSite=${config.cookieSegura ? 'Strict' : 'Lax'}`];
|
||||
if (config.cookieSegura) partes.push('Secure');
|
||||
return partes.join('; ');
|
||||
}
|
||||
|
||||
export function ponerCookieSesion(res, sesionId) {
|
||||
res.setHeader('Set-Cookie', [
|
||||
`${config.cookieNombre}=${encodeURIComponent(sesionId)}; ${atributosCookie()}; Max-Age=${config.sesionMinutos * 60}`,
|
||||
]);
|
||||
}
|
||||
|
||||
export function borrarCookieSesion(res) {
|
||||
res.setHeader('Set-Cookie', [`${config.cookieNombre}=; ${atributosCookie()}; Max-Age=0`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: si hay cookie de sesión válida, deja el usuario y sus permisos
|
||||
* en req.usuario / req.permisos. No bloquea: eso lo hace exigirSesion().
|
||||
*/
|
||||
export async function cargarSesion(req, _res, next) {
|
||||
req.usuario = null;
|
||||
req.permisos = new Set();
|
||||
const cruda = leerCookie(req, config.cookieNombre);
|
||||
if (!cruda) return next();
|
||||
|
||||
try {
|
||||
const u = await q1(
|
||||
`SELECT u.id, u.nombre, u.email, u.puesto, u.iniciales, u.extension,
|
||||
r.clave AS rol_clave, r.nombre AS rol_nombre, r.nivel AS rol_nivel, r.color AS rol_color,
|
||||
s.expira_en
|
||||
FROM sesiones s
|
||||
JOIN usuarios u ON u.id = s.usuario_id
|
||||
JOIN roles r ON r.id = u.rol_id
|
||||
WHERE s.id = ? AND s.expira_en > UTC_TIMESTAMP() AND u.activo = 1`,
|
||||
[sha(cruda)],
|
||||
);
|
||||
if (!u) return next();
|
||||
|
||||
const filas = await q1(
|
||||
`SELECT GROUP_CONCAT(p.clave) AS claves
|
||||
FROM roles_permisos rp
|
||||
JOIN permisos p ON p.id = rp.permiso_id
|
||||
JOIN usuarios u ON u.rol_id = rp.rol_id
|
||||
WHERE u.id = ?`,
|
||||
[u.id],
|
||||
);
|
||||
req.usuario = u;
|
||||
req.sesionCruda = cruda;
|
||||
req.permisos = new Set((filas?.claves ?? '').split(',').filter(Boolean));
|
||||
} catch (err) {
|
||||
console.error('[auth] error al cargar la sesión:', err.message);
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { exec } from './db.mjs';
|
||||
import { config } from './config.mjs';
|
||||
|
||||
/**
|
||||
* Registra una acción en la bitácora. Nunca lanza: si el registro falla,
|
||||
* la petición del usuario no se cae por eso.
|
||||
*/
|
||||
export async function registrar(req, { accion, entidad = '', entidad_id = '', resultado = 'ok', detalle = '' }) {
|
||||
const u = req.usuario ?? null;
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO bitacora (usuario_id, actor, rol, accion, entidad, entidad_id, resultado, detalle, ip_origen)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
u?.id ?? null,
|
||||
u?.nombre ?? 'anónimo',
|
||||
u?.rol_clave ?? '',
|
||||
accion,
|
||||
String(entidad).slice(0, 48),
|
||||
String(entidad_id).slice(0, 48),
|
||||
resultado,
|
||||
String(detalle).slice(0, 500),
|
||||
ip(req),
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[bitacora] no se pudo registrar:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Origen de la petición. X-Forwarded-For lo pone quien llama, así que solo se
|
||||
* cree cuando se declara explícitamente que hay un proxy de confianza delante
|
||||
* (CONFIAR_PROXY=true). Si no, la bitácora registraría la IP que el cliente
|
||||
* quiera y dejaría de servir para auditar.
|
||||
*/
|
||||
export function ip(req) {
|
||||
let cruda = req.socket?.remoteAddress || '';
|
||||
if (config.confiarProxy) {
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
const primera = (Array.isArray(xff) ? xff[0] : xff)?.split(',')[0]?.trim();
|
||||
if (primera) cruda = primera;
|
||||
}
|
||||
return String(cruda).replace('::ffff:', '').slice(0, 64);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// =====================================================================
|
||||
// Validación de campos editables
|
||||
// ---------------------------------------------------------------------
|
||||
// Cada entidad editable declara un mapa columna → validador. Solo las
|
||||
// columnas declaradas llegan al UPDATE, y siempre por consulta preparada.
|
||||
// Un campo ausente en el cuerpo no se toca; uno presente e inválido
|
||||
// aborta la petición con un mensaje que se le puede mostrar a la persona.
|
||||
// =====================================================================
|
||||
|
||||
class ErrorCampo extends Error {
|
||||
constructor(campo, mensaje) {
|
||||
super(mensaje);
|
||||
this.campo = campo;
|
||||
this.esErrorCampo = true;
|
||||
}
|
||||
}
|
||||
|
||||
const vacioONulo = (v) => v === null || v === undefined || v === '';
|
||||
|
||||
/**
|
||||
* Solo se aceptan primitivos. Un objeto o un arreglo en el JSON acabaría
|
||||
* guardado como "[object Object]" o como sus elementos unidos por comas.
|
||||
*/
|
||||
function comoTexto(v, campo, nombre) {
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
throw new ErrorCampo(campo, `${nombre} tiene que ser texto.`);
|
||||
}
|
||||
|
||||
export const texto = (max, { requerido = false, etiqueta } = {}) => (v, campo) => {
|
||||
const nombre = etiqueta ?? campo;
|
||||
if (vacioONulo(v)) {
|
||||
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
|
||||
return '';
|
||||
}
|
||||
const s = comoTexto(v, campo, nombre).trim();
|
||||
if (requerido && !s) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
|
||||
if (s.length > max) throw new ErrorCampo(campo, `${nombre} no puede pasar de ${max} caracteres. Lleva ${s.length}.`);
|
||||
return s;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cantidad en pesos. Un campo vacío NO se toma como cero: eso publicaría un
|
||||
* programa gratis sin que nadie lo note. Se acepta "$1,500" tal como se
|
||||
* escribe un precio, porque el formulario lo manda como texto.
|
||||
*/
|
||||
export const dinero = ({ min = 0, max = 999999, etiqueta } = {}) => (v, campo) => {
|
||||
const nombre = etiqueta ?? campo;
|
||||
if (vacioONulo(v)) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío. Si el concepto no se cobra, escribe 0.`);
|
||||
const limpio = comoTexto(v, campo, nombre).replace(/[$\s,]/g, '');
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(limpio)) {
|
||||
throw new ErrorCampo(campo, `${nombre} tiene que ser una cantidad en pesos, por ejemplo 1500 o $1,500.`);
|
||||
}
|
||||
const n = Number(limpio);
|
||||
if (n < min || n > max) {
|
||||
throw new ErrorCampo(campo, `${nombre} tiene que estar entre ${min} y ${max.toLocaleString('es-MX')} pesos.`);
|
||||
}
|
||||
return Math.round(n * 100) / 100;
|
||||
};
|
||||
|
||||
export const entero = ({ min = 0, max = 32767, nulable = false, etiqueta } = {}) => (v, campo) => {
|
||||
const nombre = etiqueta ?? campo;
|
||||
if (vacioONulo(v)) {
|
||||
if (nulable) return null;
|
||||
throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
|
||||
}
|
||||
const limpio = comoTexto(v, campo, nombre).trim();
|
||||
if (!/^-?\d+$/.test(limpio)) throw new ErrorCampo(campo, `${nombre} tiene que ser un número entero.`);
|
||||
const n = Number(limpio);
|
||||
if (n < min || n > max) throw new ErrorCampo(campo, `${nombre} tiene que estar entre ${min} y ${max}.`);
|
||||
return n;
|
||||
};
|
||||
|
||||
export const booleano = () => (v) => (v === true || v === 1 || v === '1' || v === 'true' ? 1 : 0);
|
||||
|
||||
export const opcion = (valores, { etiqueta } = {}) => (v, campo) => {
|
||||
const nombre = etiqueta ?? campo;
|
||||
const s = comoTexto(v, campo, nombre);
|
||||
if (!valores.includes(s)) {
|
||||
throw new ErrorCampo(campo, `${nombre} solo puede ser: ${valores.join(', ')}.`);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
export const fecha = ({ etiqueta } = {}) => (v, campo) => {
|
||||
const nombre = etiqueta ?? campo;
|
||||
if (vacioONulo(v)) return null;
|
||||
const s = comoTexto(v, campo, nombre).slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw new ErrorCampo(campo, `${nombre} debe tener el formato AAAA-MM-DD.`);
|
||||
const d = new Date(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s) {
|
||||
throw new ErrorCampo(campo, `${nombre} no es una fecha que exista.`);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Hora en formato de 24 horas, "20:00". Se normaliza a dos dígitos. */
|
||||
export const hora = ({ requerido = false, etiqueta } = {}) => (v, campo) => {
|
||||
const nombre = etiqueta ?? campo;
|
||||
if (vacioONulo(v)) {
|
||||
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacía. Escríbela así: 20:00.`);
|
||||
return '';
|
||||
}
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(comoTexto(v, campo, nombre).trim());
|
||||
if (!m) throw new ErrorCampo(campo, `${nombre} debe verse así: 20:00.`);
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (h > 23 || min > 59) throw new ErrorCampo(campo, `${nombre} no es una hora válida.`);
|
||||
return `${String(h).padStart(2, '0')}:${m[2]}`;
|
||||
};
|
||||
|
||||
/** Enlace de reunión. Solo http/https; se rechaza cualquier otro esquema. */
|
||||
export const enlace = ({ requerido = false, etiqueta } = {}) => (v, campo) => {
|
||||
const nombre = etiqueta ?? campo;
|
||||
if (vacioONulo(v)) {
|
||||
if (requerido) throw new ErrorCampo(campo, `${nombre} no puede quedar vacío.`);
|
||||
return '';
|
||||
}
|
||||
const s = comoTexto(v, campo, nombre).trim();
|
||||
let u;
|
||||
try {
|
||||
u = new URL(s);
|
||||
} catch {
|
||||
throw new ErrorCampo(campo, `${nombre} debe ser una dirección completa, empezando con https://`);
|
||||
}
|
||||
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
||||
throw new ErrorCampo(campo, `${nombre} debe empezar con https:// o http://`);
|
||||
}
|
||||
// El largo se comprueba sobre el valor normalizado: new URL() puede añadir
|
||||
// una barra final, y un enlace de justo 500 caracteres se pasaría de la
|
||||
// columna y reventaría al guardarse.
|
||||
const normalizado = u.toString();
|
||||
if (normalizado.length > 500) throw new ErrorCampo(campo, `${nombre} es demasiado largo: no puede pasar de 500 caracteres.`);
|
||||
return normalizado;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aplica el mapa de validadores al cuerpo de la petición.
|
||||
* @returns {{columnas: string[], valores: any[], resumen: string}}
|
||||
*/
|
||||
export function validar(cuerpo, mapa, { exigirTodos = false } = {}) {
|
||||
const columnas = [];
|
||||
const valores = [];
|
||||
for (const [columna, validador] of Object.entries(mapa)) {
|
||||
const presente = Object.prototype.hasOwnProperty.call(cuerpo ?? {}, columna);
|
||||
if (!presente) {
|
||||
if (!exigirTodos) continue;
|
||||
// En una creación, un campo ausente se valida como vacío para que
|
||||
// el validador decida si era obligatorio.
|
||||
columnas.push(columna);
|
||||
valores.push(validador(undefined, columna));
|
||||
continue;
|
||||
}
|
||||
columnas.push(columna);
|
||||
valores.push(validador(cuerpo[columna], columna));
|
||||
}
|
||||
return { columnas, valores, resumen: columnas.join(', ') };
|
||||
}
|
||||
|
||||
/** Convierte un error de validación o de llave foránea en una respuesta 400. */
|
||||
export function responderError(res, err) {
|
||||
if (err?.esErrorCampo) {
|
||||
return res.status(400).json({ error: 'campo-invalido', campo: err.campo, mensaje: err.message });
|
||||
}
|
||||
if (err?.code === 'ER_NO_REFERENCED_ROW_2' || err?.code === 'ER_NO_REFERENCED_ROW') {
|
||||
return res.status(400).json({ error: 'referencia-invalida', mensaje: 'Elegiste una opción que ya no existe. Recarga la página e inténtalo de nuevo.' });
|
||||
}
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ error: 'duplicado', mensaje: 'Ya existe un registro con ese dato.' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { ErrorCampo };
|
||||
@@ -0,0 +1,47 @@
|
||||
// Configuración central. Todo tiene valor por omisión para que la demo
|
||||
// arranque sin archivo .env.
|
||||
const bandera = (valor, porOmision) => (valor === undefined ? porOmision : valor === 'true');
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 4180),
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT ?? 3307),
|
||||
user: process.env.DB_USER ?? 'root',
|
||||
password: process.env.DB_PASSWORD ?? 'insta_root_demo',
|
||||
database: process.env.DB_NAME ?? 'insta_portal',
|
||||
},
|
||||
|
||||
// Vigencia del enlace de acceso y de la sesión, en minutos.
|
||||
magicLinkMinutos: Number(process.env.MAGIC_LINK_MINUTOS ?? 15),
|
||||
sesionMinutos: Number(process.env.SESION_MINUTOS ?? 480),
|
||||
cookieNombre: 'insta_sesion',
|
||||
|
||||
/**
|
||||
* En la demo el enlace de acceso se muestra en pantalla en lugar de
|
||||
* enviarse por correo, para que cualquiera pueda entrar y probarla.
|
||||
* OJO: con esto activo, quien conozca un correo dado de alta obtiene una
|
||||
* sesión sin más. Es una decisión consciente para una demostración con
|
||||
* datos ficticios. Para uso real hay que ponerlo en false y enviar el
|
||||
* enlace por correo.
|
||||
*/
|
||||
mostrarEnlaceEnPantalla: bandera(process.env.MOSTRAR_ENLACE, true),
|
||||
|
||||
// Cookie Secure y SameSite=Strict cuando el portal va detrás de HTTPS.
|
||||
cookieSegura: bandera(process.env.COOKIE_SEGURA, false),
|
||||
|
||||
// Solo con un proxy de confianza delante se cree el X-Forwarded-For.
|
||||
confiarProxy: bandera(process.env.CONFIAR_PROXY, false),
|
||||
|
||||
/**
|
||||
* Límite de enlaces de acceso por origen y por ventana, para que nadie los
|
||||
* pida en ráfaga. El loopback queda exento: ahí viven el desarrollo y las
|
||||
* pruebas.
|
||||
*
|
||||
* OJO detrás de un proxy: sin CONFIAR_PROXY=true todas las peticiones se
|
||||
* ven llegar desde la IP del proxy y comparten el mismo cupo, así que un
|
||||
* puñado de personas probando la demo lo agotaría entre todas.
|
||||
*/
|
||||
enlacesPorVentana: Number(process.env.ENLACES_POR_VENTANA ?? 60),
|
||||
ventanaMinutos: Number(process.env.VENTANA_MINUTOS ?? 5),
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import { config } from './config.mjs';
|
||||
|
||||
let pool;
|
||||
|
||||
export function getPool() {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
...config.db,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
charset: 'utf8mb4',
|
||||
timezone: 'Z',
|
||||
dateStrings: ['DATE'],
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
/** Consulta que devuelve filas. */
|
||||
export async function q(sql, params = []) {
|
||||
const [rows] = await getPool().execute(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Consulta que devuelve la primera fila o null. */
|
||||
export async function q1(sql, params = []) {
|
||||
const rows = await q(sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** INSERT/UPDATE/DELETE; devuelve el ResultSetHeader. */
|
||||
export async function exec(sql, params = []) {
|
||||
const [res] = await getPool().execute(sql, params);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function cerrar() {
|
||||
if (pool) await pool.end();
|
||||
pool = undefined;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// =====================================================================
|
||||
// Portal Interno INSTA · servidor
|
||||
// =====================================================================
|
||||
import express from 'express';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config } from './config.mjs';
|
||||
import { getPool, q1 } from './db.mjs';
|
||||
import { cargarSesion, canjearEnlaceMagico, ponerCookieSesion } from './auth.mjs';
|
||||
import { registrar } from './bitacora.mjs';
|
||||
import { rutas } from './rutas.mjs';
|
||||
|
||||
const raiz = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const publico = path.join(raiz, 'public');
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
// El cuerpo mal formado o demasiado grande se contesta con su código y con un
|
||||
// mensaje que la persona pueda entender, no con un 500 genérico.
|
||||
app.use((err, _req, res, next) => {
|
||||
if (!err) return next();
|
||||
if (err.type === 'entity.too.large') {
|
||||
return res.status(413).json({
|
||||
error: 'texto-demasiado-largo',
|
||||
mensaje: 'El texto que pegaste es demasiado largo. Recórtalo e inténtalo de nuevo.',
|
||||
});
|
||||
}
|
||||
if (err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ error: 'peticion-invalida', mensaje: 'No se entendió la petición. Recarga la página e inténtalo otra vez.' });
|
||||
}
|
||||
return next(err);
|
||||
});
|
||||
|
||||
app.use(cargarSesion);
|
||||
|
||||
// Cabeceras defensivas. La CSP no permite ningún recurso externo: todo el
|
||||
// portal se sirve desde este origen. Los estilos en línea sí se permiten
|
||||
// porque las plantillas usan el atributo style para los colores del catálogo.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"form-action 'self'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
].join('; '));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'same-origin');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
next();
|
||||
});
|
||||
|
||||
// ---- Magic Login por navegación directa ------------------------------
|
||||
// Es el enlace que en producción llegaría por correo.
|
||||
app.get('/acceso/:token', async (req, res) => {
|
||||
const r = await canjearEnlaceMagico(req.params.token, req);
|
||||
if (!r.ok) {
|
||||
await registrar(req, { accion: 'auth.canje', resultado: 'denegado', detalle: r.motivo });
|
||||
return res.redirect(`/?error=${encodeURIComponent(r.motivo)}`);
|
||||
}
|
||||
ponerCookieSesion(res, r.sesionId);
|
||||
const u = await q1('SELECT nombre FROM usuarios WHERE id = ?', [r.usuarioId]);
|
||||
req.usuario = { id: r.usuarioId, nombre: u?.nombre ?? '', rol_clave: '' };
|
||||
await registrar(req, { accion: 'auth.entrada', entidad: 'usuario', entidad_id: String(r.usuarioId), detalle: 'sesión iniciada con enlace mágico' });
|
||||
res.redirect('/portal');
|
||||
});
|
||||
|
||||
// Salud del proceso. A propósito NO consulta la base: si MySQL tarda en
|
||||
// arrancar, el contenedor no debe reiniciarse en bucle por eso.
|
||||
app.get('/salud', (_req, res) => {
|
||||
res.json({ ok: true, servicio: 'portal-insta', tiempoActivo: Math.round(process.uptime()) });
|
||||
});
|
||||
|
||||
app.use('/api', rutas);
|
||||
|
||||
// ---- estáticos y shell ----------------------------------------------
|
||||
app.use(express.static(publico, { extensions: ['html'], maxAge: '5m' }));
|
||||
|
||||
// El portal es una sola página; si no hay sesión, el propio shell manda a la entrada.
|
||||
app.get('/portal', (_req, res) => res.sendFile(path.join(publico, 'portal.html')));
|
||||
app.get('/', (_req, res) => res.sendFile(path.join(publico, 'index.html')));
|
||||
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith('/api')) return res.status(404).json({ error: 'endpoint-no-encontrado' });
|
||||
res.status(404).sendFile(path.join(publico, 'index.html'));
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[error]', err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: 'error-interno', mensaje: 'Algo falló del lado del servidor. Vuelve a intentarlo.' });
|
||||
});
|
||||
|
||||
// Red de seguridad: un fallo suelto se registra, no tumba el portal.
|
||||
process.on('unhandledRejection', (motivo) => console.error('[rechazo no atrapado]', motivo));
|
||||
process.on('uncaughtException', (err) => console.error('[excepción no atrapada]', err));
|
||||
|
||||
// ---- arranque --------------------------------------------------------
|
||||
try {
|
||||
const fila = await q1('SELECT COUNT(*) AS n FROM programas');
|
||||
if (!fila || fila.n === 0) throw new Error('la base está vacía: corre `npm run db:reset`');
|
||||
app.listen(config.port, () => {
|
||||
console.log(`\n ▸ Portal INSTA en http://localhost:${config.port}`);
|
||||
console.log(` base: ${config.db.database}@${config.db.host}:${config.db.port} · ${fila.n} programas cargados`);
|
||||
console.log(' accesos demo: [email protected] · [email protected] · [email protected]\n');
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`\n✖ No se pudo arrancar: ${err.message}\n`);
|
||||
console.error(' Revisa que el contenedor esté arriba: docker compose up -d');
|
||||
await getPool().end().catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// =====================================================================
|
||||
// Control de acceso por rol
|
||||
// ---------------------------------------------------------------------
|
||||
// SECCIONES es la única fuente de verdad: de aquí sale el menú que ve
|
||||
// cada persona y el permiso que exige cada endpoint. No puede haber un
|
||||
// menú que muestre algo que la API niega, ni al revés.
|
||||
// =====================================================================
|
||||
import { registrar } from './bitacora.mjs';
|
||||
|
||||
export const GRUPOS = [
|
||||
{ id: 'interno', titulo: 'Uso interno' },
|
||||
];
|
||||
|
||||
export const SECCIONES = [
|
||||
{ id: 'inicio', titulo: 'Inicio', grupo: null, icono: 'inicio', permisos: ['inicio.ver'],
|
||||
resumen: 'Punto de partida' },
|
||||
|
||||
{ id: 'oferta', titulo: 'Oferta educativa', grupo: null, icono: 'birrete', permisos: ['oferta.ver'],
|
||||
resumen: 'Los 8 programas con costos, horarios y validez oficial' },
|
||||
{ id: 'requisitos', titulo: 'Requisitos y titulación', grupo: null, icono: 'documento', permisos: ['requisitos.ver'],
|
||||
resumen: 'Documentos por nivel y formas de titularse' },
|
||||
{ id: 'becas', titulo: 'Becas y fechas', grupo: null, icono: 'etiqueta', permisos: ['becas.ver'],
|
||||
resumen: 'Descuentos vigentes y fechas de inscripción' },
|
||||
{ id: 'clases', titulo: 'Links de clases', grupo: null, icono: 'video', permisos: ['clases.ver'],
|
||||
resumen: 'Los enlaces de Google Meet por programa y materia' },
|
||||
{ id: 'preguntas', titulo: 'Preguntas frecuentes', grupo: null, icono: 'libro', permisos: ['kb.ver'],
|
||||
resumen: 'La respuesta correcta a lo que más preguntan' },
|
||||
{ id: 'mensajes', titulo: 'Mensajes listos', grupo: null, icono: 'mensaje', permisos: ['guiones.ver'],
|
||||
resumen: 'Textos para copiar y enviar por WhatsApp' },
|
||||
{ id: 'contacto', titulo: 'Datos del instituto', grupo: null, icono: 'edificio', permisos: ['contacto.ver'],
|
||||
resumen: 'Dirección, teléfonos, horarios y misión' },
|
||||
|
||||
// Van bajo "Uso interno" y no bajo "Solo dirección": Equipo lo ve también
|
||||
// administración, y un encabezado que dijera lo contrario sería falso.
|
||||
{ id: 'equipo', titulo: 'Equipo', grupo: 'interno', icono: 'personas', permisos: ['equipo.ver'],
|
||||
resumen: 'Quién es quién y qué puede editar cada rol' },
|
||||
{ id: 'bitacora', titulo: 'Bitácora', grupo: 'interno', icono: 'reloj', permisos: ['bitacora.ver'],
|
||||
resumen: 'Quién entró y qué cambió' },
|
||||
];
|
||||
|
||||
/** ¿El usuario tiene al menos uno de estos permisos? */
|
||||
export function puede(req, ...claves) {
|
||||
return claves.some((c) => req.permisos?.has(c));
|
||||
}
|
||||
|
||||
/** Secciones visibles para este usuario. */
|
||||
export function navegacionDe(req) {
|
||||
return SECCIONES
|
||||
.filter((s) => puede(req, ...s.permisos))
|
||||
.map(({ id, titulo, grupo, icono, resumen }) => ({ id, titulo, grupo, icono, resumen }));
|
||||
}
|
||||
|
||||
// ---- middlewares -----------------------------------------------------
|
||||
|
||||
export function exigirSesion(req, res, next) {
|
||||
if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' });
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exige al menos uno de los permisos indicados. Un intento denegado queda
|
||||
* en la bitácora: es justo lo que dirección quiere poder auditar.
|
||||
*/
|
||||
export function exigir(...claves) {
|
||||
return async (req, res, next) => {
|
||||
if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' });
|
||||
if (puede(req, ...claves)) return next();
|
||||
await registrar(req, {
|
||||
accion: 'acceso.denegado', entidad: 'permiso', entidad_id: claves.join('|'),
|
||||
resultado: 'denegado', detalle: `${req.method} ${req.originalUrl}`,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: 'sin-permiso',
|
||||
mensaje: `Tu rol (${req.usuario.rol_nombre}) no puede hacer esto.`,
|
||||
requiere: claves,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
// =====================================================================
|
||||
// API del portal
|
||||
// ---------------------------------------------------------------------
|
||||
// Cada endpoint declara su permiso con exigir(...), así que ocultar algo
|
||||
// del menú y negarlo en la API es la misma regla. Toda edición pasa por
|
||||
// un mapa de campos validados y queda registrada en la bitácora.
|
||||
// =====================================================================
|
||||
import express from 'express';
|
||||
import { q, q1, exec } from './db.mjs';
|
||||
import { config } from './config.mjs';
|
||||
import {
|
||||
crearEnlaceMagico, canjearEnlaceMagico, cerrarSesion,
|
||||
ponerCookieSesion, borrarCookieSesion,
|
||||
} from './auth.mjs';
|
||||
import { exigir, exigirSesion, puede, navegacionDe, GRUPOS, SECCIONES } from './rbac.mjs';
|
||||
import { registrar, ip } from './bitacora.mjs';
|
||||
import {
|
||||
texto, dinero, entero, booleano, opcion, fecha, hora, enlace, validar, responderError,
|
||||
} from './campos.mjs';
|
||||
|
||||
export const rutas = express.Router();
|
||||
|
||||
// Express 4 no atrapa el rechazo de un handler async: sin esto, cualquier
|
||||
// error de base de datos se convierte en un unhandledRejection y tumba el
|
||||
// proceso. Envolvemos cada handler al registrarlo para que el error viaje
|
||||
// al manejador de errores y el servidor siga en pie.
|
||||
for (const metodo of ['get', 'post', 'patch', 'put', 'delete']) {
|
||||
const original = rutas[metodo].bind(rutas);
|
||||
rutas[metodo] = (ruta, ...manejadores) => original(
|
||||
ruta,
|
||||
...manejadores.map((m) => (m.length === 4
|
||||
? m
|
||||
: (req, res, next) => Promise.resolve(m(req, res, next)).catch(next))),
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Autenticación · Magic Login
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/auth/demo', async (_req, res) => {
|
||||
const filas = await q(
|
||||
`SELECT u.nombre, u.email, u.puesto, u.iniciales, r.clave AS rol_clave, r.nombre AS rol_nombre,
|
||||
r.nivel AS rol_nivel, r.color AS rol_color, r.descripcion AS rol_descripcion
|
||||
FROM usuarios u JOIN roles r ON r.id = u.rol_id
|
||||
WHERE u.activo = 1 AND u.es_demo = 1 ORDER BY r.nivel DESC`,
|
||||
);
|
||||
res.json({ usuarios: filas, vigenciaMinutos: config.magicLinkMinutos });
|
||||
});
|
||||
|
||||
// Freno sencillo por IP para que nadie pida enlaces en ráfaga. En memoria a
|
||||
// propósito: es una sola instancia y no hace falta más.
|
||||
const pedidosPorIp = new Map();
|
||||
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost', '']);
|
||||
|
||||
function demasiadosPedidos(origen) {
|
||||
if (LOOPBACK.has(origen)) return false; // desarrollo y pruebas
|
||||
const ahora = Date.now();
|
||||
const ventana = config.ventanaMinutos * 60_000;
|
||||
const previos = (pedidosPorIp.get(origen) ?? []).filter((t) => ahora - t < ventana);
|
||||
previos.push(ahora);
|
||||
pedidosPorIp.set(origen, previos);
|
||||
// Limpieza perezosa para que el mapa no crezca sin fin.
|
||||
if (pedidosPorIp.size > 500) {
|
||||
for (const [clave, marcas] of pedidosPorIp) {
|
||||
if (!marcas.some((t) => ahora - t < ventana)) pedidosPorIp.delete(clave);
|
||||
}
|
||||
}
|
||||
return previos.length > config.enlacesPorVentana;
|
||||
}
|
||||
|
||||
rutas.post('/auth/enlace', async (req, res) => {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
if (!email) return res.status(400).json({ error: 'email-requerido', mensaje: 'Escribe tu correo institucional.' });
|
||||
|
||||
if (demasiadosPedidos(ip(req))) {
|
||||
await registrar(req, { accion: 'auth.enlace', entidad: 'usuario', entidad_id: email, resultado: 'denegado', detalle: 'demasiados pedidos desde el mismo origen' });
|
||||
return res.status(429).json({
|
||||
error: 'demasiados-pedidos',
|
||||
mensaje: `Pediste muchos enlaces seguidos. Espera ${config.ventanaMinutos} minutos e inténtalo otra vez.`,
|
||||
});
|
||||
}
|
||||
|
||||
const u = await q1(
|
||||
`SELECT u.id, u.nombre, u.iniciales, r.nombre AS rol_nombre, r.clave AS rol_clave
|
||||
FROM usuarios u JOIN roles r ON r.id = u.rol_id
|
||||
WHERE u.email = ? AND u.activo = 1`,
|
||||
[email],
|
||||
);
|
||||
if (!u) {
|
||||
await registrar(req, { accion: 'auth.enlace', entidad: 'usuario', entidad_id: email, resultado: 'denegado', detalle: 'correo no registrado' });
|
||||
return res.status(404).json({
|
||||
error: 'no-registrado',
|
||||
mensaje: 'Ese correo no está dado de alta en el portal. Pídele acceso a dirección.',
|
||||
});
|
||||
}
|
||||
|
||||
const { token, expiraEnMinutos } = await crearEnlaceMagico(u.id, ip(req));
|
||||
await registrar(req, { accion: 'auth.enlace', entidad: 'usuario', entidad_id: String(u.id), detalle: `enlace generado para ${u.nombre}` });
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
usuario: { nombre: u.nombre, iniciales: u.iniciales, rol_nombre: u.rol_nombre, rol_clave: u.rol_clave },
|
||||
expiraEnMinutos,
|
||||
enlace: config.mostrarEnlaceEnPantalla ? `/acceso/${token}` : null,
|
||||
token: config.mostrarEnlaceEnPantalla ? token : null,
|
||||
});
|
||||
});
|
||||
|
||||
rutas.post('/auth/consumir', async (req, res) => {
|
||||
const token = String(req.body?.token ?? '').trim();
|
||||
const r = await canjearEnlaceMagico(token, req);
|
||||
if (!r.ok) {
|
||||
await registrar(req, { accion: 'auth.canje', resultado: 'denegado', detalle: r.motivo });
|
||||
return res.status(401).json({ error: r.motivo, mensaje: motivoLegible(r.motivo) });
|
||||
}
|
||||
ponerCookieSesion(res, r.sesionId);
|
||||
|
||||
// Sin esto, los accesos por la pantalla de entrada no quedarían en la
|
||||
// bitácora y el "quién entró" que dirección revisa saldría vacío.
|
||||
const u = await q1('SELECT u.nombre, r.clave AS rol FROM usuarios u JOIN roles r ON r.id = u.rol_id WHERE u.id = ?', [r.usuarioId]);
|
||||
req.usuario = { id: r.usuarioId, nombre: u?.nombre ?? '', rol_clave: u?.rol ?? '' };
|
||||
await registrar(req, { accion: 'auth.entrada', entidad: 'usuario', entidad_id: String(r.usuarioId), detalle: 'sesión iniciada con enlace de acceso' });
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
rutas.post('/auth/salir', async (req, res) => {
|
||||
if (req.usuario) await registrar(req, { accion: 'auth.salida', entidad: 'usuario', entidad_id: String(req.usuario.id) });
|
||||
await cerrarSesion(req.sesionCruda);
|
||||
borrarCookieSesion(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
rutas.get('/auth/yo', exigirSesion, async (req, res) => {
|
||||
res.json({
|
||||
usuario: req.usuario,
|
||||
permisos: [...req.permisos].sort(),
|
||||
navegacion: navegacionDe(req),
|
||||
grupos: GRUPOS,
|
||||
fechaCorte: '2026-07-29',
|
||||
});
|
||||
});
|
||||
|
||||
function motivoLegible(motivo) {
|
||||
return {
|
||||
invalido: 'Ese enlace no es válido.',
|
||||
'ya-usado': 'Ese enlace ya se usó. Los enlaces de acceso sirven una sola vez: genera uno nuevo.',
|
||||
vencido: `El enlace venció. Tienen ${config.magicLinkMinutos} minutos de vigencia.`,
|
||||
'usuario-inactivo': 'Tu cuenta está desactivada. Habla con dirección.',
|
||||
}[motivo] ?? 'No se pudo validar el enlace.';
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Consultas compartidas
|
||||
// =====================================================================
|
||||
|
||||
const SQL_PROGRAMAS = `
|
||||
SELECT p.*, n.clave AS nivel_clave, n.nombre AS nivel_nombre, n.orden AS nivel_orden,
|
||||
u.nombre AS actualizado_por_nombre
|
||||
FROM programas p
|
||||
JOIN niveles n ON n.id = p.nivel_id
|
||||
LEFT JOIN usuarios u ON u.id = p.actualizado_por`;
|
||||
|
||||
async function horariosDe(programaId) {
|
||||
return q(
|
||||
`SELECT pm.id, pm.modalidad_id, pm.dias, pm.hora_inicio, pm.hora_fin, pm.nota,
|
||||
m.clave AS modalidad_clave, m.nombre AS modalidad_nombre, m.color AS modalidad_color, m.detalle
|
||||
FROM programas_modalidades pm JOIN modalidades m ON m.id = pm.modalidad_id
|
||||
WHERE pm.programa_id = ? ORDER BY m.clave, pm.id`,
|
||||
[programaId],
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Inicio
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/inicio', exigir('inicio.ver'), async (req, res) => {
|
||||
const [{ n: programas }] = await q('SELECT COUNT(*) AS n FROM programas WHERE activo = 1');
|
||||
const [{ n: clases }] = await q('SELECT COUNT(*) AS n FROM clases WHERE activo = 1');
|
||||
const [{ n: preguntas }] = await q('SELECT COUNT(*) AS n FROM kb_articulos');
|
||||
const [{ n: mensajes }] = await q('SELECT COUNT(*) AS n FROM guiones');
|
||||
|
||||
const promo = await q1(
|
||||
"SELECT nombre, resumen, vigencia FROM promociones WHERE clave = 'julio-inscripcion-gratis' AND activo = 1",
|
||||
);
|
||||
const contacto = await q(
|
||||
"SELECT clave, etiqueta, valor FROM institucional WHERE grupo = 'contacto' ORDER BY orden",
|
||||
);
|
||||
const proximos = await q(
|
||||
`SELECT nombre_corto, fecha_inicio, color, nivel_id FROM programas
|
||||
WHERE activo = 1 ORDER BY nivel_id, orden`,
|
||||
);
|
||||
|
||||
const extra = {};
|
||||
if (puede(req, 'datos.ver')) {
|
||||
const [{ n }] = await q("SELECT COUNT(*) AS n FROM discrepancias WHERE estado <> 'aclarado'");
|
||||
extra.datosPorConfirmar = n;
|
||||
}
|
||||
if (puede(req, 'bitacora.ver')) {
|
||||
extra.cambiosRecientes = await q(
|
||||
`SELECT actor, accion, entidad, detalle, creado_en FROM bitacora
|
||||
WHERE accion LIKE '%.editar' OR accion LIKE '%.crear' OR accion LIKE '%.borrar'
|
||||
ORDER BY id DESC LIMIT 5`,
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ conteos: { programas, clases, preguntas, mensajes }, promo, contacto, proximos, ...extra });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Oferta educativa
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/oferta', exigir('oferta.ver'), async (req, res) => {
|
||||
const programas = await q(`${SQL_PROGRAMAS} WHERE p.activo = 1 ORDER BY n.orden, p.orden`);
|
||||
for (const p of programas) p.horarios = await horariosDe(p.id);
|
||||
|
||||
const carga = {
|
||||
niveles: await q('SELECT id, clave, nombre, orden FROM niveles ORDER BY orden'),
|
||||
modalidades: await q('SELECT id, clave, nombre, color, detalle FROM modalidades ORDER BY clave'),
|
||||
programas,
|
||||
};
|
||||
// El panel de datos por confirmar solo lo ve quien puede actuar sobre ellos.
|
||||
if (puede(req, 'datos.ver')) {
|
||||
carga.porConfirmar = await q(
|
||||
"SELECT * FROM discrepancias WHERE estado <> 'aclarado' ORDER BY FIELD(prioridad,'alta','media','baja'), orden",
|
||||
);
|
||||
}
|
||||
res.json(carga);
|
||||
});
|
||||
|
||||
const CAMPOS_PROGRAMA = {
|
||||
nombre: texto(120, { requerido: true, etiqueta: 'El nombre del programa' }),
|
||||
nombre_corto: texto(48, { requerido: true, etiqueta: 'El nombre corto' }),
|
||||
duracion: texto(48, { requerido: true, etiqueta: 'La duración' }),
|
||||
fecha_inicio: texto(32, { requerido: true, etiqueta: 'La fecha de inicio' }),
|
||||
inscripcion_lista: dinero({ etiqueta: 'La inscripción de lista' }),
|
||||
inscripcion_beca: dinero({ etiqueta: 'La inscripción con beca' }),
|
||||
mensualidad_lista: dinero({ etiqueta: 'La mensualidad de lista' }),
|
||||
mensualidad_beca: dinero({ etiqueta: 'La mensualidad con beca' }),
|
||||
ciclo_semanas: entero({ min: 1, max: 104, nulable: true, etiqueta: 'Las semanas del ciclo' }),
|
||||
rvoe_acuerdo: texto(48, { etiqueta: 'El número de acuerdo' }),
|
||||
rvoe_fecha: fecha({ etiqueta: 'La fecha del acuerdo' }),
|
||||
rvoe_autoridad: texto(120, { etiqueta: 'La autoridad que lo otorgó' }),
|
||||
rvoe_estado: opcion(['confirmado', 'revisar', 'por-confirmar'], { etiqueta: 'El estado del RVOE' }),
|
||||
rvoe_nota: texto(500, { etiqueta: 'La nota del RVOE' }),
|
||||
beneficios: texto(500, { etiqueta: 'Los beneficios' }),
|
||||
};
|
||||
|
||||
rutas.patch('/oferta/:id', exigir('oferta.editar'), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const actual = await q1(
|
||||
'SELECT id, clave, nombre, rvoe_acuerdo, rvoe_autoridad FROM programas WHERE id = ?',
|
||||
[id],
|
||||
);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese programa no existe.' });
|
||||
|
||||
try {
|
||||
const { columnas, valores, resumen } = validar(req.body, CAMPOS_PROGRAMA);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
|
||||
// Marcar la validez como confirmada sin número de acuerdo dejaría en la
|
||||
// ficha una frase a medias ("Acuerdo , otorgado el —"), y el equipo la
|
||||
// citaría creyendo que el dato existe.
|
||||
const iEstado = columnas.indexOf('rvoe_estado');
|
||||
if (iEstado >= 0 && valores[iEstado] === 'confirmado') {
|
||||
const iAcuerdo = columnas.indexOf('rvoe_acuerdo');
|
||||
const iAutoridad = columnas.indexOf('rvoe_autoridad');
|
||||
const acuerdo = iAcuerdo >= 0 ? valores[iAcuerdo] : actual.rvoe_acuerdo;
|
||||
const autoridad = iAutoridad >= 0 ? valores[iAutoridad] : actual.rvoe_autoridad;
|
||||
if (!acuerdo) {
|
||||
return res.status(400).json({
|
||||
error: 'campo-invalido', campo: 'rvoe_acuerdo',
|
||||
mensaje: 'Para marcar la validez como confirmada hace falta el número de acuerdo.',
|
||||
});
|
||||
}
|
||||
if (!autoridad) {
|
||||
return res.status(400).json({
|
||||
error: 'campo-invalido', campo: 'rvoe_autoridad',
|
||||
mensaje: 'Para marcar la validez como confirmada hace falta la autoridad que la otorgó.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await exec(
|
||||
`UPDATE programas SET ${columnas.map((c) => `${c} = ?`).join(', ')}, actualizado_por = ? WHERE id = ?`,
|
||||
[...valores, req.usuario.id, id],
|
||||
);
|
||||
await registrar(req, { accion: 'oferta.editar', entidad: 'programa', entidad_id: actual.clave, detalle: `${actual.nombre} · campos: ${resumen}` });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// --- horarios de un programa -----------------------------------------
|
||||
|
||||
const CAMPOS_HORARIO = {
|
||||
programa_id: entero({ min: 1, etiqueta: 'El programa' }),
|
||||
modalidad_id: entero({ min: 1, etiqueta: 'La modalidad' }),
|
||||
dias: texto(64, { requerido: true, etiqueta: 'Los días' }),
|
||||
hora_inicio: hora({ etiqueta: 'La hora de inicio' }),
|
||||
hora_fin: hora({ etiqueta: 'La hora de fin' }),
|
||||
nota: texto(160, { etiqueta: 'La nota' }),
|
||||
};
|
||||
|
||||
rutas.post('/horarios', exigir('oferta.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, CAMPOS_HORARIO, { exigirTodos: true });
|
||||
const r = await exec(
|
||||
`INSERT INTO programas_modalidades (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
|
||||
valores,
|
||||
);
|
||||
await registrar(req, { accion: 'oferta.crear', entidad: 'horario', entidad_id: String(r.insertId), detalle: `horario nuevo en el programa ${req.body?.programa_id}` });
|
||||
res.status(201).json({ ok: true, id: r.insertId });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.patch('/horarios/:id', exigir('oferta.editar'), async (req, res) => {
|
||||
try {
|
||||
const { programa_id: _omitir, ...editables } = CAMPOS_HORARIO;
|
||||
const { columnas, valores, resumen } = validar(req.body, editables);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
const r = await exec(
|
||||
`UPDATE programas_modalidades SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...valores, Number(req.params.id)],
|
||||
);
|
||||
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await registrar(req, { accion: 'oferta.editar', entidad: 'horario', entidad_id: req.params.id, detalle: resumen });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.delete('/horarios/:id', exigir('oferta.editar'), async (req, res) => {
|
||||
const r = await exec('DELETE FROM programas_modalidades WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await registrar(req, { accion: 'oferta.borrar', entidad: 'horario', entidad_id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Requisitos y titulación
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/requisitos', exigir('requisitos.ver'), async (_req, res) => {
|
||||
const niveles = await q('SELECT id, clave, nombre FROM niveles ORDER BY orden');
|
||||
for (const n of niveles) {
|
||||
n.requisitos = await q('SELECT id, texto, nota, orden FROM requisitos WHERE nivel_id = ? ORDER BY orden, id', [n.id]);
|
||||
n.programas = await q('SELECT nombre_corto, color FROM programas WHERE nivel_id = ? AND activo = 1 ORDER BY orden', [n.id]);
|
||||
}
|
||||
res.json({
|
||||
niveles,
|
||||
titulacion: await q('SELECT id, nombre, descripcion, orden FROM titulacion_opciones ORDER BY orden, id'),
|
||||
});
|
||||
});
|
||||
|
||||
const CAMPOS_REQUISITO = {
|
||||
nivel_id: entero({ min: 1, etiqueta: 'El nivel' }),
|
||||
texto: texto(160, { requerido: true, etiqueta: 'El requisito' }),
|
||||
nota: texto(200, { etiqueta: 'La nota' }),
|
||||
orden: entero({ min: 0, max: 127, etiqueta: 'El orden' }),
|
||||
};
|
||||
|
||||
rutas.post('/requisitos', exigir('requisitos.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, CAMPOS_REQUISITO, { exigirTodos: true });
|
||||
const r = await exec(
|
||||
`INSERT INTO requisitos (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
|
||||
valores,
|
||||
);
|
||||
await registrar(req, { accion: 'requisitos.crear', entidad: 'requisito', entidad_id: String(r.insertId), detalle: String(req.body?.texto ?? '').slice(0, 120) });
|
||||
res.status(201).json({ ok: true, id: r.insertId });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.patch('/requisitos/:id', exigir('requisitos.editar'), async (req, res) => {
|
||||
try {
|
||||
const { nivel_id: _omitir, ...editables } = CAMPOS_REQUISITO;
|
||||
const { columnas, valores, resumen } = validar(req.body, editables);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
const r = await exec(
|
||||
`UPDATE requisitos SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...valores, Number(req.params.id)],
|
||||
);
|
||||
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await registrar(req, { accion: 'requisitos.editar', entidad: 'requisito', entidad_id: req.params.id, detalle: resumen });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.delete('/requisitos/:id', exigir('requisitos.editar'), async (req, res) => {
|
||||
const fila = await q1('SELECT texto FROM requisitos WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!fila) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await exec('DELETE FROM requisitos WHERE id = ?', [Number(req.params.id)]);
|
||||
await registrar(req, { accion: 'requisitos.borrar', entidad: 'requisito', entidad_id: req.params.id, detalle: fila.texto.slice(0, 120) });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
const CAMPOS_TITULACION = {
|
||||
nombre: texto(64, { requerido: true, etiqueta: 'El nombre' }),
|
||||
descripcion: texto(255, { etiqueta: 'La descripción' }),
|
||||
orden: entero({ min: 0, max: 127, etiqueta: 'El orden' }),
|
||||
};
|
||||
|
||||
rutas.post('/titulacion', exigir('requisitos.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, CAMPOS_TITULACION, { exigirTodos: true });
|
||||
const r = await exec(
|
||||
`INSERT INTO titulacion_opciones (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
|
||||
valores,
|
||||
);
|
||||
await registrar(req, { accion: 'requisitos.crear', entidad: 'titulacion', entidad_id: String(r.insertId), detalle: String(req.body?.nombre ?? '') });
|
||||
res.status(201).json({ ok: true, id: r.insertId });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.patch('/titulacion/:id', exigir('requisitos.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores, resumen } = validar(req.body, CAMPOS_TITULACION);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
const r = await exec(
|
||||
`UPDATE titulacion_opciones SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...valores, Number(req.params.id)],
|
||||
);
|
||||
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await registrar(req, { accion: 'requisitos.editar', entidad: 'titulacion', entidad_id: req.params.id, detalle: resumen });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.delete('/titulacion/:id', exigir('requisitos.editar'), async (req, res) => {
|
||||
const r = await exec('DELETE FROM titulacion_opciones WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!r.affectedRows) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await registrar(req, { accion: 'requisitos.borrar', entidad: 'titulacion', entidad_id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Becas, promociones y fechas de inscripción
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/becas', exigir('becas.ver'), async (_req, res) => {
|
||||
res.json({
|
||||
promociones: await q('SELECT * FROM promociones ORDER BY activo DESC, orden'),
|
||||
fechas: await q(
|
||||
`SELECT p.nombre_corto, p.nombre, p.fecha_inicio, p.duracion, p.color, n.nombre AS nivel
|
||||
FROM programas p JOIN niveles n ON n.id = p.nivel_id
|
||||
WHERE p.activo = 1 ORDER BY n.orden, p.orden`,
|
||||
),
|
||||
escalaReferidos: [1, 2, 3, 4, 5].map((n) => ({
|
||||
referidos: n,
|
||||
descuento: n * 20,
|
||||
mensualidad_licenciatura: 2000 * (1 - n * 0.2),
|
||||
mensualidad_bachillerato: 1500 * (1 - n * 0.2),
|
||||
conviene: 2000 * (1 - n * 0.2) > 1500 ? 'beca' : 'referidos',
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const CAMPOS_PROMOCION = {
|
||||
nombre: texto(120, { requerido: true, etiqueta: 'El nombre' }),
|
||||
resumen: texto(200, { etiqueta: 'La frase para el interesado' }),
|
||||
aplica_a: opcion(['inscripcion', 'mensualidad', 'ambos'], { etiqueta: 'A qué aplica' }),
|
||||
vigencia: texto(120, { etiqueta: 'La vigencia' }),
|
||||
acumulable: booleano(),
|
||||
condiciones: texto(900, { etiqueta: 'Las condiciones' }),
|
||||
activo: booleano(),
|
||||
};
|
||||
|
||||
rutas.post('/becas', exigir('becas.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, CAMPOS_PROMOCION, { exigirTodos: true });
|
||||
const clave = `promo-${Date.now().toString(36)}`;
|
||||
const [{ n }] = await q('SELECT COALESCE(MAX(orden), 0) + 1 AS n FROM promociones');
|
||||
const r = await exec(
|
||||
`INSERT INTO promociones (clave, orden, ${columnas.join(', ')}) VALUES (?, ?, ${columnas.map(() => '?').join(', ')})`,
|
||||
[clave, n, ...valores],
|
||||
);
|
||||
await registrar(req, { accion: 'becas.crear', entidad: 'promocion', entidad_id: clave, detalle: String(req.body?.nombre ?? '') });
|
||||
res.status(201).json({ ok: true, id: r.insertId });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.patch('/becas/:id', exigir('becas.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT clave, nombre FROM promociones WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Esa promoción no existe.' });
|
||||
try {
|
||||
const { columnas, valores, resumen } = validar(req.body, CAMPOS_PROMOCION);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
await exec(
|
||||
`UPDATE promociones SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...valores, Number(req.params.id)],
|
||||
);
|
||||
await registrar(req, { accion: 'becas.editar', entidad: 'promocion', entidad_id: actual.clave, detalle: `${actual.nombre} · campos: ${resumen}` });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.delete('/becas/:id', exigir('becas.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT clave, nombre FROM promociones WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await exec('DELETE FROM promociones WHERE id = ?', [Number(req.params.id)]);
|
||||
await registrar(req, { accion: 'becas.borrar', entidad: 'promocion', entidad_id: actual.clave, detalle: actual.nombre });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Links de clases
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/clases', exigir('clases.ver'), async (req, res) => {
|
||||
const puedeEditar = puede(req, 'clases.editar');
|
||||
const clases = await q(
|
||||
`SELECT c.id, c.programa_id, c.materia, c.docente, c.grupo, c.dias, c.hora_inicio, c.hora_fin,
|
||||
c.enlace, c.notas, c.activo, c.actualizado_en,
|
||||
p.nombre_corto AS programa, p.nombre AS programa_nombre, p.clave AS programa_clave, p.color AS programa_color,
|
||||
n.nombre AS nivel, u.nombre AS actualizado_por
|
||||
FROM clases c
|
||||
JOIN programas p ON p.id = c.programa_id
|
||||
JOIN niveles n ON n.id = p.nivel_id
|
||||
LEFT JOIN usuarios u ON u.id = c.actualizado_por
|
||||
${puedeEditar ? '' : 'WHERE c.activo = 1'}
|
||||
ORDER BY n.orden, p.orden, FIELD(c.dias,'Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo'), c.hora_inicio, c.materia`,
|
||||
);
|
||||
res.json({
|
||||
clases,
|
||||
programas: await q(
|
||||
`SELECT p.id, p.nombre_corto, p.nombre, p.color, n.nombre AS nivel
|
||||
FROM programas p JOIN niveles n ON n.id = p.nivel_id
|
||||
WHERE p.activo = 1 ORDER BY n.orden, p.orden`,
|
||||
),
|
||||
dias: ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'],
|
||||
puedeEditar,
|
||||
});
|
||||
});
|
||||
|
||||
const CAMPOS_CLASE = {
|
||||
programa_id: entero({ min: 1, etiqueta: 'El programa' }),
|
||||
materia: texto(120, { requerido: true, etiqueta: 'La materia' }),
|
||||
docente: texto(96, { etiqueta: 'El docente' }),
|
||||
grupo: texto(48, { etiqueta: 'El grupo' }),
|
||||
dias: opcion(['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'], { etiqueta: 'El día' }),
|
||||
// La hora es obligatoria: una clase sin hora aparece en la tabla con el día
|
||||
// pero sin cuándo, y es justo lo que se le dicta a un alumno.
|
||||
hora_inicio: hora({ requerido: true, etiqueta: 'La hora de inicio' }),
|
||||
hora_fin: hora({ requerido: true, etiqueta: 'La hora de fin' }),
|
||||
enlace: enlace({ requerido: true, etiqueta: 'El link de la clase' }),
|
||||
notas: texto(300, { etiqueta: 'Las notas' }),
|
||||
activo: booleano(),
|
||||
};
|
||||
|
||||
rutas.post('/clases', exigir('clases.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, CAMPOS_CLASE, { exigirTodos: true });
|
||||
const [{ n }] = await q('SELECT COALESCE(MAX(orden), 0) + 1 AS n FROM clases');
|
||||
const r = await exec(
|
||||
`INSERT INTO clases (${columnas.join(', ')}, orden, actualizado_por) VALUES (${columnas.map(() => '?').join(', ')}, ?, ?)`,
|
||||
[...valores, n, req.usuario.id],
|
||||
);
|
||||
await registrar(req, {
|
||||
accion: 'clases.crear', entidad: 'clase', entidad_id: String(r.insertId),
|
||||
detalle: `${req.body?.materia ?? ''} · ${req.body?.dias ?? ''} ${req.body?.hora_inicio ?? ''}`,
|
||||
});
|
||||
res.status(201).json({ ok: true, id: r.insertId });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.patch('/clases/:id', exigir('clases.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT id, materia FROM clases WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Esa clase ya no existe.' });
|
||||
try {
|
||||
const { columnas, valores, resumen } = validar(req.body, CAMPOS_CLASE);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
await exec(
|
||||
`UPDATE clases SET ${columnas.map((c) => `${c} = ?`).join(', ')}, actualizado_por = ? WHERE id = ?`,
|
||||
[...valores, req.usuario.id, Number(req.params.id)],
|
||||
);
|
||||
await registrar(req, { accion: 'clases.editar', entidad: 'clase', entidad_id: req.params.id, detalle: `${actual.materia} · campos: ${resumen}` });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.delete('/clases/:id', exigir('clases.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT materia FROM clases WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await exec('DELETE FROM clases WHERE id = ?', [Number(req.params.id)]);
|
||||
await registrar(req, { accion: 'clases.borrar', entidad: 'clase', entidad_id: req.params.id, detalle: actual.materia });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Preguntas frecuentes
|
||||
// =====================================================================
|
||||
|
||||
/** Escapa los comodines de LIKE para que buscar "%" no devuelva todo. */
|
||||
function paraLike(texto) {
|
||||
return `%${String(texto).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
||||
}
|
||||
|
||||
rutas.get('/preguntas', exigir('kb.ver'), async (req, res) => {
|
||||
const busq = String(req.query.q ?? '').trim().slice(0, 120);
|
||||
let articulos;
|
||||
if (busq) {
|
||||
const like = paraLike(busq);
|
||||
articulos = await q(
|
||||
`SELECT id, seccion, pregunta, respuesta, palabras_clave, fuente, orden FROM kb_articulos
|
||||
WHERE pregunta LIKE ? OR respuesta LIKE ? OR palabras_clave LIKE ?
|
||||
ORDER BY orden, id`,
|
||||
[like, like, like],
|
||||
);
|
||||
} else {
|
||||
articulos = await q('SELECT id, seccion, pregunta, respuesta, palabras_clave, fuente, orden FROM kb_articulos ORDER BY orden, id');
|
||||
}
|
||||
// GROUP BY en lugar de DISTINCT: con ONLY_FULL_GROUP_BY activo, un
|
||||
// ORDER BY por una columna fuera del SELECT rompe el DISTINCT.
|
||||
const todas = await q('SELECT seccion FROM kb_articulos GROUP BY seccion ORDER BY MIN(orden)');
|
||||
res.json({ secciones: todas.map((s) => s.seccion), articulos, busqueda: busq });
|
||||
});
|
||||
|
||||
const CAMPOS_PREGUNTA = {
|
||||
seccion: texto(48, { requerido: true, etiqueta: 'La categoría' }),
|
||||
pregunta: texto(255, { requerido: true, etiqueta: 'La pregunta' }),
|
||||
respuesta: texto(4000, { requerido: true, etiqueta: 'La respuesta' }),
|
||||
palabras_clave: texto(255, { etiqueta: 'Las palabras clave' }),
|
||||
orden: entero({ min: 0, max: 32767, etiqueta: 'El orden' }),
|
||||
};
|
||||
|
||||
rutas.post('/preguntas', exigir('kb.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, CAMPOS_PREGUNTA, { exigirTodos: true });
|
||||
const r = await exec(
|
||||
`INSERT INTO kb_articulos (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
|
||||
valores,
|
||||
);
|
||||
await registrar(req, { accion: 'kb.crear', entidad: 'pregunta', entidad_id: String(r.insertId), detalle: String(req.body?.pregunta ?? '').slice(0, 120) });
|
||||
res.status(201).json({ ok: true, id: r.insertId });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.patch('/preguntas/:id', exigir('kb.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT pregunta FROM kb_articulos WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
try {
|
||||
const { columnas, valores, resumen } = validar(req.body, CAMPOS_PREGUNTA);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
await exec(
|
||||
`UPDATE kb_articulos SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...valores, Number(req.params.id)],
|
||||
);
|
||||
await registrar(req, { accion: 'kb.editar', entidad: 'pregunta', entidad_id: req.params.id, detalle: `${actual.pregunta.slice(0, 90)} · campos: ${resumen}` });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.delete('/preguntas/:id', exigir('kb.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT pregunta FROM kb_articulos WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await exec('DELETE FROM kb_articulos WHERE id = ?', [Number(req.params.id)]);
|
||||
await registrar(req, { accion: 'kb.borrar', entidad: 'pregunta', entidad_id: req.params.id, detalle: actual.pregunta.slice(0, 120) });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Mensajes listos
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/mensajes', exigir('guiones.ver'), async (_req, res) => {
|
||||
const guiones = await q('SELECT id, categoria, titulo, cuerpo, canal, orden FROM guiones ORDER BY orden, id');
|
||||
res.json({ categorias: [...new Set(guiones.map((g) => g.categoria))], guiones });
|
||||
});
|
||||
|
||||
const CAMPOS_MENSAJE = {
|
||||
categoria: texto(48, { requerido: true, etiqueta: 'La categoría' }),
|
||||
titulo: texto(160, { requerido: true, etiqueta: 'El título' }),
|
||||
cuerpo: texto(4000, { requerido: true, etiqueta: 'El mensaje' }),
|
||||
canal: texto(24, { etiqueta: 'El canal' }),
|
||||
orden: entero({ min: 0, max: 32767, etiqueta: 'El orden' }),
|
||||
};
|
||||
|
||||
rutas.post('/mensajes', exigir('guiones.editar'), async (req, res) => {
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, CAMPOS_MENSAJE, { exigirTodos: true });
|
||||
const r = await exec(
|
||||
`INSERT INTO guiones (${columnas.join(', ')}) VALUES (${columnas.map(() => '?').join(', ')})`,
|
||||
valores,
|
||||
);
|
||||
await registrar(req, { accion: 'guiones.crear', entidad: 'mensaje', entidad_id: String(r.insertId), detalle: String(req.body?.titulo ?? '') });
|
||||
res.status(201).json({ ok: true, id: r.insertId });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.patch('/mensajes/:id', exigir('guiones.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT titulo FROM guiones WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
try {
|
||||
const { columnas, valores, resumen } = validar(req.body, CAMPOS_MENSAJE);
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
await exec(
|
||||
`UPDATE guiones SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...valores, Number(req.params.id)],
|
||||
);
|
||||
await registrar(req, { accion: 'guiones.editar', entidad: 'mensaje', entidad_id: req.params.id, detalle: `${actual.titulo} · campos: ${resumen}` });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
rutas.delete('/mensajes/:id', exigir('guiones.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT titulo FROM guiones WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await exec('DELETE FROM guiones WHERE id = ?', [Number(req.params.id)]);
|
||||
await registrar(req, { accion: 'guiones.borrar', entidad: 'mensaje', entidad_id: req.params.id, detalle: actual.titulo });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Datos del instituto
|
||||
// =====================================================================
|
||||
|
||||
const GRUPOS_INSTITUCIONAL = {
|
||||
contacto: 'Contacto y ubicación',
|
||||
identidad: 'Identidad del instituto',
|
||||
proposito: 'Misión, visión y valores',
|
||||
};
|
||||
|
||||
rutas.get('/contacto', exigir('contacto.ver'), async (_req, res) => {
|
||||
const filas = await q('SELECT id, grupo, clave, etiqueta, valor FROM institucional ORDER BY orden, id');
|
||||
res.json({
|
||||
grupos: Object.entries(GRUPOS_INSTITUCIONAL).map(([id, titulo]) => ({
|
||||
id, titulo, campos: filas.filter((f) => f.grupo === id),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
rutas.patch('/contacto/:id', exigir('contacto.editar'), async (req, res) => {
|
||||
const actual = await q1('SELECT clave, etiqueta FROM institucional WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
try {
|
||||
const { columnas, valores } = validar(req.body, {
|
||||
etiqueta: texto(96, { requerido: true, etiqueta: 'El nombre del dato' }),
|
||||
valor: texto(4000, { requerido: true, etiqueta: 'El contenido' }),
|
||||
});
|
||||
if (!columnas.length) return res.status(400).json({ error: 'nada-que-guardar', mensaje: 'No cambiaste nada.' });
|
||||
await exec(
|
||||
`UPDATE institucional SET ${columnas.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...valores, Number(req.params.id)],
|
||||
);
|
||||
await registrar(req, { accion: 'contacto.editar', entidad: 'dato', entidad_id: actual.clave, detalle: actual.etiqueta });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (responderError(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Datos por confirmar
|
||||
// =====================================================================
|
||||
|
||||
rutas.patch('/datos/:id', exigir('datos.gestionar'), async (req, res) => {
|
||||
const estado = String(req.body?.estado ?? '');
|
||||
if (!['abierto', 'en-proceso', 'aclarado'].includes(estado)) {
|
||||
return res.status(400).json({ error: 'estado-invalido', mensaje: 'Ese estado no existe.' });
|
||||
}
|
||||
const actual = await q1('SELECT titulo FROM discrepancias WHERE id = ?', [Number(req.params.id)]);
|
||||
if (!actual) return res.status(404).json({ error: 'no-encontrado', mensaje: 'Ese registro ya no existe. Alguien más lo pudo haber eliminado: recarga la página.' });
|
||||
await exec('UPDATE discrepancias SET estado = ? WHERE id = ?', [estado, Number(req.params.id)]);
|
||||
await registrar(req, { accion: 'datos.editar', entidad: 'dato-por-confirmar', entidad_id: req.params.id, detalle: `${actual.titulo.slice(0, 90)} → ${estado}` });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Equipo
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/equipo', exigir('equipo.ver'), async (_req, res) => {
|
||||
const usuarios = await q(
|
||||
`SELECT u.id, u.nombre, u.email, u.puesto, u.iniciales, u.extension, u.activo, u.ultimo_acceso,
|
||||
r.clave AS rol_clave, r.nombre AS rol_nombre, r.nivel AS rol_nivel, r.color AS rol_color
|
||||
FROM usuarios u JOIN roles r ON r.id = u.rol_id ORDER BY r.nivel DESC, u.nombre`,
|
||||
);
|
||||
const roles = await q('SELECT id, clave, nombre, nivel, color, descripcion FROM roles ORDER BY nivel DESC');
|
||||
const matriz = await q(
|
||||
`SELECT r.clave AS rol, p.clave AS permiso FROM roles_permisos rp
|
||||
JOIN roles r ON r.id = rp.rol_id JOIN permisos p ON p.id = rp.permiso_id`,
|
||||
);
|
||||
const porRol = {};
|
||||
for (const r of roles) porRol[r.clave] = matriz.filter((m) => m.rol === r.clave).map((m) => m.permiso);
|
||||
res.json({
|
||||
usuarios, roles, porRol,
|
||||
permisos: await q('SELECT clave, seccion, descripcion FROM permisos ORDER BY seccion, clave'),
|
||||
secciones: SECCIONES.map(({ id, titulo, permisos }) => ({ id, titulo, permisos })),
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Bitácora
|
||||
// =====================================================================
|
||||
|
||||
rutas.get('/bitacora', exigir('bitacora.ver'), async (req, res) => {
|
||||
// Math.trunc: sin él, un limite=50.5 se interpola tal cual en el LIMIT y
|
||||
// MySQL rechaza la consulta con un 500.
|
||||
const limite = Math.trunc(Math.min(200, Math.max(10, Number(req.query.limite) || 40)));
|
||||
|
||||
// Las listas van acotadas a propósito: en una demo que se prueba mucho, las
|
||||
// sesiones se acumulan y sin tope la página crecería sin control.
|
||||
const [{ n: sesionesTotal }] = await q(
|
||||
'SELECT COUNT(*) AS n FROM sesiones WHERE expira_en > UTC_TIMESTAMP()',
|
||||
);
|
||||
const [{ n: eventosTotal }] = await q('SELECT COUNT(*) AS n FROM bitacora');
|
||||
|
||||
res.json({
|
||||
eventosTotal,
|
||||
sesionesTotal,
|
||||
entradas: await q(
|
||||
`SELECT id, actor, rol, accion, entidad, entidad_id, resultado, detalle, ip_origen, creado_en
|
||||
FROM bitacora ORDER BY id DESC LIMIT ${limite}`,
|
||||
),
|
||||
sesionesActivas: await q(
|
||||
`SELECT u.nombre, u.iniciales, r.nombre AS rol, s.creada_en, s.expira_en, s.ip_origen
|
||||
FROM sesiones s JOIN usuarios u ON u.id = s.usuario_id JOIN roles r ON r.id = u.rol_id
|
||||
WHERE s.expira_en > UTC_TIMESTAMP() ORDER BY s.creada_en DESC LIMIT 25`,
|
||||
),
|
||||
enlaces: await q(
|
||||
`SELECT u.nombre, t.creado_en, t.expira_en, t.usado_en, t.ip_origen
|
||||
FROM magic_tokens t JOIN usuarios u ON u.id = t.usuario_id ORDER BY t.id DESC LIMIT 15`,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
// =====================================================================
|
||||
// Verificación rápida de la API sin navegador.
|
||||
// node tests/api.mjs
|
||||
// Comprueba que cada sección responda y que los permisos se apliquen.
|
||||
// =====================================================================
|
||||
const BASE = process.env.BASE ?? 'http://localhost:4180';
|
||||
|
||||
let fallos = 0;
|
||||
let pasos = 0;
|
||||
|
||||
function ok(cond, etiqueta, extra = '') {
|
||||
pasos++;
|
||||
if (cond) console.log(` ✔ ${etiqueta}${extra ? ` · ${extra}` : ''}`);
|
||||
else { fallos++; console.log(` ✖ ${etiqueta}${extra ? ` · ${extra}` : ''}`); }
|
||||
}
|
||||
|
||||
/** Sesión con su propia bolsa de cookies. */
|
||||
async function entrar(email) {
|
||||
const r1 = await fetch(`${BASE}/api/auth/enlace`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }),
|
||||
});
|
||||
const { token } = await r1.json();
|
||||
const r2 = await fetch(`${BASE}/api/auth/consumir`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }),
|
||||
});
|
||||
const cookie = r2.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
|
||||
const pedir = (ruta, opciones = {}) => fetch(`${BASE}/api${ruta}`, {
|
||||
...opciones,
|
||||
headers: { 'Content-Type': 'application/json', cookie, ...(opciones.headers ?? {}) },
|
||||
body: opciones.body ? JSON.stringify(opciones.body) : undefined,
|
||||
});
|
||||
return { pedir, json: async (ruta) => (await pedir(ruta)).json() };
|
||||
}
|
||||
|
||||
const SECCIONES = ['inicio', 'oferta', 'requisitos', 'becas', 'clases', 'preguntas', 'mensajes', 'contacto', 'equipo', 'bitacora'];
|
||||
|
||||
console.log('\n▸ Verificación de la API del Portal INSTA\n');
|
||||
|
||||
// ---- navegación por rol ----------------------------------------------
|
||||
console.log('Navegación por rol');
|
||||
const esperado = { director: 10, administrativo: 9, empleado: 8 };
|
||||
const sesiones = {};
|
||||
for (const [rol, email] of Object.entries({
|
||||
director: '[email protected]',
|
||||
administrativo: '[email protected]',
|
||||
empleado: '[email protected]',
|
||||
})) {
|
||||
sesiones[rol] = await entrar(email);
|
||||
const yo = await sesiones[rol].json('/auth/yo');
|
||||
ok(yo.navegacion.length === esperado[rol], `${rol} ve ${esperado[rol]} secciones`, `vio ${yo.navegacion.length}: ${yo.navegacion.map((s) => s.id).join(', ')}`);
|
||||
}
|
||||
|
||||
// ---- todas las secciones responden ----------------------------------
|
||||
console.log('\nTodas las secciones responden para dirección');
|
||||
for (const s of SECCIONES) {
|
||||
const r = await sesiones.director.pedir(`/${s}`);
|
||||
ok(r.ok, `GET /${s}`, r.ok ? '' : `${r.status}`);
|
||||
}
|
||||
|
||||
// ---- el empleado no ve lo restringido -------------------------------
|
||||
console.log('\nEl empleado no alcanza lo restringido');
|
||||
for (const s of ['equipo', 'bitacora']) {
|
||||
const r = await sesiones.empleado.pedir(`/${s}`);
|
||||
ok(r.status === 403, `GET /${s} responde 403`, `respondió ${r.status}`);
|
||||
}
|
||||
|
||||
console.log('\nEl empleado no puede editar nada');
|
||||
const intentos = [
|
||||
['PATCH', '/oferta/1', { nombre: 'Cambio no autorizado' }],
|
||||
['POST', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', enlace: 'https://meet.google.com/x' }],
|
||||
['PATCH', '/preguntas/1', { pregunta: 'X' }],
|
||||
['PATCH', '/becas/1', { nombre: 'X' }],
|
||||
['PATCH', '/contacto/1', { valor: 'X' }],
|
||||
['DELETE', '/requisitos/1', null],
|
||||
['PATCH', '/datos/1', { estado: 'aclarado' }],
|
||||
['POST', '/titulacion', { nombre: 'X' }],
|
||||
['POST', '/horarios', { programa_id: 1, modalidad_id: 1, dias: 'Lunes' }],
|
||||
['POST', '/mensajes', { categoria: 'X', titulo: 'X', cuerpo: 'X' }],
|
||||
];
|
||||
for (const [metodo, ruta, cuerpo] of intentos) {
|
||||
const r = await sesiones.empleado.pedir(ruta, { method: metodo, body: cuerpo ?? undefined });
|
||||
ok(r.status === 403, `${metodo} ${ruta} responde 403`, `respondió ${r.status}`);
|
||||
}
|
||||
|
||||
// ---- administración: solo clases ------------------------------------
|
||||
console.log('\nAdministración edita clases pero no el catálogo');
|
||||
const negadosParaAdmin = [
|
||||
['PATCH', '/oferta/1', { nombre: 'X' }],
|
||||
['PATCH', '/becas/1', { nombre: 'X' }],
|
||||
['PATCH', '/preguntas/1', { pregunta: 'X' }],
|
||||
['PATCH', '/contacto/1', { valor: 'X' }],
|
||||
['DELETE', '/requisitos/1', null],
|
||||
['PATCH', '/datos/1', { estado: 'aclarado' }],
|
||||
];
|
||||
for (const [metodo, ruta, cuerpo] of negadosParaAdmin) {
|
||||
const r = await sesiones.administrativo.pedir(ruta, { method: metodo, body: cuerpo ?? undefined });
|
||||
ok(r.status === 403, `${metodo} ${ruta} negado a administración`, `respondió ${r.status}`);
|
||||
}
|
||||
|
||||
const creada = await sesiones.administrativo.pedir('/clases', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
programa_id: 1, materia: 'Materia de prueba', docente: 'Prueba', grupo: 'Prueba',
|
||||
dias: 'Viernes', hora_inicio: '18:00', hora_fin: '19:30',
|
||||
enlace: 'https://meet.google.com/prueba-abc-def', notas: '', activo: 1,
|
||||
},
|
||||
});
|
||||
const nueva = await creada.json();
|
||||
ok(creada.status === 201 && nueva.id, 'administración crea una clase', `id ${nueva.id}`);
|
||||
|
||||
const editada = await sesiones.administrativo.pedir(`/clases/${nueva.id}`, {
|
||||
method: 'PATCH', body: { materia: 'Materia de prueba editada' },
|
||||
});
|
||||
ok(editada.ok, 'administración edita la clase');
|
||||
|
||||
const borrada = await sesiones.administrativo.pedir(`/clases/${nueva.id}`, { method: 'DELETE' });
|
||||
ok(borrada.ok, 'administración borra la clase');
|
||||
|
||||
// ---- validación de campos -------------------------------------------
|
||||
console.log('\nValidación de campos');
|
||||
const malos = [
|
||||
['enlace no es una URL', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', enlace: 'no-es-una-url' }],
|
||||
['enlace con esquema raro', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', enlace: 'javascript:alert(1)' }],
|
||||
['día inexistente', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunon', enlace: 'https://meet.google.com/x' }],
|
||||
['hora mal escrita', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', hora_inicio: '25:99', enlace: 'https://meet.google.com/x' }],
|
||||
['materia vacía', '/clases', { programa_id: 1, materia: ' ', dias: 'Lunes', enlace: 'https://meet.google.com/x' }],
|
||||
['programa que no existe', '/clases', { programa_id: 9999, materia: 'X', dias: 'Lunes', enlace: 'https://meet.google.com/x' }],
|
||||
];
|
||||
for (const [etiqueta, ruta, cuerpo] of malos) {
|
||||
const r = await sesiones.director.pedir(ruta, { method: 'POST', body: cuerpo });
|
||||
const d = await r.json().catch(() => ({}));
|
||||
ok(r.status === 400, `rechaza: ${etiqueta}`, `${r.status} ${d.mensaje ?? ''}`);
|
||||
}
|
||||
|
||||
const rvoeMalo = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { rvoe_estado: 'inventado' } });
|
||||
ok(rvoeMalo.status === 400, 'rechaza un estado de RVOE inventado', `${rvoeMalo.status}`);
|
||||
|
||||
const dineroMalo = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { mensualidad_beca: 'mucho' } });
|
||||
ok(dineroMalo.status === 400, 'rechaza una mensualidad que no es número', `${dineroMalo.status}`);
|
||||
|
||||
const campoNoDeclarado = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { clave: 'otra-clave', activo: 0 } });
|
||||
ok(campoNoDeclarado.status === 400, 'ignora campos no editables y avisa que no hay nada que guardar', `${campoNoDeclarado.status}`);
|
||||
const prog1 = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 1);
|
||||
ok(prog1 && prog1.clave !== 'otra-clave', 'la clave del programa no se pudo cambiar');
|
||||
|
||||
// ---- dirección sí puede editar el catálogo --------------------------
|
||||
console.log('\nDirección edita el catálogo');
|
||||
const antes = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 1);
|
||||
const r1 = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { beneficios: 'Beneficio de prueba' } });
|
||||
ok(r1.ok, 'dirección edita un programa');
|
||||
const despues = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 1);
|
||||
ok(despues.beneficios === 'Beneficio de prueba', 'el cambio se guardó');
|
||||
ok(despues.actualizado_por_nombre === 'Marisol Aguilar', 'quedó registrado quién lo cambió', despues.actualizado_por_nombre ?? '(nadie)');
|
||||
await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { beneficios: antes.beneficios } });
|
||||
|
||||
// ---- la bitácora registra ------------------------------------------
|
||||
console.log('\nLa bitácora registra');
|
||||
const bit = await sesiones.director.json('/bitacora?limite=120');
|
||||
ok(bit.entradas.some((e) => e.accion === 'acceso.denegado' && e.resultado === 'denegado'), 'guardó los intentos sin permiso');
|
||||
ok(bit.entradas.some((e) => e.accion === 'oferta.editar'), 'guardó la edición de la oferta');
|
||||
ok(bit.entradas.some((e) => e.accion === 'clases.crear'), 'guardó la creación de la clase');
|
||||
ok(bit.entradas.some((e) => e.accion === 'clases.borrar'), 'guardó el borrado de la clase');
|
||||
|
||||
// ---- el contenido es el del CSV ------------------------------------
|
||||
console.log('\nEl contenido coincide con el archivo de oferta educativa');
|
||||
const oferta = await sesiones.director.json('/oferta');
|
||||
ok(oferta.programas.length === 8, 'hay 8 programas', `hay ${oferta.programas.length}`);
|
||||
const psico = oferta.programas.find((p) => p.clave === 'prog-psicologia');
|
||||
ok(Number(psico.mensualidad_beca) === 1500 && Number(psico.mensualidad_lista) === 2000, 'Psicología: $2,000 de lista y $1,500 con beca');
|
||||
ok(psico.rvoe_acuerdo === '20110687', 'Psicología: acuerdo 20110687');
|
||||
const bach = oferta.programas.find((p) => p.clave === 'prog-bachillerato');
|
||||
ok(Number(bach.inscripcion_lista) === 1125 && Number(bach.mensualidad_beca) === 1125, 'Bachillerato: inscripción $1,125 y mensualidad con beca $1,125');
|
||||
ok(bach.rvoe_estado === 'por-confirmar', 'Bachillerato: RVOE sin confirmar');
|
||||
// Bachillerato y las dos maestrías sin acuerdo, más Trabajo Social por revisar.
|
||||
ok(oferta.programas.filter((p) => p.rvoe_estado === 'por-confirmar').length === 3, '3 programas sin número de acuerdo');
|
||||
ok(oferta.programas.filter((p) => p.rvoe_estado === 'revisar').length === 1, '1 programa por revisar (Trabajo Social)');
|
||||
ok(oferta.programas.filter((p) => p.rvoe_estado === 'confirmado').length === 4, '4 programas con validez confirmada');
|
||||
|
||||
const req = await sesiones.director.json('/requisitos');
|
||||
const nivelBach = req.niveles.find((n) => n.clave === 'bachillerato');
|
||||
ok(nivelBach.requisitos.some((r) => r.texto.includes('6 fotografías')), 'el bachillerato pide 6 fotografías');
|
||||
ok(req.niveles.find((n) => n.clave === 'licenciatura').requisitos.some((r) => r.texto.includes('4 fotografías')), 'la licenciatura pide 4 fotografías');
|
||||
ok(req.titulacion.length === 5, 'hay 5 formas de titulación', `hay ${req.titulacion.length}`);
|
||||
|
||||
const clases = await sesiones.director.json('/clases');
|
||||
ok(clases.clases.length >= 27, 'hay al menos 27 clases', `hay ${clases.clases.length}`);
|
||||
ok(clases.clases.every((c) => /^https?:\/\//.test(c.enlace)), 'todos los links son direcciones válidas');
|
||||
ok(new Set(clases.clases.map((c) => c.programa_id)).size === 8, 'hay clases en los 8 programas');
|
||||
|
||||
const becas = await sesiones.director.json('/becas');
|
||||
ok(becas.promociones.length === 5, 'hay 5 esquemas de precio', `hay ${becas.promociones.length}`);
|
||||
// La fuente solo declara no acumulable el programa de referidos: la beca y los
|
||||
// descuentos de temporada sí se combinan, y así se ofrecen en WhatsApp.
|
||||
const referidos = becas.promociones.find((p) => p.clave === 'referidos');
|
||||
ok(referidos && referidos.acumulable === 0, 'el programa de referidos no se combina con nada');
|
||||
ok(becas.promociones.filter((p) => p.acumulable === 0).length === 1, 'es el único que no se combina');
|
||||
ok(becas.promociones.find((p) => p.clave === 'beca-general').acumulable === 1, 'la beca del 25% sí se combina');
|
||||
ok(becas.escalaReferidos[0].conviene === 'beca', 'con 1 referido conviene la beca');
|
||||
ok(becas.escalaReferidos[4].mensualidad_licenciatura === 0, 'con 5 referidos la mensualidad queda en 0');
|
||||
ok(becas.fechas.length === 8, 'la tabla de fechas trae los 8 programas');
|
||||
|
||||
const mensajes = await sesiones.director.json('/mensajes');
|
||||
const caro = mensajes.guiones.find((g) => g.titulo.includes('está caro'));
|
||||
ok(caro && !/otro 20%/.test(caro.cuerpo), 'el mensaje de precio no suma referidos sobre la beca');
|
||||
ok(caro && /no se juntan/i.test(caro.cuerpo), 'el mensaje de precio aclara que se elige un esquema');
|
||||
const kb = await sesiones.director.json('/preguntas');
|
||||
ok(kb.articulos.every((a) => !/seis licenciaturas/i.test(a.respuesta)), 'no dice "seis licenciaturas": son cinco');
|
||||
|
||||
// ---- el empleado solo ve clases activas -----------------------------
|
||||
console.log('\nAlcance de las clases');
|
||||
const clasesEmpleado = await sesiones.empleado.json('/clases');
|
||||
ok(clasesEmpleado.puedeEditar === false, 'el empleado no recibe permiso de edición');
|
||||
ok(clasesEmpleado.clases.every((c) => c.activo === 1), 'el empleado solo ve clases activas');
|
||||
|
||||
// ---- sin sesión -----------------------------------------------------
|
||||
console.log('\nSin sesión');
|
||||
for (const ruta of ['/inicio', '/oferta', '/clases']) {
|
||||
const r = await fetch(`${BASE}/api${ruta}`);
|
||||
ok(r.status === 401, `GET ${ruta} sin sesión responde 401`, `respondió ${r.status}`);
|
||||
}
|
||||
|
||||
// ---- el enlace de acceso sirve UNA sola vez, incluso en paralelo -----
|
||||
console.log('\nEl enlace de acceso');
|
||||
{
|
||||
const r1 = await fetch(`${BASE}/api/auth/enlace`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: '[email protected]' }),
|
||||
});
|
||||
const { token } = await r1.json();
|
||||
|
||||
// Cinco canjes a la vez: solo uno debe crear sesión.
|
||||
const canjes = await Promise.all(Array.from({ length: 5 }, () => fetch(`${BASE}/api/auth/consumir`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }),
|
||||
})));
|
||||
const exitosos = canjes.filter((r) => r.ok);
|
||||
ok(exitosos.length === 1, 'cinco canjes simultáneos del mismo enlace crean una sola sesión', `crearon ${exitosos.length}`);
|
||||
|
||||
const cookies = exitosos.flatMap((r) => r.headers.getSetCookie().map((c) => c.split(';')[0]));
|
||||
let sesionesValidas = 0;
|
||||
for (const cookie of cookies) {
|
||||
const yo = await fetch(`${BASE}/api/auth/yo`, { headers: { cookie } });
|
||||
if (yo.ok) sesionesValidas++;
|
||||
}
|
||||
ok(sesionesValidas <= 1, 'no queda más de una sesión viva por enlace', `quedaron ${sesionesValidas}`);
|
||||
|
||||
const tarde = await fetch(`${BASE}/api/auth/consumir`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }),
|
||||
});
|
||||
ok(tarde.status === 401, 'reusarlo después también se rechaza', `respondió ${tarde.status}`);
|
||||
ok((await tarde.json()).error === 'ya-usado', 'y dice que ya se usó');
|
||||
}
|
||||
|
||||
// ---- una cookie mal formada no debe tumbar la petición ---------------
|
||||
{
|
||||
const r = await fetch(`${BASE}/api/oferta`, { headers: { cookie: 'insta_sesion=%E0%A4%A' } });
|
||||
ok(r.status === 401, 'una cookie con % mal formado responde 401 y no cuelga', `respondió ${r.status}`);
|
||||
}
|
||||
|
||||
// ---- dinero: ni vacío ni con formato roto se guardan en silencio -----
|
||||
console.log('\nLos precios');
|
||||
{
|
||||
const vacio = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { inscripcion_lista: '' } });
|
||||
ok(vacio.status === 400, 'un precio vacío se rechaza en lugar de guardarse como $0', `${vacio.status}`);
|
||||
ok((await vacio.json()).campo === 'inscripcion_lista', 'y señala el campo');
|
||||
|
||||
const antes = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 2);
|
||||
const conFormato = await sesiones.director.pedir('/oferta/2', { method: 'PATCH', body: { mensualidad_lista: '$2,500' } });
|
||||
ok(conFormato.ok, 'un precio escrito como "$2,500" se acepta');
|
||||
const despuesP = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 2);
|
||||
ok(Number(despuesP.mensualidad_lista) === 2500, 'y se guarda como 2500, no como 2.50', String(despuesP.mensualidad_lista));
|
||||
await sesiones.director.pedir('/oferta/2', { method: 'PATCH', body: { mensualidad_lista: antes.mensualidad_lista } });
|
||||
|
||||
const raro = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { mensualidad_beca: '1500 pesos' } });
|
||||
ok(raro.status === 400, 'un precio con letras se rechaza', `${raro.status}`);
|
||||
const objeto = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { nombre: { a: 1 } } });
|
||||
ok(objeto.status === 400, 'un objeto en un campo de texto se rechaza', `${objeto.status}`);
|
||||
}
|
||||
|
||||
// ---- validez oficial: no se puede confirmar sin acuerdo --------------
|
||||
{
|
||||
const r = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { rvoe_estado: 'confirmado', rvoe_acuerdo: '' } });
|
||||
ok(r.status === 400, 'no deja marcar la validez como confirmada sin número de acuerdo', `${r.status}`);
|
||||
}
|
||||
|
||||
// ---- una clase sin hora no se guarda --------------------------------
|
||||
{
|
||||
const r = await sesiones.director.pedir('/clases', {
|
||||
method: 'POST',
|
||||
body: { programa_id: 1, materia: 'Sin hora', dias: 'Lunes', hora_inicio: '', hora_fin: '', enlace: 'https://meet.google.com/x-y-z' },
|
||||
});
|
||||
ok(r.status === 400, 'una clase sin hora se rechaza', `${r.status}`);
|
||||
}
|
||||
|
||||
// ---- consultas raras no deben tumbar nada ---------------------------
|
||||
console.log('\nEntradas raras');
|
||||
{
|
||||
const todo = await sesiones.director.json('/preguntas?q=%25');
|
||||
const normal = await sesiones.director.json('/preguntas');
|
||||
ok(todo.articulos.length < normal.articulos.length, 'buscar "%" no devuelve todo', `devolvió ${todo.articulos.length} de ${normal.articulos.length}`);
|
||||
|
||||
const bit = await sesiones.director.pedir('/bitacora?limite=50.5');
|
||||
ok(bit.ok, 'limite=50.5 no revienta la consulta', `respondió ${bit.status}`);
|
||||
|
||||
const grande = await sesiones.director.pedir('/preguntas/1', { method: 'PATCH', body: { respuesta: 'x'.repeat(300_000) } });
|
||||
ok(grande.status === 413 || grande.status === 400, 'un texto enorme responde 413 o 400, no 500', `respondió ${grande.status}`);
|
||||
ok((await grande.json()).mensaje, 'y con un mensaje que se pueda leer');
|
||||
|
||||
const malJson = await fetch(`${BASE}/api/auth/enlace`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{esto no es json',
|
||||
});
|
||||
ok(malJson.status === 400, 'un JSON roto responde 400', `respondió ${malJson.status}`);
|
||||
|
||||
const idRaro = await sesiones.director.pedir('/clases/abc', { method: 'DELETE' });
|
||||
ok(idRaro.status === 404 || idRaro.status === 400, 'un id que no es número responde 404 o 400', `respondió ${idRaro.status}`);
|
||||
}
|
||||
|
||||
// ---- cabeceras defensivas -------------------------------------------
|
||||
{
|
||||
const r = await fetch(`${BASE}/`);
|
||||
const csp = r.headers.get('content-security-policy');
|
||||
ok(Boolean(csp), 'se envía Content-Security-Policy');
|
||||
ok(csp?.includes("default-src 'self'"), 'y no permite recursos externos');
|
||||
ok(r.headers.get('x-content-type-options') === 'nosniff', 'se envía X-Content-Type-Options');
|
||||
ok(r.headers.get('x-frame-options') === 'DENY', 'se envía X-Frame-Options');
|
||||
}
|
||||
|
||||
console.log(`\n${fallos === 0 ? '✔' : '✖'} ${pasos - fallos} de ${pasos} comprobaciones pasaron\n`);
|
||||
process.exit(fallos === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,150 @@
|
||||
// =====================================================================
|
||||
// Capturas de referencia en WebKit.
|
||||
// node tests/capturas.mjs
|
||||
// Deja los PNG en tests/capturas/ y una hoja de contactos HTML para
|
||||
// revisar el portal completo de un vistazo.
|
||||
// =====================================================================
|
||||
import { webkit } from '@playwright/test';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASE = process.env.BASE ?? 'http://localhost:4180';
|
||||
const SALIDA = path.resolve('tests/capturas');
|
||||
|
||||
const SECCIONES = ['inicio', 'oferta', 'requisitos', 'becas', 'clases', 'preguntas', 'mensajes', 'contacto', 'equipo', 'bitacora'];
|
||||
|
||||
const VISTAS = [
|
||||
{ id: 'escritorio', ancho: 1440, alto: 900 },
|
||||
{ id: 'tableta', ancho: 820, alto: 1100 },
|
||||
{ id: 'movil', ancho: 390, alto: 844 },
|
||||
];
|
||||
|
||||
const CUENTAS = {
|
||||
director: '[email protected]',
|
||||
administrativo: '[email protected]',
|
||||
empleado: '[email protected]',
|
||||
};
|
||||
|
||||
await rm(SALIDA, { recursive: true, force: true });
|
||||
await mkdir(SALIDA, { recursive: true });
|
||||
|
||||
const navegador = await webkit.launch();
|
||||
const hechas = [];
|
||||
|
||||
async function capturar(ctx, nombre, url) {
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(450); // deja terminar la entrada en escena
|
||||
const archivo = `${nombre}.png`;
|
||||
const destino = path.join(SALIDA, archivo);
|
||||
const alto = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
// WebKit no puede capturar más de 32767 px de alto. Si una sección se pasa,
|
||||
// es en sí mismo un aviso: se corta la captura y se anota.
|
||||
if (alto > 30000) {
|
||||
console.log(` ⚠ ${nombre} mide ${alto} px de alto: se captura solo la primera pantalla`);
|
||||
await page.screenshot({ path: destino });
|
||||
hechas.push({ archivo, nombre: `${nombre} (recortada · ${alto} px)` });
|
||||
} else {
|
||||
await page.screenshot({ path: destino, fullPage: true });
|
||||
hechas.push({ archivo, nombre: `${nombre} · ${alto} px` });
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
|
||||
async function abrirSesion(ctx, email) {
|
||||
const r = await ctx.request.post(`${BASE}/api/auth/enlace`, { data: { email } });
|
||||
const { token } = await r.json();
|
||||
const p = await ctx.newPage();
|
||||
await p.goto(`${BASE}/acceso/${token}`);
|
||||
await p.waitForSelector('.barra__nav .nav-item');
|
||||
await p.close();
|
||||
}
|
||||
|
||||
for (const v of VISTAS) {
|
||||
const ctx = await navegador.newContext({
|
||||
viewport: { width: v.ancho, height: v.alto },
|
||||
colorScheme: 'light',
|
||||
locale: 'es-MX',
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
await capturar(ctx, `${v.id}-00-entrada`, `${BASE}/`);
|
||||
await abrirSesion(ctx, CUENTAS.director);
|
||||
|
||||
let i = 1;
|
||||
for (const s of SECCIONES) {
|
||||
await capturar(ctx, `${v.id}-${String(i++).padStart(2, '0')}-${s}`, `${BASE}/portal#/${s}`);
|
||||
}
|
||||
await ctx.close();
|
||||
console.log(` ✔ ${v.id} (${v.ancho} px)`);
|
||||
}
|
||||
|
||||
// La oferta en vista de tabla, que es la alterna
|
||||
{
|
||||
const ctx = await navegador.newContext({ viewport: { width: 1440, height: 900 }, locale: 'es-MX' });
|
||||
await abrirSesion(ctx, CUENTAS.director);
|
||||
const p = await ctx.newPage();
|
||||
await p.goto(`${BASE}/portal#/oferta`, { waitUntil: 'networkidle' });
|
||||
await p.locator('[data-vista="tabla"]').click();
|
||||
await p.waitForTimeout(400);
|
||||
await p.screenshot({ path: path.join(SALIDA, 'escritorio-oferta-tabla.png'), fullPage: true });
|
||||
hechas.push({ archivo: 'escritorio-oferta-tabla.png', nombre: 'oferta en tabla' });
|
||||
|
||||
// La ventana de edición de un programa
|
||||
await p.locator('[data-vista="tarjetas"]').click();
|
||||
await p.locator('[data-editar-programa]').first().click();
|
||||
await p.waitForTimeout(350);
|
||||
await p.screenshot({ path: path.join(SALIDA, 'escritorio-editor-programa.png') });
|
||||
hechas.push({ archivo: 'escritorio-editor-programa.png', nombre: 'editor de programa' });
|
||||
await p.close();
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// La ventana de edición de una clase, en teléfono
|
||||
{
|
||||
const ctx = await navegador.newContext({ viewport: { width: 390, height: 844 }, locale: 'es-MX' });
|
||||
await abrirSesion(ctx, CUENTAS.administrativo);
|
||||
const p = await ctx.newPage();
|
||||
await p.goto(`${BASE}/portal#/clases`, { waitUntil: 'networkidle' });
|
||||
await p.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
|
||||
await p.waitForTimeout(350);
|
||||
await p.screenshot({ path: path.join(SALIDA, 'movil-editor-clase.png') });
|
||||
hechas.push({ archivo: 'movil-editor-clase.png', nombre: 'editor de clase en móvil' });
|
||||
await p.close();
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// El menú de cada rol, para ver la diferencia de permisos de un vistazo
|
||||
for (const [rol, email] of Object.entries(CUENTAS)) {
|
||||
const ctx = await navegador.newContext({ viewport: { width: 1180, height: 1000 }, locale: 'es-MX' });
|
||||
await abrirSesion(ctx, email);
|
||||
const p = await ctx.newPage();
|
||||
await p.goto(`${BASE}/portal#/inicio`, { waitUntil: 'networkidle' });
|
||||
await p.waitForTimeout(450);
|
||||
await p.screenshot({ path: path.join(SALIDA, `rol-${rol}.png`) });
|
||||
hechas.push({ archivo: `rol-${rol}.png`, nombre: `menú de ${rol}` });
|
||||
await p.close();
|
||||
await ctx.close();
|
||||
}
|
||||
console.log(' ✔ menús por rol y ventanas de edición');
|
||||
|
||||
await navegador.close();
|
||||
|
||||
const hoja = `<!doctype html><meta charset="utf-8"><title>Capturas · Portal INSTA</title>
|
||||
<style>
|
||||
body{font:16px/1.6 system-ui;margin:0;padding:28px;background:#f3f7fc;color:#16283f}
|
||||
h1{font-family:Palatino,Georgia,serif;margin:0 0 6px}
|
||||
p{margin:0 0 24px;color:#45535f}
|
||||
.g{display:grid;gap:18px;grid-template-columns:repeat(auto-fill,minmax(340px,1fr))}
|
||||
figure{margin:0;background:#fff;border:1px solid #cfdce9;border-radius:20px 4px 20px 4px;overflow:hidden}
|
||||
figcaption{padding:10px 14px;font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#66737f;border-bottom:1px solid #e5edf5}
|
||||
img{display:block;width:100%;height:auto}
|
||||
</style>
|
||||
<h1>Portal interno INSTA · capturas en WebKit</h1>
|
||||
<p>${hechas.length} capturas · escritorio 1440, tableta 820, teléfono 390, las ventanas de edición y los tres menús por rol.</p>
|
||||
<div class="g">
|
||||
${hechas.map((h) => `<figure><figcaption>${h.nombre}</figcaption><img src="${h.archivo}" alt="${h.nombre}" loading="lazy"></figure>`).join('\n')}
|
||||
</div>`;
|
||||
|
||||
await writeFile(path.join(SALIDA, 'index.html'), hoja, 'utf8');
|
||||
console.log(`\n ${hechas.length} capturas en tests/capturas/ · abre tests/capturas/index.html\n`);
|
||||
@@ -0,0 +1,561 @@
|
||||
// =====================================================================
|
||||
// Verificación del portal en WebKit
|
||||
// Comprueba lo que la demo tiene que sostener: Magic Login, permisos,
|
||||
// que el contenido sea el del archivo de oferta educativa, que dirección
|
||||
// pueda editar de verdad, y que nada se desborde en ningún tamaño.
|
||||
// =====================================================================
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ROLES = {
|
||||
director: { email: '[email protected]', nombre: 'Marisol Aguilar', secciones: 10 },
|
||||
administrativo: { email: '[email protected]', nombre: 'Rubén Cázares', secciones: 9 },
|
||||
empleado: { email: '[email protected]', nombre: 'Denisse Ochoa', secciones: 8 },
|
||||
};
|
||||
|
||||
const SECCIONES = ['inicio', 'oferta', 'requisitos', 'becas', 'clases', 'preguntas', 'mensajes', 'contacto', 'equipo', 'bitacora'];
|
||||
|
||||
/** Entra por la vía real: genera el enlace y lo canjea, como haría el correo. */
|
||||
async function entrar(page, email) {
|
||||
const r = await page.request.post('/api/auth/enlace', { data: { email } });
|
||||
expect(r.ok(), `no se pudo generar el enlace de ${email}`).toBeTruthy();
|
||||
const { token } = await r.json();
|
||||
await page.goto(`/acceso/${token}`);
|
||||
await expect(page.locator('.barra__nav .nav-item').first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function irA(page, seccion) {
|
||||
await page.goto(`/portal#/${seccion}`);
|
||||
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
|
||||
await expect(page.locator('.cargando')).toHaveCount(0);
|
||||
// En teléfono los bloques largos llegan plegados. Se abren para que las
|
||||
// pruebas midan lo mismo en cualquier tamaño; el plegado se prueba aparte.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('details.plegable').forEach((d) => { d.open = true; });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
test.describe('Entrada', () => {
|
||||
test('ofrece los tres accesos y explica el enlace de un solo uso', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: /Trabajar y estudiar/ })).toBeVisible();
|
||||
await expect(page.locator('.tarjeta-rol')).toHaveCount(3);
|
||||
await expect(page.getByText(/sirve una vez y caduca/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('un correo no registrado se rechaza con un mensaje claro', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.fill('#email', '[email protected]');
|
||||
await page.click('#enviar');
|
||||
await expect(page.locator('#error-entrada')).toContainText(/no está dado de alta/i);
|
||||
});
|
||||
|
||||
test('el acceso rápido entra al portal', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('.tarjeta-rol', { hasText: 'Empleado' }).click();
|
||||
await page.waitForURL('**/portal');
|
||||
await expect(page.locator('.usuario__txt b')).toHaveText(ROLES.empleado.nombre);
|
||||
});
|
||||
|
||||
test('un enlace ya usado no vuelve a servir', async ({ page }) => {
|
||||
const r = await page.request.post('/api/auth/enlace', { data: { email: ROLES.empleado.email } });
|
||||
const { token } = await r.json();
|
||||
await page.goto(`/acceso/${token}`);
|
||||
await expect(page.locator('.barra__nav .nav-item').first()).toBeVisible();
|
||||
await page.request.post('/api/auth/salir');
|
||||
await page.goto(`/acceso/${token}`);
|
||||
await expect(page.locator('#error-entrada')).toContainText(/ya se usó/i);
|
||||
});
|
||||
|
||||
test('un enlace inventado no entra', async ({ page }) => {
|
||||
await page.goto('/acceso/token-que-no-existe');
|
||||
await expect(page.locator('#error-entrada')).toContainText(/no es válido/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
test.describe('Permisos', () => {
|
||||
for (const [clave, rol] of Object.entries(ROLES)) {
|
||||
test(`${clave} ve exactamente sus ${rol.secciones} secciones`, async ({ page }) => {
|
||||
await entrar(page, rol.email);
|
||||
await expect(page.locator('.barra__nav .nav-item[data-seccion]')).toHaveCount(rol.secciones);
|
||||
await expect(page.locator('.usuario__txt b')).toHaveText(rol.nombre);
|
||||
});
|
||||
}
|
||||
|
||||
test('el empleado no ve Equipo ni Bitácora', async ({ page }) => {
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
for (const oculta of ['equipo', 'bitacora']) {
|
||||
await expect(page.locator(`.nav-item[data-seccion="${oculta}"]`)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('el administrativo ve Equipo pero no la Bitácora', async ({ page }) => {
|
||||
await entrar(page, ROLES.administrativo.email);
|
||||
await expect(page.locator('.nav-item[data-seccion="equipo"]')).toHaveCount(1);
|
||||
await expect(page.locator('.nav-item[data-seccion="bitacora"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('la API niega lo que el menú oculta', async ({ page }) => {
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
for (const ruta of ['/api/equipo', '/api/bitacora']) {
|
||||
const r = await page.request.get(ruta);
|
||||
expect(r.status(), `${ruta} debería negarse`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
test('sin sesión la API responde 401 y el portal manda a la entrada', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
expect((await page.request.get('/api/inicio')).status()).toBe(401);
|
||||
await page.goto('/portal');
|
||||
await page.waitForURL('**/');
|
||||
await expect(page.locator('.tarjeta-rol').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('un intento denegado queda en la bitácora', async ({ page }) => {
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
await page.request.get('/api/bitacora'); // 403 a propósito
|
||||
await page.request.post('/api/auth/salir');
|
||||
await entrar(page, ROLES.director.email);
|
||||
const { entradas } = await (await page.request.get('/api/bitacora?limite=60')).json();
|
||||
expect(entradas.some((e) => e.accion === 'acceso.denegado' && e.resultado === 'denegado')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('solo dirección ve los botones de editar el catálogo', async ({ page }) => {
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
await irA(page, 'oferta');
|
||||
await expect(page.locator('[data-editar-programa]')).toHaveCount(0);
|
||||
await irA(page, 'clases');
|
||||
await expect(page.locator('[data-editar-clase]')).toHaveCount(0);
|
||||
|
||||
await page.request.post('/api/auth/salir');
|
||||
await entrar(page, ROLES.administrativo.email);
|
||||
await irA(page, 'oferta');
|
||||
await expect(page.locator('[data-editar-programa]')).toHaveCount(0);
|
||||
await irA(page, 'clases');
|
||||
await expect(page.locator('[data-editar-clase]').first()).toBeVisible();
|
||||
|
||||
await page.request.post('/api/auth/salir');
|
||||
await entrar(page, ROLES.director.email);
|
||||
await irA(page, 'oferta');
|
||||
await expect(page.locator('[data-editar-programa]').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
test.describe('Oferta educativa', () => {
|
||||
test.beforeEach(async ({ page }) => { await entrar(page, ROLES.director.email); });
|
||||
|
||||
test('muestra los 8 programas en tarjetas', async ({ page }) => {
|
||||
await irA(page, 'oferta');
|
||||
await page.locator('[data-vista="tarjetas"]').click();
|
||||
await expect(page.locator('.programa')).toHaveCount(8);
|
||||
await expect(page.getByRole('heading', { name: 'Licenciatura en Derecho' })).toBeVisible();
|
||||
await expect(page.locator('.programa', { hasText: 'Psicología' }).locator('.precio b')).toHaveText('$1,500');
|
||||
});
|
||||
|
||||
test('la vista de tabla trae las columnas del archivo de oferta', async ({ page }) => {
|
||||
await irA(page, 'oferta');
|
||||
await page.locator('[data-vista="tabla"]').click();
|
||||
const tabla = page.locator('.tabla').first();
|
||||
await expect(tabla.locator('tbody tr')).toHaveCount(8);
|
||||
|
||||
// Debajo de 780 px el encabezado se oculta y cada celda lleva su propia
|
||||
// etiqueta en data-col, así que se comprueba la estructura, no la vista.
|
||||
for (const col of ['Programa', 'Nivel', 'Duración', 'Inicia', 'Inscripción', 'Mensualidad', 'Horarios', 'Validez oficial']) {
|
||||
await expect(tabla.locator('thead th', { hasText: col })).toHaveCount(1);
|
||||
await expect(tabla.locator(`tbody td[data-col="${col}"]`).first()).toBeVisible();
|
||||
}
|
||||
await expect(tabla).toContainText('20211510'); // Derecho
|
||||
await expect(tabla).toContainText('$1,125'); // Bachillerato
|
||||
});
|
||||
|
||||
test('la elección entre tarjetas y tabla se recuerda al volver', async ({ page }) => {
|
||||
await irA(page, 'oferta');
|
||||
await page.locator('[data-vista="tabla"]').click();
|
||||
await expect(page.locator('.tabla').first()).toBeVisible();
|
||||
await irA(page, 'inicio');
|
||||
await irA(page, 'oferta');
|
||||
await expect(page.locator('[data-vista="tabla"]')).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('.programa')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('marca los programas sin RVOE confirmado y dice por qué', async ({ page }) => {
|
||||
await irA(page, 'oferta');
|
||||
await page.locator('[data-vista="tarjetas"]').click();
|
||||
const bach = page.locator('.programa', { hasText: 'Bachillerato General' });
|
||||
await expect(bach).toContainText(/Sin confirmar/i);
|
||||
await expect(bach).toContainText(/no lo cites/i);
|
||||
await expect(page.locator('.programa', { hasText: 'Trabajo Social' })).toContainText(/Por revisar/i);
|
||||
await expect(page.locator('.respuesta')).toContainText(/validez oficial sin confirmar/i);
|
||||
});
|
||||
|
||||
test('dirección edita un programa y el cambio se ve', async ({ page }) => {
|
||||
await irA(page, 'oferta');
|
||||
await page.locator('[data-vista="tarjetas"]').click();
|
||||
await page.locator('.programa', { hasText: 'Licenciatura en Derecho' }).locator('[data-editar-programa]').click();
|
||||
|
||||
const dlg = page.locator('dialog.editor');
|
||||
await expect(dlg).toBeVisible();
|
||||
await expect(dlg.locator('input[name="nombre"]')).toHaveValue('Licenciatura en Derecho');
|
||||
await dlg.locator('textarea[name="beneficios"]').fill('Beca del 25% y asesoría para titulación');
|
||||
await dlg.locator('[data-guardar]').click();
|
||||
|
||||
await expect(dlg).toHaveCount(0);
|
||||
await expect(page.locator('.programa', { hasText: 'Licenciatura en Derecho' })).toContainText('asesoría para titulación');
|
||||
|
||||
// Se deja como estaba.
|
||||
await page.locator('.programa', { hasText: 'Licenciatura en Derecho' }).locator('[data-editar-programa]').click();
|
||||
await page.locator('dialog.editor textarea[name="beneficios"]').fill('Beca del 25% en inscripción y mensualidad.');
|
||||
await page.locator('dialog.editor [data-guardar]').click();
|
||||
await expect(page.locator('dialog.editor')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('el editor rechaza un dato inválido y lo explica', async ({ page }) => {
|
||||
await irA(page, 'oferta');
|
||||
await page.locator('[data-vista="tarjetas"]').click();
|
||||
await page.locator('[data-editar-programa]').first().click();
|
||||
const dlg = page.locator('dialog.editor');
|
||||
await dlg.locator('input[name="nombre"]').fill('');
|
||||
await dlg.locator('[data-guardar]').click();
|
||||
await expect(dlg.locator('.editor__error')).toContainText(/Falta llenar/i);
|
||||
await expect(dlg).toBeVisible();
|
||||
await dlg.locator('[data-cerrar]').first().click();
|
||||
await expect(dlg).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dirección ve los datos pendientes de confirmar', async ({ page }) => {
|
||||
await irA(page, 'oferta');
|
||||
const panel = page.locator('.placa', { hasText: 'Datos pendientes de confirmar' });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toContainText(/RVOE del Bachillerato/i);
|
||||
});
|
||||
|
||||
test('el empleado no ve el panel de datos pendientes', async ({ page }) => {
|
||||
await page.request.post('/api/auth/salir');
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
await irA(page, 'oferta');
|
||||
await expect(page.locator('.placa', { hasText: 'Datos pendientes de confirmar' })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
test.describe('Links de clases', () => {
|
||||
test('se agrupan por programa y traen link para entrar y copiar', async ({ page }) => {
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
await irA(page, 'clases');
|
||||
await expect(page.locator('[data-bloque]')).toHaveCount(8);
|
||||
const psico = page.locator('[data-bloque]', { hasText: 'Licenciatura en Psicología' });
|
||||
await expect(psico).toContainText('Psicopatología');
|
||||
const fila = psico.locator('tbody tr', { hasText: 'Psicopatología' });
|
||||
await expect(fila.locator('.clase-enlace')).toHaveAttribute('href', /^https:\/\/meet\.google\.com\//);
|
||||
await expect(fila.locator('.clase-enlace')).toHaveAttribute('target', '_blank');
|
||||
await expect(fila).toContainText('Martes');
|
||||
});
|
||||
|
||||
test('el filtro por programa deja solo ese bloque', async ({ page }) => {
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
await irA(page, 'clases');
|
||||
await page.locator('#filtros-clases .filtro', { hasText: 'Derecho' }).first().click();
|
||||
const visibles = await page.locator('[data-bloque]:not([hidden])').count();
|
||||
expect(visibles).toBe(1);
|
||||
await page.locator('#filtros-clases .filtro', { hasText: 'Todos los programas' }).click();
|
||||
await expect(page.locator('[data-bloque]:not([hidden])')).toHaveCount(8);
|
||||
});
|
||||
|
||||
test('administración crea, edita y elimina una clase', async ({ page }, info) => {
|
||||
// Nombre único por ejecución: así el test no depende de que otro haya
|
||||
// limpiado, y un alta duplicada se vería de inmediato en el conteo.
|
||||
const materia = `Materia de prueba ${info.project.name} ${Date.now()}`;
|
||||
await entrar(page, ROLES.administrativo.email);
|
||||
await irA(page, 'clases');
|
||||
|
||||
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
|
||||
let dlg = page.locator('dialog.editor');
|
||||
await expect(dlg).toBeVisible();
|
||||
await dlg.locator('input[name="materia"]').fill(materia);
|
||||
await dlg.locator('input[name="enlace"]').fill('https://meet.google.com/prueba-auto-uno');
|
||||
await dlg.locator('select[name="dias"]').selectOption('Viernes');
|
||||
await dlg.locator('[data-guardar]').click();
|
||||
await expect(dlg).toHaveCount(0);
|
||||
|
||||
const fila = page.locator('tbody tr', { hasText: materia });
|
||||
await expect(fila, 'guardar una vez debe crear exactamente una clase').toHaveCount(1);
|
||||
|
||||
await fila.locator('[data-editar-clase]').click();
|
||||
dlg = page.locator('dialog.editor');
|
||||
await dlg.locator('input[name="docente"]').fill('Profa. Prueba');
|
||||
await dlg.locator('[data-guardar]').click();
|
||||
await expect(dlg).toHaveCount(0);
|
||||
await expect(page.locator('tbody tr', { hasText: materia })).toContainText('Profa. Prueba');
|
||||
|
||||
page.once('dialog', (d) => d.accept());
|
||||
await page.locator('tbody tr', { hasText: materia }).locator('[data-editar-clase]').click();
|
||||
await page.locator('dialog.editor [data-borrar]').click();
|
||||
await expect(page.locator('dialog.editor')).toHaveCount(0);
|
||||
await expect(page.locator('tbody tr', { hasText: materia })).toHaveCount(0);
|
||||
|
||||
// Y se confirma contra la API, no solo contra lo que se pintó.
|
||||
const { clases } = await (await page.request.get('/api/clases')).json();
|
||||
expect(clases.filter((c) => c.materia === materia)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rechaza un link que no es una dirección', async ({ page }) => {
|
||||
await entrar(page, ROLES.administrativo.email);
|
||||
await irA(page, 'clases');
|
||||
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
|
||||
const dlg = page.locator('dialog.editor');
|
||||
await dlg.locator('input[name="materia"]').fill('Prueba de link malo');
|
||||
await dlg.locator('input[name="enlace"]').fill('meet.google.com/sin-esquema');
|
||||
await dlg.locator('[data-guardar]').click();
|
||||
await expect(dlg.locator('.editor__error')).toContainText(/https:\/\//);
|
||||
await expect(dlg.locator('input[name="enlace"]')).toHaveAttribute('aria-invalid', 'true');
|
||||
await dlg.locator('[data-cerrar]').first().click();
|
||||
});
|
||||
|
||||
test('el empleado ve los links pero no puede editarlos', async ({ page }) => {
|
||||
await entrar(page, ROLES.empleado.email);
|
||||
await irA(page, 'clases');
|
||||
await expect(page.locator('.clase-enlace').first()).toBeVisible();
|
||||
await expect(page.locator('[data-agregar-clase]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-editar-clase]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
test.describe('Contenido', () => {
|
||||
test.beforeEach(async ({ page }) => { await entrar(page, ROLES.director.email); });
|
||||
|
||||
test('los requisitos distinguen las 6 fotos del bachillerato', async ({ page }) => {
|
||||
await irA(page, 'requisitos');
|
||||
await expect(page.locator('.placa', { hasText: 'Bachillerato' }).first()).toContainText('6 fotografías');
|
||||
await expect(page.locator('.placa', { hasText: 'Licenciatura' }).first()).toContainText('4 fotografías');
|
||||
await expect(page.getByText('Por experiencia profesional')).toBeVisible();
|
||||
});
|
||||
|
||||
test('dirección agrega y quita un requisito', async ({ page }) => {
|
||||
await irA(page, 'requisitos');
|
||||
await page.locator('.placa', { hasText: 'Maestría' }).locator('[data-agregar-requisito]').click();
|
||||
const dlg = page.locator('dialog.editor');
|
||||
await dlg.locator('input[name="texto"]').fill('Carta de motivos');
|
||||
await dlg.locator('[data-guardar]').click();
|
||||
await expect(dlg).toHaveCount(0);
|
||||
await expect(page.locator('.placa', { hasText: 'Maestría' })).toContainText('Carta de motivos');
|
||||
|
||||
page.once('dialog', (d) => d.accept());
|
||||
await page.locator('.horario', { hasText: 'Carta de motivos' }).locator('[data-editar-requisito]').click();
|
||||
await page.locator('dialog.editor [data-borrar]').click();
|
||||
await expect(page.locator('dialog.editor')).toHaveCount(0);
|
||||
await expect(page.locator('.lienzo__interno')).not.toContainText('Carta de motivos');
|
||||
});
|
||||
|
||||
test('becas explica que los referidos no se juntan y trae la escala', async ({ page }) => {
|
||||
await irA(page, 'becas');
|
||||
await expect(page.locator('.respuesta')).toContainText(/no se junta/i);
|
||||
await expect(page.locator('.tabla', { hasText: 'Referidos' }).locator('tbody tr').last()).toContainText('$0');
|
||||
await expect(page.locator('.placa', { hasText: 'Cuándo inicia cada programa' })).toContainText('Mayo 2026');
|
||||
await expect(page.locator('.placa', { hasText: 'Cuándo inicia cada programa' })).toContainText('Enero 2026');
|
||||
});
|
||||
|
||||
test('las preguntas frecuentes buscan de verdad', async ({ page }) => {
|
||||
await irA(page, 'preguntas');
|
||||
const total = await page.locator('#resultados details').count();
|
||||
expect(total).toBeGreaterThan(15);
|
||||
await page.fill('#buscar', 'referidos');
|
||||
await expect.poll(async () => page.locator('#resultados details').count()).toBeLessThan(total);
|
||||
await expect(page.locator('#resultados')).toContainText('20%');
|
||||
await page.fill('#buscar', 'zzzznoexiste');
|
||||
await expect(page.locator('.vacio')).toBeVisible();
|
||||
});
|
||||
|
||||
test('los mensajes se filtran por categoría', async ({ page }) => {
|
||||
await irA(page, 'mensajes');
|
||||
const total = await page.locator('#lista > .placa').count();
|
||||
expect(total).toBeGreaterThan(15);
|
||||
await page.locator('.filtro', { hasText: 'Objeciones' }).click();
|
||||
const visibles = await page.locator('#lista > .placa:not([hidden])').count();
|
||||
expect(visibles).toBeGreaterThan(0);
|
||||
expect(visibles).toBeLessThan(total);
|
||||
});
|
||||
|
||||
test('los datos del instituto traen la dirección correcta', async ({ page }) => {
|
||||
await irA(page, 'contacto');
|
||||
await expect(page.locator('.respuesta')).toContainText('1409-d');
|
||||
await expect(page.locator('.respuesta')).toContainText('58110');
|
||||
await expect(page.locator('.lienzo__interno')).toContainText(/Trabajar y estudiar es posible/);
|
||||
await expect(page.locator('.lienzo__interno')).toContainText('443 356 0979');
|
||||
});
|
||||
|
||||
test('dirección edita un dato del instituto', async ({ page }) => {
|
||||
await irA(page, 'contacto');
|
||||
const editar = page.getByRole('button', { name: 'Editar Horario de atención' });
|
||||
await editar.click();
|
||||
|
||||
const dlg = page.locator('dialog.editor');
|
||||
await expect(dlg).toBeVisible();
|
||||
const antes = await dlg.locator('textarea[name="valor"]').inputValue();
|
||||
expect(antes).toContain('10:00');
|
||||
await dlg.locator('textarea[name="valor"]').fill(`${antes} · verificado`);
|
||||
await dlg.locator('[data-guardar]').click();
|
||||
await expect(dlg).toHaveCount(0);
|
||||
await expect(page.locator('.lienzo__interno')).toContainText('· verificado');
|
||||
|
||||
// Se deja como estaba.
|
||||
await page.getByRole('button', { name: 'Editar Horario de atención' }).click();
|
||||
await page.locator('dialog.editor textarea[name="valor"]').fill(antes);
|
||||
await page.locator('dialog.editor [data-guardar]').click();
|
||||
await expect(page.locator('dialog.editor')).toHaveCount(0);
|
||||
await expect(page.locator('.lienzo__interno')).not.toContainText('· verificado');
|
||||
});
|
||||
|
||||
test('editar dos veces seguidas no abre dos ventanas', async ({ page }) => {
|
||||
await irA(page, 'contacto');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.getByRole('button', { name: 'Editar WhatsApp' }).click();
|
||||
await expect(page.locator('dialog.editor')).toHaveCount(1);
|
||||
await page.locator('dialog.editor [data-guardar]').click();
|
||||
await expect(page.locator('dialog.editor')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('el equipo muestra la matriz de quién ve qué', async ({ page }) => {
|
||||
await irA(page, 'equipo');
|
||||
await expect(page.locator('.matriz')).toBeVisible();
|
||||
const filaBitacora = page.locator('.matriz tbody tr', { hasText: 'Bitácora' });
|
||||
await expect(filaBitacora.locator('td').nth(1)).toContainText('●'); // dirección
|
||||
await expect(filaBitacora.locator('td').nth(2)).toContainText('○'); // administración
|
||||
await expect(filaBitacora.locator('td').nth(3)).toContainText('○'); // empleado
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
test.describe('Legibilidad y responsividad', () => {
|
||||
test('el texto base no baja de 17 px', async ({ page }) => {
|
||||
await entrar(page, ROLES.director.email);
|
||||
const tam = await page.evaluate(() => parseFloat(getComputedStyle(document.body).fontSize));
|
||||
expect(tam).toBeGreaterThanOrEqual(17);
|
||||
});
|
||||
|
||||
test('el portal siempre usa tema claro, incluso si el sistema pide oscuro', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await entrar(page, ROLES.director.email);
|
||||
const fondo = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||
expect(fondo).toBe('rgb(250, 251, 252)');
|
||||
const texto = await page.evaluate(() => getComputedStyle(document.body).color);
|
||||
expect(texto).toBe('rgb(20, 24, 29)');
|
||||
});
|
||||
|
||||
test('los botones y campos llegan al alto mínimo para tocarse', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await entrar(page, ROLES.director.email);
|
||||
await irA(page, 'clases');
|
||||
for (const sel of ['.nav-item', '.boton', '.clase-enlace']) {
|
||||
const caja = await page.locator(sel).first().boundingBox();
|
||||
expect(caja.height, `${sel} es demasiado bajo para tocarse`).toBeGreaterThanOrEqual(38);
|
||||
}
|
||||
});
|
||||
|
||||
for (const ancho of [390, 768, 1024, 1440]) {
|
||||
test(`ninguna sección se desborda a ${ancho}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: ancho, height: 900 });
|
||||
await entrar(page, ROLES.director.email);
|
||||
for (const s of SECCIONES) {
|
||||
await irA(page, s);
|
||||
const desborde = await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(desborde, `${s} se desborda horizontalmente`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('la ventana de edición cabe en un teléfono', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await entrar(page, ROLES.director.email);
|
||||
await irA(page, 'clases');
|
||||
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
|
||||
const dlg = page.locator('dialog.editor');
|
||||
await expect(dlg).toBeVisible();
|
||||
const caja = await dlg.boundingBox();
|
||||
expect(caja.width).toBeLessThanOrEqual(390);
|
||||
expect(caja.x).toBeGreaterThanOrEqual(0);
|
||||
await dlg.locator('[data-cerrar]').first().click();
|
||||
await expect(dlg).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('en móvil el menú es un cajón que abre, navega y cierra', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await entrar(page, ROLES.director.email);
|
||||
await expect(page.locator('.tope')).toBeVisible();
|
||||
const barra = page.locator('#barra');
|
||||
await page.click('#abrir-menu');
|
||||
await expect(barra).toHaveAttribute('data-abierta', '1');
|
||||
await page.locator('.nav-item[data-seccion="oferta"]').click();
|
||||
await expect(page.locator('.cabecera-seccion h1')).toHaveText('Oferta educativa');
|
||||
await expect(barra).toHaveAttribute('data-abierta', '0');
|
||||
});
|
||||
|
||||
test('la sección activa se anuncia con aria-current', async ({ page }) => {
|
||||
await entrar(page, ROLES.director.email);
|
||||
await irA(page, 'clases');
|
||||
await expect(page.locator('.nav-item[data-seccion="clases"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.nav-item[aria-current="page"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('se puede navegar con el teclado desde la entrada', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
const enfocado = await page.evaluate(() => document.activeElement?.className ?? '');
|
||||
expect(enfocado).toContain('tarjeta-rol');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/portal');
|
||||
});
|
||||
|
||||
test('Escape cierra la ventana de edición sin guardar', async ({ page }) => {
|
||||
await entrar(page, ROLES.director.email);
|
||||
await irA(page, 'clases');
|
||||
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
|
||||
await expect(page.locator('dialog.editor')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog.editor')).toHaveCount(0);
|
||||
await expect(page.locator('#barra')).not.toHaveAttribute('data-abierta', '1');
|
||||
});
|
||||
|
||||
test('en teléfono los bloques largos llegan plegados y en escritorio abiertos', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await entrar(page, ROLES.director.email);
|
||||
await page.goto('/portal#/clases');
|
||||
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
|
||||
|
||||
const plegables = page.locator('details.plegable');
|
||||
await expect(plegables.first()).toBeVisible();
|
||||
expect(await plegables.first().evaluate((d) => d.open)).toBe(false);
|
||||
// El resumen sí se ve y al pulsarlo se abre.
|
||||
await plegables.first().locator('summary').click();
|
||||
expect(await plegables.first().evaluate((d) => d.open)).toBe(true);
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/portal#/clases');
|
||||
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
|
||||
expect(await page.locator('details.plegable').first().evaluate((d) => d.open)).toBe(true);
|
||||
await expect(page.locator('details.plegable summary').first()).toBeHidden();
|
||||
});
|
||||
|
||||
test('ninguna sección se vuelve un scroll interminable en teléfono', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await entrar(page, ROLES.director.email);
|
||||
for (const s of SECCIONES) {
|
||||
await page.goto(`/portal#/${s}`);
|
||||
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
|
||||
await expect(page.locator('.cargando')).toHaveCount(0);
|
||||
const alto = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
expect(alto, `${s} mide ${alto} px de alto en teléfono`).toBeLessThan(12000);
|
||||
}
|
||||
});
|
||||
|
||||
test('el logo del instituto se carga', async ({ page }) => {
|
||||
await entrar(page, ROLES.director.email);
|
||||
const ok = await page.locator('.barra__cabeza img').evaluate((img) => img.complete && img.naturalWidth > 0);
|
||||
expect(ok, 'el logo no se cargó').toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user