Contro EscolarDocumentación
⌘K
LÉEME

Contro Escolar

Cómo arrancar el sistema y qué contiene el repositorio

Sistema de control escolar para instituciones mexicanas. Alumnos, grupos, docentes, materias, calificaciones, asistencia y boletas — con portales separados para administración, docentes y familias.

Funciona igual como servicio en la nube (school.atomtech.mx) o instalado en el servidor de la escuela, para que la información de los alumnos no salga de su red. Es el mismo código: la diferencia es una variable de entorno.

AtomTech · versión 1.0


Arrancar en 5 minutos

Necesitas Docker y Node.js 20+.

# 1. Dependencias
npm install

# 2. Configuración
cp .env.example .env

# 3. Base de datos
docker compose up -d

# 4. Esquema y datos de demostración
npm run setup

# 5. Arrancar
npm run dev

Abre http://localhost:3000.

Cuentas de demostración

Rol Usuario Contraseña
Administración admin@colegio.mx Contro2026!
Control escolar control@colegio.mx Contro2026!
Dirección direccion.secundaria@colegio.mx Contro2026!
Docente laura.ramirez@colegio.mx Docente2026!
Tutor tutor@correo.mx Tutor2026!
Alumno alumno@colegio.mx Alumno2026!

Los datos de demostración incluyen dos planteles (primaria y secundaria), 18 grupos, 408 alumnos con sus tutores, 8 docentes, calificaciones de dos trimestres y cuatro semanas de asistencia.

Qué probar primero

  1. Entra como docente → abre una materia → captura calificaciones → publica
  2. Entra como tutor → mira la boleta de su hijo
  3. Entra como control escolar → Boletas → imprime un grupo completo
  4. Cambia entre plantel Primaria y Secundaria en la barra superior
  5. Prueba el tema oscuro (el icono ◐ arriba a la derecha)

Documentación

Los documentos están en docs/ en Markdown. Para leerlos con formato, navegación e índice:

npm run docs:serve

O genera el sitio estático y ábrelo con doble clic:

npm run docs:build
open docs/site/index.html
Documento De qué trata
Visión y PRD El problema, a quién servimos, alcance y métricas
Arquitectura Cómo está construido y por qué así
Modelo de datos Entidades, relaciones y convenciones
Multi-institución Cómo conviven varias escuelas sin verse
Roles y permisos Quién puede hacer qué
Modelo académico Cómo se calculan las calificaciones
Sistema de diseño Tokens, componentes, accesibilidad
Instalación local Servidor de la escuela, paso a paso
Despliegue SaaS school.atomtech.mx
Seguridad y privacidad Datos de menores, LFPDPPP, pendientes
Interfaces Endpoints e integraciones
Móvil y app nativa Estado actual y camino a seguir
Roadmap Qué sigue y en qué orden
Operación Alta de escuelas, soporte, calendario del ciclo
Subir la landing Publicar la portada en el hosting actual
Publicar la aplicación Poner el sistema completo en un VPS
Preguntas y siguientes pasos Lo que hace falta decidir

Comandos

Desarrollo

npm run dev              # servidor de desarrollo
npm run build            # compilación de producción
npm run start            # servir la compilación
npm run typecheck        # verificar tipos

Base de datos

npm run db:studio        # explorador visual de datos
npm run db:migrate       # crear y aplicar una migración
npm run db:deploy        # aplicar migraciones (producción)
npm run db:seed          # datos de demostración
npm run db:reset         # borrar todo y volver a sembrar

Pruebas

npm test                 # calificaciones (19) + integridad de datos (11)
npm run test:humo        # rutas y permisos (61) + contenido (37), con el servidor arriba

Operación

npm run instalar         # dar de alta una institución real (interactivo)
npm run respaldar        # respaldo manual de la base de datos
npm run landing          # generar la landing estática para subir por FTP
npm run docs:build       # generar el sitio de documentación

Instalar en producción

En el servidor de la escuela

cp .env.onprem.example .env
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
echo "AUTH_SECRET=$(openssl rand -base64 48)" >> .env

docker compose -f docker-compose.onprem.yml up -d --build
docker compose -f docker-compose.onprem.yml exec app npx prisma migrate deploy
docker compose -f docker-compose.onprem.yml exec app npm run instalar

Guía completa: Instalación local.

En la nube (SaaS)

cp .env.saas.example .env
# … generar secretos y ajustar ROOT_DOMAIN

docker compose -f docker-compose.saas.yml up -d --build
docker compose -f docker-compose.saas.yml exec app npx prisma migrate deploy
docker compose -f docker-compose.saas.yml exec app npm run instalar

Guía completa: Despliegue SaaS.


Construido con

Next.js 15 · React 19 App Router con Server Components y Server Actions
TypeScript 5.9 Estricto
PostgreSQL 16 · Prisma 6 Datos y migraciones
Tailwind CSS 4 Tokens semánticos para claro/oscuro
Zod 4 Validación de toda entrada
jose · bcryptjs Sesiones y contraseñas
Docker · Caddy Despliegue y TLS automático

Sin fuentes remotas, sin CDNs, sin servicios externos obligatorios: el sistema compila y corre en un servidor sin salida a internet.


Estructura

src/
├── app/            rutas: panel, docente, portal, api
├── components/     interfaz: primitivas y componentes de dominio
├── lib/            lógica: sesión, permisos, calificaciones, formato
└── middleware.ts   guarda de borde

prisma/             esquema, migraciones y datos de demostración
scripts/            instalación, respaldo y pruebas
deploy/             proxy y respaldos automáticos
docs/               documentación + generador del sitio HTML

Estado

Funcional y verificado. Compilación limpia de 40 rutas y 128 pruebas que pasan: 19 de la aritmética de calificaciones, 11 de integridad de datos, 61 de rutas y permisos, y 37 de contenido renderizado. La imagen Docker de producción compila (661 MB).

Lo que falta antes de cobrarle a un cliente está listado en Roadmap §2 — son unas dos semanas de trabajo, sobre todo de seguridad y documentación de usuario.

Las decisiones que tomé por mi cuenta y las preguntas que necesito responder están en Preguntas y siguientes pasos.

Los 15 documentos

Ordenados por a quién le sirven. El último recoge lo que hace falta decidir.

00PRD — Contro Escolar Documento de producto. Qué construimos, para quién y por qué 01Arquitectura Cómo está construido el sistema y por qué así 02Modelo de datos Las entidades del sistema, cómo se relacionan y por qué están así 03Multi-institución (multi-tenancy) Cómo conviven varias escuelas en una instancia, y cómo se garantiza que no se vean entre sí 04Roles y permisos Quién puede hacer qué, y dónde se cambia. Código: src/lib/rbac.ts 05Modelo académico Cómo se calculan las calificaciones y cómo funciona el ciclo de captura 06Sistema de diseño Cómo se ve y por qué. Código: src/app/globals.css, src/components/ui/primitives.tsx 07Instalación en el servidor de la escuela (on-premise) Todo dentro de la red de la escuela. La información de los alumnos no sale a internet 08Despliegue SaaS — school.atomtech.mx Cómo se pone en producción el servicio multi-institución y cómo se opera 09Seguridad y privacidad El sistema guarda datos personales de menores de edad 10Interfaces del sistema Endpoints HTTP disponibles y cómo se integraría con otros sistemas 11Móvil y app nativa Cómo funciona hoy en el celular y cuál es el camino a una app nativa 12Roadmap Qué sigue, en qué orden y por qué. Estado a agosto de 2026 13Operación Procedimientos para el día a día: alta de escuelas, soporte, incidencias y ciclo escolar 14Preguntas abiertas y siguientes pasos Lo que hace falta decidir contigo antes de seguir, y en qué orden avanzar 15Publicar en school.atomtech.mx Pasos concretos para poner Contro Escolar en línea sin tocar el sitio actual de Atom Tech 16Subir la landing a school.atomtech.mx Cómo crear el subdominio y publicar la portada comercial en el hosting que ya tienes
DOCUMENTO 00

PRD — Contro Escolar

Documento de producto. Qué construimos, para quién y por qué


1. Resumen

Contro Escolar es un sistema de control escolar para instituciones de educación básica y media en México. Administra alumnos, grupos, docentes, materias, calificaciones, asistencia y boletas, con portales separados para personal administrativo, docentes y familias.

Se ofrece en dos modalidades con el mismo código:

SaaS On-premise
Dónde viven los datos Infraestructura de AtomTech Servidor de la escuela
Acceso colegio.school.atomtech.mx Red local o dominio propio
Actualizaciones Automáticas Cuando la escuela decide
Consulta de familias fuera de la escuela Sí Solo si se publica el servidor
Costo Suscripción mensual Licencia + soporte

La decisión entre una y otra es una variable de entorno, no una versión distinta del producto. Una escuela puede empezar local y migrar a la nube (o al revés) sin cambiar de sistema.


2. El problema

Lo que reportan las escuelas privadas medianas en México (5 a 40 grupos):

  1. Las calificaciones viven en Excel. Cada docente tiene su archivo. Control escolar consolida a mano al final de cada periodo. Los errores de captura se descubren cuando un padre reclama.
  2. La boleta se arma dos veces. Una en el sistema oficial (SIGED / control escolar estatal) y otra "bonita" para entregar a los padres.
  3. Los expedientes están en papel. Cuando se necesita el teléfono de un tutor durante una emergencia, alguien corre al archivero.
  4. Los padres preguntan por WhatsApp. No hay dónde consultar el avance de su hijo, así que preguntan a la maestra por mensaje directo, a cualquier hora.
  5. Los sistemas existentes son caros o pesados. Las plataformas internacionales cuestan por alumno en dólares y traen módulos que la escuela no usará; las nacionales suelen tener interfaces de hace quince años.
  6. Hay escuelas que no quieren su información en la nube. Sea por política interna, por desconfianza o porque su internet es malo.

3. A quién servimos

Usuarios primarios

Rol Quién es Qué necesita
Control escolar Secretaria / servicios escolares Inscribir, mantener expedientes, imprimir boletas, generar constancias
Dirección Director del plantel Ver el avance de captura, detectar alumnos en riesgo, comunicar
Docente Profesor de grupo o materia Capturar calificaciones y asistencia rápido, desde el celular si hace falta
Familia Padre, madre o tutor Ver calificaciones, faltas y avisos sin preguntar a nadie
Alumno Estudiante de secundaria en adelante Consultar su propio avance

Comprador

El director general o dueño de la institución. Le importa: que el personal lo use sin capacitación larga, que las boletas salgan bien, y que no se le caiga en la entrega de calificaciones.

Perfil de institución objetivo

  • Escuela privada de 100 a 1,500 alumnos.
  • Uno o varios niveles (preescolar + primaria + secundaria es el caso típico).
  • Personal administrativo de 2 a 10 personas.
  • Sin equipo de sistemas propio.

4. Principios de producto

  1. Cada pantalla tiene un dueño claro. Si un docente entra al sistema, ve sus grupos, no un menú de veinte opciones que no le tocan.
  2. La captura es el camino crítico. Todo lo demás puede tomar tres clics; la captura de calificaciones no. Se optimiza esa pantalla por encima de todas.
  3. Nunca perder una calificación. Toda modificación queda en bitácora con autor, fecha, valor anterior y valor nuevo.
  4. Vacío no es cero. Una actividad sin capturar no reprueba a nadie. Es el error más común de los sistemas escolares y el que más pleitos causa.
  5. Funciona sin internet perfecto. Sin fuentes remotas, sin CDNs, sin dependencias externas en tiempo de ejecución.
  6. Español mexicano, no traducciones. "Boleta", "grupo", "control escolar", "CCT", "tutor". El vocabulario que ya usan.
  7. Se puede imprimir. En México la boleta física sigue existiendo y se firma.

5. Alcance

Incluido en la versión 1.0 (construido y funcionando)

Estructura institucional

  • Institución multi-plantel (preescolar, primaria, secundaria, bachillerato…)
  • Reglas de calificación por plantel: escala, mínima aprobatoria, decimales, redondeo a enteros
  • Ciclos escolares con periodos de evaluación (trimestres, bimestres, parciales)
  • Estados de periodo: planeado → abierto → en revisión → cerrado

Comunidad

  • Alumnos con expediente completo (datos personales, salud, contacto de emergencia)
  • Tutores vinculados, con relación de parentesco y responsable de pago
  • Docentes con adscripción a planteles
  • Importación masiva de alumnos desde CSV/Excel con vista previa y validación
  • Matrículas generadas automáticamente (PRIM-2026-0042)
  • Validación de CURP

Académico

  • Grados y grupos por ciclo, con docente titular y cupo
  • Catálogo de materias con plan de estudios por grado
  • Asignaciones (materia × grupo × docente)
  • Criterios de evaluación con pesos ("Examen 40%, Tareas 35%, Participación 25%")
  • Actividades evaluables con puntaje
  • Cálculo automático de la calificación de periodo, con ajuste manual auditado
  • Publicación controlada: el docente decide cuándo lo ven las familias
  • Asistencia con pase de lista diario (presente / retardo / falta / justificada)
  • Boletas imprimibles individuales y por grupo completo

Portales

  • Panel administrativo (dirección y control escolar)
  • Portal docente (captura y asistencia)
  • Portal de familias (calificaciones, asistencia, boleta, avisos)

Sistema

  • Autenticación con roles y permisos granulares
  • Contraseñas temporales imprimibles (para escuelas sin correo configurado)
  • Cambio obligatorio de contraseña en el primer acceso
  • Bitácora de auditoría de todo el sistema
  • Avisos segmentados por audiencia y grupo
  • Tema claro y oscuro
  • Diseño responsivo (funciona en celular)
  • Respaldos automáticos diarios

Fuera de alcance en la 1.0 (roadmap)

Ver Roadmap para el detalle y la justificación.

  • Colegiaturas y facturación CFDI
  • Horarios y control de aulas
  • Mensajería bidireccional escuela↔familia
  • App nativa iOS/Android
  • Exportación al formato oficial SIGED
  • Biblioteca, transporte, cafetería
  • Reportes analíticos avanzados

6. Recorridos principales

R1 · Arranque de ciclo (control escolar)

  1. Crea el ciclo escolar y sus periodos → se generan solos repartiendo el año
  2. Crea los grupos del ciclo → se generan sus materias desde el plan de estudios
  3. Importa la lista de alumnos desde Excel → vista previa, valida, confirma
  4. Asigna docente a cada materia → detecta y señala las que quedan sin docente

Meta: un ciclo completo listo en menos de una hora.

R2 · Captura de calificaciones (docente)

  1. Entra y ve sus materias con el avance de captura de cada una
  2. Abre una materia → define criterios una vez, luego crea actividades
  3. Captura la hoja completa en una tabla; Tab avanza entre celdas
  4. Guarda → el sistema calcula la calificación de periodo de cada alumno
  5. Revisa, ajusta lo que haga falta, publica a familias

Meta: capturar un grupo de 30 alumnos en menos de cinco minutos.

R3 · Consulta familiar (tutor)

  1. Entra con el correo que le dio la escuela
  2. Ve el promedio, las materias, las faltas y los avisos de su hijo
  3. Si tiene varios hijos, cambia entre ellos con un toque
  4. Descarga o imprime la boleta

Meta: cero llamadas a la escuela para preguntar calificaciones.

R4 · Cierre de periodo (dirección)

  1. Ve el porcentaje de captura global y por materia
  2. Cierra la captura → el periodo pasa a "en revisión"
  3. Corrige lo que falte con control escolar
  4. Imprime las boletas del grupo completo, una por hoja
  5. Cierra el periodo → queda congelado y auditable

7. Métricas de éxito

Métrica Objetivo Cómo se mide
Tiempo de captura por grupo < 5 min Bitácora: primera y última captura de una asignación
Adopción docente > 90% de los docentes capturan en el sistema Docentes con capturas / docentes activos
Adopción familiar > 60% de tutores entran al menos una vez por periodo User.ultimoAcceso de rol TUTOR
Captura completa al cierre > 98% Calificaciones capturadas / esperadas
Correcciones post-cierre < 2% Eventos calificacion.ajustar después de cerrar
Tiempo de instalación de una escuela nueva < 1 hora Del npm run instalar al primer alumno inscrito

8. Decisiones de producto y su razón

Decisión Razón
Un solo código para SaaS y on-premise Mantener dos productos duplica el costo y garantiza que uno se quede atrás
Multi-tenant por fila (tenantId), no por base de datos Cientos de escuelas pequeñas en una instancia; migrar el esquema en 300 bases es inviable sin equipo dedicado
Calificación calculada y ajustable El docente tiene la última palabra; el sistema propone, no impone. El ajuste queda auditado
Vacío ≠ cero Evita el problema más común: alumnos "reprobados" a mitad de periodo porque el examen final no se ha aplicado
Publicación explícita a familias El docente necesita capturar en borrador sin que un padre vea un 5 provisional
Contraseñas temporales imprimibles Muchas escuelas no tienen correo institucional para todos; la secretaria imprime y entrega
PDF vía impresión del navegador Evita una dependencia pesada de renderizado; "Guardar como PDF" ya está en ese diálogo y funciona igual sin internet
Sin fuentes ni recursos remotos Un servidor local en una escuela puede no tener salida a internet
Escala de calificación configurable por plantel Una institución puede tener primaria con 5–10 enteros y preparatoria con 0–100

9. Riesgos

Riesgo Impacto Mitigación
El docente sigue usando su Excel Alto — sin captura no hay sistema La hoja de captura imita una tabla de Excel; se puede capturar solo la calificación final sin desglose
Fallo el día de entrega de boletas Muy alto — reputacional Respaldos diarios, sonda de salud, boletas imprimibles desde cualquier navegador
La escuela no tiene quién administre el servidor local Medio Instalación en un solo comando, respaldo automático, y contrato de soporte remoto
Cambios normativos SEP en la boleta Medio La boleta es una plantilla; la escala y los periodos ya son configurables
Fuga entre instituciones en SaaS Muy alto — legal tenantId obligatorio en toda consulta, verificación cruzada subdominio↔sesión, y RLS como endurecimiento documentado

10. Modelo comercial sugerido

Cifras de referencia, no comprometidas. Ver Operación.

Plan Alumnos Precio orientativo Incluye
Esencial hasta 200 $1,900 MXN/mes Un plantel, todos los módulos, soporte por correo
Profesional hasta 600 $3,900 MXN/mes Multi-plantel, soporte prioritario, capacitación inicial
Institucional 600+ Cotización Dominio propio, integraciones, acompañamiento
Servidor propio sin límite Licencia anual + implementación Instalación local, actualizaciones, soporte remoto

Ventaja competitiva frente a las plataformas internacionales: precio en pesos, vocabulario mexicano, y la opción on-premise que ninguna de ellas ofrece.


11. Preguntas abiertas

Están recogidas en Preguntas y siguientes pasos.

DOCUMENTO 01

Arquitectura

Cómo está construido el sistema y por qué así


1. Panorama

                    ┌──────────────────────────────────┐
  Navegador ──────▶ │  Caddy (proxy + TLS automático)  │
  (web / móvil)     └────────────────┬─────────────────┘
                                     │
                    ┌────────────────▼─────────────────┐
                    │      Next.js 15 (App Router)     │
                    │                                  │
                    │  ├─ middleware   guarda de borde │
                    │  ├─ RSC          páginas         │
                    │  ├─ Server       mutaciones      │
                    │  │  Actions                      │
                    │  └─ /api         salud, auth, tls│
                    └────────────────┬─────────────────┘
                                     │ Prisma
                    ┌────────────────▼─────────────────┐
                    │         PostgreSQL 16            │
                    └────────────────┬─────────────────┘
                                     │ pg_dump diario
                    ┌────────────────▼─────────────────┐
                    │        ./backups (30 días)       │
                    └──────────────────────────────────┘

Cuatro contenedores, una imagen de aplicación, cero servicios externos obligatorios. Es lo que permite que la misma cosa corra en un VPS de AtomTech y en la torre que la escuela tiene bajo el escritorio de la dirección.


2. Decisiones y sus porqués

Next.js con App Router y Server Components

Por qué: la mayoría de las pantallas son listas y formularios que leen de la base de datos. Con Server Components esos datos se consultan en el servidor y llegan ya renderizados: no hay endpoints REST que mantener, ni estado de carga que sincronizar en el cliente, ni un cliente HTTP con su capa de tipos.

Consecuencia: el JavaScript que baja al navegador es pequeño (~107 kB en la mayoría de rutas). Importa porque muchas escuelas trabajan con equipos viejos y conexiones lentas.

Lo que se paga: las mutaciones van por Server Actions, que son un protocolo propio de Next. Se documenta en API cómo hacerlas accesibles desde fuera (la app nativa usará /api/auth/login + endpoints REST específicos).

PostgreSQL y no SQLite

Por qué: aunque una escuela sola cabría en SQLite, el modo SaaS necesita concurrencia real, y queremos que ambos modos usen exactamente el mismo motor. Un bug que solo aparece en producción porque desarrollo usa otro motor es un costo que no vale la pena.

Prisma como capa de datos

Por qué: el esquema es el documento de referencia del dominio y genera los tipos de TypeScript. Cuando alguien agrega un campo al alumno, el compilador señala todos los lugares que hay que tocar.

Lo que se paga: consultas muy complejas requieren SQL crudo. Hasta ahora ninguna lo ha necesitado.

Por qué: sin tabla de sesiones que limpiar, sin Redis, y cualquier réplica de la app atiende cualquier petición sin sesión pegajosa. En on-premise significa un servicio menos que puede fallar.

Lo que se paga: no se puede revocar una sesión individual antes de que expire. Se mitiga con sesiones cortas (12 h por defecto) y verificando en cada carga de página que el usuario siga activo — getActiveContext consulta el estado del usuario en cada petición, así que desactivar una cuenta la expulsa en el siguiente clic.

Tailwind v4 con tokens semánticos

Por qué: el tema oscuro se define una vez, en un bloque de variables CSS. Los componentes usan bg-superficie y text-suave, nunca bg-white dark:bg-slate-900. Agregar un tercer tema (alto contraste, por ejemplo) sería otro bloque de variables, no tocar 60 archivos.

Sin fuentes ni recursos remotos

Por qué: un servidor escolar puede no tener salida a internet. Todo — tipografía del sistema, iconos SVG en el bundle, CSS inline — viaja en la imagen. También hace que la compilación sea reproducible sin red.


3. Estructura del código

src/
├── middleware.ts              guarda de borde: sesión válida o /login
│
├── app/
│   ├── layout.tsx             tema, metadatos, script anti-parpadeo
│   ├── page.tsx               enruta a cada rol según su portada
│   ├── login/                 acceso
│   ├── cuenta/password/       cambio obligatorio de contraseña
│   │
│   ├── panel/                 ← dirección y control escolar
│   │   ├── layout.tsx         AppShell + verificación de rol
│   │   ├── alumnos/           listado, alta, ficha, importación CSV
│   │   ├── docentes/          listado, alta, ficha, accesos
│   │   ├── grupos/            listado, alta, detalle con lista e inscripción
│   │   ├── materias/          catálogo y plan de estudios
│   │   ├── grados/            catálogo de grados
│   │   ├── asignaciones/      materia × grupo × docente
│   │   ├── calificaciones/    avance y hoja de captura
│   │   ├── asistencia/        pase de lista
│   │   ├── boletas/           individual y por grupo
│   │   ├── avisos/            comunicación
│   │   ├── planteles/         planteles y reglas de calificación
│   │   ├── ciclos/            ciclos y periodos de evaluación
│   │   ├── usuarios/          cuentas y accesos
│   │   ├── bitacora/          auditoría
│   │   └── configuracion/     institución e identidad visual
│   │
│   ├── docente/               ← portal docente
│   ├── portal/                ← portal de familias
│   ├── actions/contexto.ts    cambio de plantel/ciclo, cerrar sesión
│   └── api/
│       ├── salud/             sonda para proxy y monitoreo
│       ├── auth/login/        autenticación JSON (app nativa)
│       └── tls/permitido/     autorización de certificados on-demand
│
├── components/
│   ├── ui/                    primitivas: Card, Button, Field, Table, Badge…
│   ├── app-shell.tsx          navegación, selectores, menú de cuenta
│   ├── libreta.tsx            hoja de captura de calificaciones
│   ├── pase-de-lista.tsx      captura de asistencia
│   ├── boleta-vista.tsx       boleta imprimible
│   ├── pagina-captura.tsx     página de captura (panel + docente)
│   └── pagina-asistencia.tsx  página de asistencia (panel + docente)
│
└── lib/
    ├── env.ts                 modo de despliegue y configuración
    ├── prisma.ts              cliente único por proceso
    ├── session.ts             sesión, tenant activo, guardas
    ├── auth.ts                login, hash, contraseñas temporales
    ├── rbac.ts                matriz de permisos por rol
    ├── grades.ts              TODA la aritmética de calificaciones
    ├── academico.ts           consultas de libreta y boleta
    ├── portal.ts              qué alumnos ve una familia
    ├── audit.ts               bitácora
    ├── form.ts                validación y errores de formulario
    ├── csv.ts                 lectura y escritura de CSV
    ├── format.ts              fechas, nombres y etiquetas en español
    └── cn.ts                  unión de clases

La regla de oro del código

Toda consulta lleva tenantId. Sin excepciones. Las páginas obtienen el tenant de getActiveContext(); los server actions, de requireSession(). Un prisma.student.findMany({ where: { schoolId } }) sin tenantId es un bug de seguridad aunque funcione.


4. Flujo de una petición

Lectura de una página protegida

GET /panel/alumnos
  │
  ├─ middleware        ¿hay cookie de sesión y es válida?  → si no, /login
  │
  ├─ panel/layout.tsx  getActiveContext()
  │                      ├─ verifica que el usuario siga activo
  │                      ├─ si debe cambiar contraseña → /cuenta/password
  │                      ├─ resuelve el tenant por subdominio
  │                      ├─ corta si la sesión es de otra institución
  │                      └─ resuelve plantel y ciclo activos (cookies)
  │                    verifica que el rol pertenezca al panel
  │
  ├─ page.tsx          consulta con tenantId + schoolId + filtros
  │
  └─ HTML renderizado en el servidor

Mutación (server action)

POST (server action) guardarPuntajes
  │
  ├─ autorizarSeccion()
  │    ├─ requireSession()
  │    ├─ ¿la asignación es de este tenant?
  │    ├─ ¿el usuario es el docente de esa asignación, o es staff?
  │    └─ ¿el periodo acepta captura ahora mismo?
  │
  ├─ valida con Zod
  ├─ escribe en transacción
  ├─ recalcula las calificaciones de periodo
  ├─ registra en bitácora
  └─ revalidatePath()

Nótese que la autorización se resuelve en el servidor a partir de la sesión, nunca a partir de un campo del formulario. Los ids que sí vienen del formulario (courseSectionId) se verifican contra el tenant antes de usarse.


5. El cálculo de calificaciones

Vive completo en src/lib/grades.ts, en funciones puras sin acceso a base de datos. Está así a propósito: cuando una escuela cuestione una calificación, queremos poder abrir un archivo y explicar el resultado, además de tener pruebas automatizadas sobre él (npm run test).

Las reglas, en orden:

  1. Cada criterio ("Examen 40%") vale su peso.
  2. Dentro del criterio, el logro es puntos obtenidos / puntos posibles de las actividades capturadas.
  3. Una actividad sin capturar no cuenta ni a favor ni en contra.
  4. NE (no entregó) sí cuenta como cero sobre los puntos máximos.
  5. Si un criterio no tiene nada capturado, su peso se reparte entre los demás (re-normalización sobre el peso efectivo).
  6. Las actividades sin criterio se agrupan con el peso restante hasta 100%.
  7. El porcentaje resultante se mapea a la escala del plantel: escalaMin + (porcentaje/100) × (escalaMax − escalaMin).
  8. Se redondea según la configuración del plantel y se recorta al rango.

La regla 3 es la que evita el problema clásico: un alumno que va bien no aparece reprobado en octubre solo porque el examen final es en noviembre.

Ver el detalle y los casos cubiertos en Modelo académico.


6. Rendimiento

Estado actual medido (408 alumnos, 18 grupos, 5,472 calificaciones):

Ruta Tamaño de JS Notas
Mayoría del panel ~107 kB Solo el runtime compartido
Hoja de captura ~128 kB Incluye la tabla interactiva
Boletas ~107 kB Renderizado en servidor

Decisiones de rendimiento tomadas:

  • El listado de alumnos pagina de 50 en 50 con conteo en la misma consulta.
  • El avance de captura usa groupBy y count, no trae las filas.
  • La inscripción de alumnos a un grupo filtra en el cliente sobre una lista ya cargada: son cientos de registros, no miles, y evita un viaje por tecla.
  • Índices en el esquema sobre tenantId, (schoolId, estatus), (groupId, estatus), (studentId, termId), (studentId, fecha).

Dónde se rompería primero: la generación de boletas de grupo completo hace una consulta por alumno. Con 40 alumnos son 40 rondas; aceptable. Si una institución pidiera imprimir 500 boletas de una vez, habría que reescribir construirBoleta para trabajar por lotes. Está anotado en el roadmap.


7. Seguridad

Detalle completo en Seguridad y privacidad. Resumen:

Capa Qué hace
Middleware Rechaza peticiones sin sesión válida antes de tocar la base
getActiveContext Verifica usuario activo, tenant correcto y contraseña vigente
requirePermission Autoriza por permiso concreto, no por rol implícito
Consultas tenantId obligatorio; los updateMany llevan el tenant en el where
Server actions Los ids del formulario se verifican contra el tenant antes de usarse
Bitácora Todo cambio queda registrado con autor, IP y valores
Contraseñas bcrypt, coste 10; comparación de tiempo constante en el login
Cabeceras nosniff, SAMEORIGIN, Referrer-Policy, Permissions-Policy

8. Qué se decidió NO hacer

No hicimos Por qué
Una API REST completa Nadie la consume todavía. Se agregará cuando la app nativa la necesite, con endpoints diseñados para ella
Estado global en el cliente (Redux, Zustand) Los datos viven en el servidor; el estado local de cada formulario cabe en useState
Componentes de terceros (shadcn, MUI) Las primitivas propias son ~600 líneas y no arrastran actualizaciones ajenas ni estilos que pelear
Generación de PDF en el servidor Puppeteer pesa ~300 MB en la imagen; el diálogo de impresión del navegador ya guarda PDF
WebSockets / tiempo real Nadie necesita ver la calificación aparecer mientras otro la escribe
Cache de datos (Redis) Postgres responde en milisegundos a este volumen; sería un servicio más que puede fallar en on-premise
Microservicios Una escuela no genera el tráfico que justifique la complejidad operativa

Estas decisiones son reversibles. Se documentan para que quien las revise en un año sepa que fueron deliberadas y bajo qué supuestos.

DOCUMENTO 02

Modelo de datos

Las entidades del sistema, cómo se relacionan y por qué están así


1. Mapa general

Tenant (institución)
 └── School (plantel: primaria, secundaria…)
      ├── SchoolYear (ciclo 2025-2026)
      │    ├── Term (1er trimestre, 2do…)
      │    └── Group (1°A) ──── GradeLevel (1°)
      │         ├── Enrollment ──── Student
      │         └── CourseSection ──── Subject
      │              │                └── Teacher
      │              ├── EvaluationCriterion (Examen 40%)
      │              ├── Assignment (Examen parcial) ── Score
      │              ├── TermGrade  ← lo que va a la boleta
      │              └── Attendance
      ├── GradeLevel (catálogo de grados)
      └── Subject (catálogo de materias) ── GradeLevelSubject (plan de estudios)

Student ── StudentGuardian ── Guardian (tutor)
User ── UserSchool (alcance a planteles)
     └── Teacher / Student / Guardian (identidad opcional)
AuditLog, Announcement

2. Las cuatro entidades que hay que entender

CourseSection — la asignación

Es la unidad de trabajo del sistema: "Matemáticas de 1°A en 2025-2026, la imparte la Mtra. Pérez".

Todo lo académico cuelga de aquí: criterios, actividades, calificaciones y asistencia. Cuando un docente entra al sistema, lo que ve es su lista de CourseSection.

Único por (groupId, subjectId, schoolYearId): una materia no se puede asignar dos veces al mismo grupo en el mismo ciclo.

TermGrade — la calificación de boleta

Un registro por alumno × asignación × periodo. Guarda dos valores:

Campo Qué es
calificacionCalculada Lo que salió de los criterios y actividades
calificacion Lo que realmente va a la boleta

Normalmente son iguales. Difieren cuando el docente ajusta a mano; en ese caso origen pasa a MANUAL y el sistema deja de sobrescribir ese valor aunque se capturen más actividades.

Vaciar el campo en la interfaz devuelve origen a CALCULADA y restaura el valor calculado.

publicada controla si la familia lo ve. Es distinto de "capturado": el docente puede tener todo listo y no haberlo liberado.

Enrollment — la inscripción

Vincula alumno ↔ grupo ↔ ciclo. Único por (studentId, schoolYearId): un alumno está en un solo grupo por ciclo.

Guarda numeroLista, que en México importa: las listas oficiales van numeradas por orden alfabético y el docente pasa lista por número.

Tenant — la institución

Todo cuelga de aquí. En SaaS, slug es el subdominio (colegio → colegio.school.atomtech.mx). En on-premise hay un único tenant, normalmente con slug local.


3. Catálogo de entidades

Tenancy

Modelo Qué representa Notas
Tenant Institución cliente slug único = subdominio. Identidad visual y plan
School Plantel o nivel Aquí viven las reglas de calificación: escala, mínima aprobatoria, decimales, redondeo

Que las reglas de calificación estén en School y no en Tenant es deliberado: una institución puede tener primaria con escala 5–10 en enteros y preparatoria con 0–100 y un decimal.

Calendario

Modelo Qué representa Notas
SchoolYear Ciclo escolar esActual marca el vigente. Único por (schoolId, nombre)
Term Periodo de evaluación estatus gobierna quién puede capturar. pesoFinal permite ponderar

Estados de un periodo:

PLANEADO ──▶ ABIERTO ──▶ EN_REVISION ──▶ CERRADO
              ▲              │               │
              └──────────────┴───────────────┘
                     (dirección puede reabrir)
Estado Docente Dirección / control escolar
PLANEADO — —
ABIERTO captura captura
EN_REVISION solo lectura captura
CERRADO solo lectura solo lectura (hay que reabrir)

Estructura académica

Modelo Qué representa Notas
GradeLevel Grado (1°, 2°…) Catálogo por plantel. orden gobierna el ordenamiento en toda la app
Group Grupo del ciclo (1°A) Tiene docente titular y cupo
Subject Materia promedia = false para talleres que se reportan pero no cuentan al promedio
GradeLevelSubject Plan de estudios Qué materias lleva cada grado. Alimenta la generación automática de asignaciones

Personas

Modelo Qué representa Notas
User Credencial de acceso Email único por institución: el mismo correo puede existir en dos escuelas del SaaS
UserSchool Alcance a planteles Vacío = todos los planteles del tenant
Teacher Docente userId opcional: puede existir sin acceso al portal
TeacherSchool Adscripción Un docente puede dar clase en primaria y secundaria
Student Alumno Expediente completo: datos, salud, contacto de emergencia
Guardian Tutor Se reutiliza entre hermanos si comparten correo
StudentGuardian Vínculo alumno–tutor parentesco, esPrincipal, esResponsablePago

Por qué Teacher y User están separados: una escuela quiere registrar a todos sus docentes en el sistema desde el día uno, pero darles acceso al portal poco a poco. También hay docentes que nunca usarán el sistema y cuya carga la captura control escolar.

Lo mismo aplica a Student y Guardian.

Evaluación

Modelo Qué representa Notas
CourseSection Asignación La unidad de trabajo
EvaluationCriterion Criterio con peso termId nulo = aplica a todos los periodos
Assignment Actividad evaluable publicada controla si la familia ve el detalle
Score Puntaje de un alumno noEntregado distingue el cero real del "sin capturar"
TermGrade Calificación de periodo Lo que va a la boleta
Attendance Asistencia Único por (courseSectionId, studentId, fecha)

Por qué Score.noEntregado es un campo y no un puntos = 0: hay tres estados distintos y confundirlos causa problemas reales:

Estado En la base Efecto en el cálculo
Sin capturar no existe el Score No cuenta ni a favor ni en contra
Cero real puntos = 0, noEntregado = false Cuenta como 0 sobre los puntos posibles
No entregó puntos = 0, noEntregado = true Igual que el cero, pero visible como "NE"

Comunicación y auditoría

Modelo Qué representa Notas
Announcement Aviso Segmentado por audiencia, plantel y grupo
AuditLog Bitácora Inmutable. Autor, acción, entidad, valores, IP

4. Convenciones del esquema

Convención Razón
id es cuid() Ordenable por creación, seguro para exponer en URLs, sin colisiones entre instancias
Nombres de modelo en inglés Compatibilidad con herramientas y convenciones de Prisma
Enums y campos en español Es dominio educativo mexicano: PREESCOLAR, matricula, apellidoPaterno
@@map a snake_case plural Convención estándar de Postgres para las tablas
tenantId en todas las tablas de negocio Permite filtrar sin joins y habilita RLS a futuro
Calificaciones como Float, no Decimal Decimal de Prisma no se serializa a componentes cliente sin conversión manual. Con una escala de 5 a 10 y un decimal, Float no tiene problemas de precisión
Fechas de asistencia como @db.Date No tiene hora; guardarla como timestamp causa desfases de zona horaria
onDelete explícito Cascade donde el hijo no tiene sentido solo; Restrict donde borrar perdería historial; SetNull en referencias opcionales

Sobre las fechas y la zona horaria

Las fechas que el usuario escribe (<input type="date">) se convierten con mediodía local: new Date("2026-03-15T12:00:00"). Sin eso, una fecha guardada en México a medianoche se convierte a UTC y aparece como el día anterior.

Todo el formateo de salida usa Intl.DateTimeFormat con timeZone: "America/Mexico_City".


5. Restricciones únicas

Tabla Restricción Qué previene
tenants slug Dos instituciones en el mismo subdominio
schools (tenantId, clave) Dos planteles con la misma clave corta
school_years (schoolId, nombre) Dos ciclos "2025-2026" en un plantel
terms (schoolYearId, orden) Dos periodos en la misma posición
grade_levels (schoolId, orden) Dos grados en la misma posición
groups (schoolYearId, gradeLevelId, nombre) Dos "1°A" en el mismo ciclo
subjects (schoolId, clave) Dos materias con clave MAT
students (tenantId, matricula) Matrículas duplicadas
users (tenantId, email) Dos cuentas con el mismo correo en una escuela
enrollments (studentId, schoolYearId) Un alumno en dos grupos el mismo ciclo
course_sections (groupId, subjectId, schoolYearId) La misma materia dos veces en un grupo
scores (assignmentId, studentId) Dos puntajes para la misma actividad
term_grades (courseSectionId, studentId, termId) Dos calificaciones de periodo
attendances (courseSectionId, studentId, fecha) Doble pase de lista el mismo día

6. Índices

Más allá de los que crean las restricciones únicas y las llaves foráneas:

Tabla Índice Para qué
students (schoolId, estatus) Listado de alumnos activos de un plantel
enrollments (groupId, estatus) Lista de un grupo
term_grades (studentId, termId) Boleta de un alumno
attendances (studentId, fecha) Historial de asistencia
course_sections teacherId "Mis grupos" del docente
audit_logs (tenantId, createdAt) Bitácora ordenada por fecha
audit_logs (entidad, entidadId) Historial de un registro concreto
announcements (tenantId, createdAt) Avisos recientes
Todas tenantId Aislamiento por institución

7. Migraciones

# Desarrollo: crea la migración y la aplica
npm run db:migrate

# Producción: solo aplica las pendientes
npm run db:deploy

# Ver el estado
npx prisma migrate status

La migración inicial (20260805000000_inicial) contiene el esquema completo.

Regla: en producción nunca se usa db push. Solo migrate deploy, para que el historial de cambios del esquema quede versionado y sea reproducible entre el SaaS y cada instalación local.


8. Volumen esperado

Referencia para dimensionar, con datos reales del seed de demostración:

Escuela Alumnos Grupos Asignaciones Calificaciones/ciclo Asistencias/ciclo
Chica 150 8 60 ~1,400 ~110,000
Mediana 500 20 160 ~4,800 ~380,000
Grande 1,500 55 450 ~14,000 ~1,100,000

La asistencia es la tabla que crece: un registro por alumno × materia × día. Con 1,500 alumnos son ~1.1 M de filas al año, que Postgres maneja sin esfuerzo con el índice (studentId, fecha).

Punto de atención: si una institución llegara a 5,000 alumnos, convendría particionar attendances por ciclo escolar. Está anotado en el roadmap.

DOCUMENTO 03

Multi-institución (multi-tenancy)

Cómo conviven varias escuelas en una instancia, y cómo se garantiza que no se vean entre sí


1. La estrategia elegida

Aislamiento por fila (tenantId en cada tabla), base de datos y esquema compartidos.

Las tres opciones y por qué esta

Estrategia Aislamiento Costo por escuela Migrar el esquema Veredicto
Fila (tenantId) Lógico Casi cero Una vez ✅ Elegida
Esquema por escuela Medio Bajo N esquemas por despliegue ❌
Base de datos por escuela Físico Alto N bases por despliegue ❌ para SaaS

El argumento decisivo: el mercado objetivo son escuelas de 150 a 1,500 alumnos. Doscientas escuelas en el SaaS significan doscientas migraciones cada vez que cambia el esquema, y AtomTech no tiene (todavía) un equipo dedicado a operar eso. Con aislamiento por fila, una migración cubre a todas.

El riesgo que asumimos: un error de programación puede filtrar datos entre instituciones. Es un riesgo real y por eso la sección 3 existe.

Cuándo cambiaríamos de estrategia: si una institución grande exigiera aislamiento físico por contrato o auditoría, se le da su propio despliegue — que es exactamente la modalidad on-premise, ya soportada. No hace falta reescribir nada.


2. Cómo se resuelve la institución

En SaaS: por subdominio

colegio-hidalgo.school.atomtech.mx  →  tenant slug "colegio-hidalgo"
instituto-juarez.school.atomtech.mx →  tenant slug "instituto-juarez"

resolveTenantSlug() en src/lib/session.ts:

  1. Lee x-forwarded-host (lo pone el proxy) o host.
  2. Si termina en .school.atomtech.mx, el prefijo es el slug.
  3. En desarrollo también funciona colegio.localhost:3000.
  4. Si no hay subdominio, cae al slug de on-premise.

En on-premise: fijo

DEPLOYMENT_MODE=onprem hace que siempre se use ONPREM_TENANT_SLUG. No hay resolución dinámica ni subdominios que configurar.

DNS necesario para el SaaS

*.school.atomtech.mx.  A  <ip-del-servidor>
school.atomtech.mx.    A  <ip-del-servidor>

Con el comodín, dar de alta una escuela nueva no requiere tocar el DNS ni el proxy: se crea el tenant, y Caddy emite su certificado la primera vez que alguien visita el subdominio (ver sección 5).


3. Las tres barreras contra fugas

Barrera 1 — Verificación cruzada sesión ↔ subdominio

En getActiveContext(), en cada carga de página:

if (session.tid && session.rol !== "SUPERADMIN" && tenant.slug !== tenantSlug) {
  redirect("/sin-acceso?motivo=institucion");
}

Si alguien copia su cookie de sesión de colegio-a.school.atomtech.mx y la pega en colegio-b.school.atomtech.mx, la petición se corta. La cookie es válida criptográficamente, pero apunta a otra institución.

Barrera 2 — tenantId obligatorio en toda consulta

La regla, sin excepciones:

// ✅ Correcto
await prisma.student.findMany({
  where: { tenantId: ctx.tenant.id, schoolId: ctx.school.id },
});

// ❌ Bug de seguridad, aunque "funcione"
await prisma.student.findMany({ where: { schoolId } });

En mutaciones, el where compuesto es la barrera:

// El updateMany con tenantId no puede tocar filas de otra institución
await prisma.school.updateMany({
  where: { id, tenantId: session.tid },
  data: parsed.data,
});

Los ids que llegan del formulario siempre se verifican antes de usarse:

const plantel = await prisma.school.findFirst({
  where: { id: schoolId, tenantId: session.tid },
});
if (!plantel) return { error: "El plantel no existe." };

Nótese el mensaje: "no existe", no "no tienes permiso". No confirmamos la existencia de recursos de otras instituciones.

Barrera 3 — Alcance por plantel dentro de la institución

UserSchool limita a un director o coordinador a sus planteles. Se aplica en getActiveContext:

const schools = await prisma.school.findMany({
  where: {
    tenantId: tenant.id,
    ...(scopedIds.length > 0 ? { id: { in: scopedIds } } : {}),
  },
});

Lista vacía = acceso a todos los planteles del tenant (es el caso de ADMIN).


4. Endurecimiento opcional: Row Level Security

Las tres barreras viven en la aplicación. Si un día se quiere una red de seguridad en la base de datos — por ejemplo, para una auditoría de seguridad o para conectar una herramienta de BI de terceros — Postgres RLS lo permite sin cambiar el modelo:

-- Habilitar en las tablas de negocio
ALTER TABLE students ENABLE ROW LEVEL SECURITY;

CREATE POLICY aislamiento_tenant ON students
  USING ("tenantId" = current_setting('app.tenant_id', true));

Y en la aplicación, fijar la variable de sesión por petición:

await prisma.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;

Por qué no está activo hoy:

  1. Con el pool de conexiones de Prisma hay que garantizar que la variable se fije en la misma conexión que ejecuta la consulta, lo que obliga a envolver cada petición en una transacción. Es un costo de rendimiento y de complejidad real.
  2. Las tres barreras de aplicación cubren el modelo de amenaza actual.
  3. En on-premise (una sola institución) no aporta nada.

Cuándo activarlo: al primer cliente que lo pida por contrato, o al conectar cualquier consumidor de la base que no sea esta aplicación.


5. Certificados TLS por subdominio

Caddy usa on-demand TLS: emite el certificado la primera vez que alguien visita un subdominio, sin configuración previa.

El peligro es obvio: cualquiera podría apuntar un CNAME a nuestro servidor y provocar emisiones ilimitadas contra Let's Encrypt hasta agotar la cuota.

Por eso Caddy pregunta antes, a /api/tls/permitido:

GET /api/tls/permitido?domain=colegio.school.atomtech.mx
  → 200 si existe un Tenant con slug "colegio" y está activo
  → 404 en cualquier otro caso

El endpoint verifica también que el dominio esté bajo el dominio raíz y que no sea un subdominio anidado. Solo lo consulta el proxy, por la red interna de Docker.


6. Dar de alta una institución nueva

En SaaS

docker compose -f docker-compose.saas.yml exec app npm run instalar

El asistente pregunta nombre, identificador, primer plantel, ciclo, escala de calificación y correo del administrador. Al terminar imprime la URL y la contraseña temporal.

Desatendido (para automatizar desde un formulario de contratación):

docker compose -f docker-compose.saas.yml exec app \
  env INST_SLUG=colegio-hidalgo \
      INST_NOMBRE="Colegio Miguel Hidalgo" \
      INST_NIVEL=2 \
      INST_ADMIN_EMAIL=direccion@colegiohidalgo.mx \
      INST_ADMIN_NOMBRE="Alicia Mendoza" \
      INST_CONFIRMAR=1 \
      npm run instalar

Variables disponibles: INST_SLUG, INST_NOMBRE, INST_RFC, INST_NIVEL, INST_PLANTEL, INST_PLANTEL_CLAVE, INST_CCT, INST_GRADOS, INST_CICLO, INST_CICLO_INICIO, INST_CICLO_FIN, INST_PERIODOS, INST_TIPO_PERIODO, INST_ESCALA_MIN, INST_ESCALA_MAX, INST_APROBATORIA, INST_ENTEROS, INST_ADMIN_NOMBRE, INST_ADMIN_EMAIL, INST_ADMIN_PASSWORD, INST_CONFIRMAR.

No hay que tocar DNS, proxy ni reiniciar nada.

En on-premise

Idéntico, pero solo se corre una vez:

docker compose -f docker-compose.onprem.yml exec app npm run instalar

7. Suspender o dar de baja una institución

Suspender (impago, fin de contrato): poner Tenant.activo = false.

  • El login la rechaza con un mensaje claro.
  • getActiveContext corta cualquier sesión abierta.
  • /api/tls/permitido deja de autorizar su certificado.
  • Los datos se conservan intactos. Reactivar es cambiar el campo de vuelta.

Dar de baja definitivamente:

  1. Exportar sus datos y entregarlos (ver Operación).
  2. Esperar el periodo de retención acordado en el contrato.
  3. DELETE FROM tenants WHERE slug = '...' — las cascadas del esquema borran todo lo asociado.

8. Aislamiento en desarrollo local

Con DEPLOYMENT_MODE=saas en tu máquina:

URL Institución
localhost:3000 La de ONPREM_TENANT_SLUG (por defecto local)
colegio.localhost:3000 Slug colegio
otra.localhost:3000 Slug otra

Los subdominios de localhost funcionan sin configurar nada en Chrome, Firefox y Safari. Es la forma más rápida de comprobar el aislamiento: crea dos instituciones, entra en una, copia la cookie a la otra y verifica que te expulse.

DOCUMENTO 04

Roles y permisos

Quién puede hacer qué, y dónde se cambia. Código: src/lib/rbac.ts


1. Los siete roles

Rol Quién es Portal Alcance
SUPERADMIN Soporte de AtomTech Panel Todas las instituciones
ADMIN Administrador de la institución Panel Todos los planteles de su institución
DIRECTOR Director de plantel Panel Los planteles que se le asignen
CONTROL_ESCOLAR Servicios escolares / secretaría Panel Los planteles que se le asignen
DOCENTE Profesor Portal docente Sus asignaciones
TUTOR Padre, madre o tutor Portal familias Sus hijos
ALUMNO Estudiante Portal familias Él mismo

El alcance por plantel se define con UserSchool. Lista vacía = todos los planteles de la institución.


2. Matriz de permisos

Los permisos son cadenas recurso.accion. La lista completa por rol está en PERMISSIONS dentro de src/lib/rbac.ts.

Permiso ADMIN DIRECTOR CONTROL ESC. DOCENTE TUTOR/ALUMNO
institucion.ver ✅ ✅ ✅ — —
institucion.editar ✅ — — — —
plantel.ver ✅ ✅ ✅ ✅ —
plantel.administrar ✅ — — — —
usuario.administrar ✅ — — — —
bitacora.ver ✅ ✅ — — —
ciclo.administrar ✅ ✅ ✅ — —
periodo.administrar ✅ ✅ ✅ — —
grado.administrar ✅ ✅ ✅ — —
grupo.administrar ✅ ✅ ✅ — —
materia.administrar ✅ ✅ ✅ — —
asignacion.administrar ✅ ✅ ✅ — —
alumno.ver ✅ ✅ ✅ ✅ —
alumno.administrar ✅ ✅ ✅ — —
tutor.ver ✅ ✅ ✅ ✅ —
tutor.administrar ✅ ✅ ✅ — —
docente.ver ✅ ✅ ✅ — —
docente.administrar ✅ ✅ — — —
calificacion.ver ✅ ✅ ✅ — —
calificacion.ver.propias — — — ✅ ✅
calificacion.capturar ✅ ✅ — ✅ —
calificacion.publicar ✅ ✅ ✅ — —
asistencia.ver ✅ ✅ ✅ ✅ —
asistencia.capturar ✅ ✅ — ✅ —
boleta.ver ✅ ✅ ✅ ✅ —
boleta.ver.propia — — — — ✅
boleta.emitir ✅ ✅ ✅ — —
aviso.ver ✅ ✅ ✅ ✅ ✅
aviso.publicar ✅ ✅ ✅ ✅ —
reporte.ver ✅ ✅ ✅ — —

SUPERADMIN tiene todos los de ADMIN más plataforma.administrar.


3. Decisiones que suelen sorprender

Control escolar no captura calificaciones

Tiene calificacion.publicar pero no calificacion.capturar.

Por qué: la responsabilidad de la calificación es del docente. Si la secretaria pudiera capturar, se diluye quién puso qué nota, que es justo lo que la bitácora existe para poder responder.

Si una escuela lo pide (pasa: docentes que entregan en papel), la línea a cambiar es una:

CONTROL_ESCOLAR: [
  // …
  "calificacion.capturar",   // ← agregar
],

Y quedará en el diff de git quién lo autorizó y cuándo.

El director no administra planteles ni usuarios

Puede operar el suyo por completo, pero no crear otro plantel ni dar de alta cuentas administrativas. Eso es del ADMIN de la institución.

El docente puede publicar avisos

Restringidos a sus grupos. Es lo que hace que el sistema le sirva para algo más que capturar: puede avisar de un examen sin pasar por dirección.

Un usuario no puede desactivarse a sí mismo

alternarUsuario corta si id === session.uid. Evita el caso de dejar una institución sin administrador activo.

Nadie restablece la contraseña del SUPERADMIN desde el panel

Es la cuenta de soporte de AtomTech. Se administra por fuera.


4. Dónde se aplican los permisos

Navegación

navFor(rol, NAV_PANEL) filtra los elementos del menú según los permisos. Si no tienes usuario.administrar, no ves "Usuarios y accesos".

Esto es cosmético, no seguridad. Ocultar el enlace no protege la ruta.

Páginas

export default async function UsuariosPage() {
  await requirePermission("usuario.administrar");  // ← la barrera real
  // …
}

requirePermission redirige a /sin-acceso si el rol no lo tiene.

Para páginas donde el permiso solo cambia lo que se muestra:

const puedeAdministrar = can(ctx.session.rol, "alumno.administrar");
// … y luego se condiciona el botón de "Nuevo alumno"

Server actions

Cada action verifica por su cuenta. Nunca se asume que quien llegó ahí pasó por la página correspondiente:

export async function crearAlumno(_prev: FormState, formData: FormData) {
  const session = await requirePermission("alumno.administrar");
  // …
}

Layouts de portal

Además del permiso, cada portal verifica que el rol le corresponda:

// panel/layout.tsx
if (!ROLES_STAFF.includes(ctx.session.rol)) {
  redirect(homeRouteFor(ctx.session.rol));
}

Un docente que escriba /panel/alumnos a mano acaba en /docente.


5. La autorización del docente sobre su asignación

Es la más fina del sistema y vive en autorizarSeccion() (src/app/panel/calificaciones/actions.ts). Comprueba, en orden:

  1. Hay sesión con institución.
  2. La asignación existe y es de esta institución.
  3. El periodo pertenece al ciclo de esa asignación.
  4. Quien pide es el docente de esa asignación, o es staff con calificacion.publicar.
  5. Si la operación escribe, el periodo lo permite ahora mismo:
Estado del periodo Docente Staff
PLANEADO ❌ "aún no se abre" ❌
ABIERTO ✅ ✅
EN_REVISION ❌ "está en revisión" ✅
CERRADO ❌ "pide que lo reabran" ❌

Los mensajes de error dicen qué pasó y qué hacer, no "acceso denegado".


6. Qué ve una familia

alumnosVisibles() (src/lib/portal.ts) es el único punto de entrada:

  • ALUMNO → solo el Student cuyo userId es el suyo.
  • TUTOR → los Student vinculados vía StudentGuardian a su Guardian.

Todas las páginas del portal parten de esa lista. Si alguien manipula ?alumno=<id> en la URL, el id simplemente no está en la lista y se muestra el primero al que sí tiene derecho.

Además, la boleta del portal se construye con soloPublicadas: true: las calificaciones que el docente aún no libera no existen para la familia.


7. Cómo cambiar los permisos

  1. Editar PERMISSIONS en src/lib/rbac.ts.
  2. Si es un permiso nuevo, agregarlo al tipo Permission.
  3. TypeScript señalará cualquier uso inconsistente.
  4. Correr npm run test:humo para verificar que los bloqueos entre portales siguen funcionando.

La matriz es una lista explícita por rol, no una jerarquía. Es más verboso pero hace que el cambio sea visible y revisable en el diff — que es exactamente lo que se quiere de un cambio de permisos.


8. Auditoría

Toda operación relevante se registra en AuditLog con autor, acción, entidad, resumen legible, IP y User-Agent.

Las acciones sensibles guardan además los valores anterior y nuevo:

await logAudit({
  accion: "calificacion.ajustar",
  resumen: `Ajuste manual en Matemáticas: 6 → 8`,
  datos: { studentId, anterior: 6, calculada: 6.2, nueva: 8 },
});

Es la evidencia con la que se resuelve una queja de calificación. Consultable desde Panel · Bitácora con filtros por tipo y búsqueda de texto.

DOCUMENTO 05

Modelo académico

Cómo se calculan las calificaciones y cómo funciona el ciclo de captura


1. El ciclo de un periodo

1. Control escolar crea el ciclo y sus periodos
      │
2. Dirección abre el periodo               PLANEADO → ABIERTO
      │
3. El docente define sus criterios (una vez por asignación/periodo)
      │
4. El docente crea actividades y captura puntajes
      │      ↓ el sistema calcula la calificación de periodo
      │
5. El docente ajusta lo que haga falta y publica a familias
      │
6. Dirección cierra la captura             ABIERTO → EN_REVISION
      │      ↑ el docente ya no puede cambiar; control escolar sí
      │
7. Se imprimen las boletas
      │
8. Dirección cierra el periodo             EN_REVISION → CERRADO
             ↑ congelado; reabrir queda en bitácora

2. Las dos formas de calificar

El sistema soporta ambas sin que la escuela tenga que elegir de antemano.

A) Solo la calificación final

El docente escribe directamente el número que va a boleta, en la tabla "Calificación de periodo". No crea actividades ni criterios.

Es lo que hacen las escuelas que llevan su registro en papel y solo vacían el resultado. Funciona desde el primer minuto, sin configurar nada.

B) Con desglose

El docente define criterios con pesos, crea actividades y captura puntajes. El sistema calcula la calificación y él puede ajustarla si hace falta.

Da trazabilidad: cuando un padre pregunta "¿por qué 7?", la respuesta está en la pantalla, actividad por actividad.

Las dos conviven. Un docente puede usar desglose en Matemáticas y calificación directa en Educación Física, en el mismo grupo.


3. El cálculo, paso a paso

Ejemplo completo

Criterios de la asignación:

Criterio Peso
Examen 40%
Trabajos y tareas 35%
Participación 25%

Actividades y puntajes de Ana:

Actividad Criterio Máx. Ana
Examen parcial Examen 10 10
Cuaderno Trabajos 10 5
Proyecto Trabajos 10 5
Participación Participación 10 10

Paso 1 — logro por criterio

Examen:         10/10  = 100%
Trabajos:       10/20  =  50%
Participación:  10/10  = 100%

Paso 2 — ponderación

100% × 40  = 40.0
 50% × 35  = 17.5
100% × 25  = 25.0
             ─────
             82.5   sobre un peso efectivo de 100

Paso 3 — mapeo a la escala del plantel (5 a 10)

5 + (82.5 / 100) × (10 − 5) = 5 + 4.125 = 9.125

Paso 4 — redondeo según el plantel

Configuración Resultado
1 decimal 9.1
Enteros (regla SEP básica) 9
Escala 0–100, sin decimales 83

El mapeo a la escala mínima

Nótese que 0% no da 0, da escalaMin. Con escala 5–10, un alumno que no obtuvo nada saca 5, no 0.

Esto es correcto para México: en educación básica la boleta no baja de 5. Si una institución quiere que 0% sea 0, configura escalaMin = 0.


4. Las reglas que evitan pleitos

Regla 1 — Vacío no es cero

Una actividad sin capturar no cuenta. Ni a favor ni en contra.

Por qué importa: en octubre, con solo el primer examen aplicado, un alumno con 10 debe ver 10 — no un 3.3 porque "faltan dos exámenes". Es el error más común de los sistemas escolares y genera llamadas de padres alarmados.

En el ejemplo, si solo estuviera capturado el examen (10/10):

Peso efectivo: 40 (solo el criterio Examen tiene datos)
Porcentaje:    40 / 40 = 100%
Calificación:  10

Regla 2 — NE sí es cero

Escribir NE (o -) en la celda marca no entregó: cuenta como 0 sobre los puntos máximos, y se distingue visualmente del "sin capturar".

Es la diferencia entre "todavía no lo evalúo" y "no lo entregó".

Regla 3 — Re-normalización del peso

Si un criterio completo está sin capturar, su peso se reparte proporcionalmente entre los demás. El alumno se califica sobre lo que sí se evaluó.

Regla 4 — El ajuste manual manda

Si el docente escribe un valor distinto al calculado:

  • TermGrade.origen pasa a MANUAL.
  • El sistema deja de sobrescribir esa calificación aunque se capturen más actividades.
  • Queda en bitácora: quién, cuándo, valor anterior, valor calculado, valor nuevo.

Vaciar el campo devuelve origen a CALCULADA y restaura el valor del sistema.

Regla 5 — Publicación explícita

Las calificaciones tienen publicada. Mientras sea false, la familia no las ve. El docente puede capturar en borrador todo el periodo y liberar al final.


5. Promedios

Promedio de la materia en el ciclo

Promedia las calificaciones de periodo. Si los periodos tienen pesoFinal > 0, se pondera:

T1 (20%) = 6      T2 (30%) = 8      T3 (50%) = 10
(6×20 + 8×30 + 10×50) / 100 = 8.6

Si todos los pesos están en 0, es promedio aritmético simple.

Los periodos sin calificación se ignoran; no cuentan como cero.

Promedio general

Promedio de los promedios de materia, excluyendo las materias con promedia = false (talleres, clubes, actividades que se reportan pero no cuentan).


6. Configuración por plantel

Cada plantel define sus propias reglas en Panel · Planteles:

Campo Qué hace Ejemplo típico
escalaMin / escalaMax Rango de la calificación 5 y 10 en básica; 0 y 100 en algunas prepas
calificacionMinAprobatoria Debajo se marca reprobado 6 en básica; 70 en escala de 100
decimales Cuántos se muestran 1
redondearEnteros Asienta enteros en boleta ✅ en primaria y secundaria SEP
gradingMode Numérica o conceptual Conceptual en preescolar
asistenciaMinima % mínimo para acreditar 80

Una institución con primaria (5–10 enteros) y preparatoria (0–100 con decimal) las configura por separado. La boleta de cada plantel sale con su escala.


7. Asistencia

Se registra por alumno × asignación × día, con cuatro estados:

Estado En la interfaz Cuenta como asistencia
PRESENTE P (verde) ✅
RETARDO R (ámbar) ✅
AUSENTE F (rojo) ❌
JUSTIFICADO J (azul) ❌ (pero se distingue)

Todos arrancan como presentes. El docente solo toca las excepciones — que es como se pasa lista en un salón real. Guardar registra a los treinta, no solo a los que se marcaron.

El porcentaje que se muestra a las familias es (presentes + retardos) / total.

No se puede pasar lista de un día futuro.


8. La boleta

construirBoleta() (src/lib/academico.ts) es la fuente única: la usan la vista del panel, la del portal de familias y la impresión. No hay dos cálculos que puedan divergir.

Incluye:

  • Datos del alumno: nombre, matrícula, CURP, grupo
  • Datos del plantel: nombre, CCT, director
  • Una fila por materia del grupo — incluidas las que aún no tienen calificación, para que la boleta muestre el pendiente en vez de omitir la materia
  • Columna por periodo, más el promedio de la materia
  • Promedio general
  • Resumen de asistencia del ciclo
  • Escala de evaluación aplicada
  • Espacios de firma para dirección y tutor

La versión del portal de familias se construye con soloPublicadas: true.

Impresión

El CSS de globals.css fuerza tema claro en papel, oculta la navegación (.no-imprimir) y aplica salto de página entre boletas (.salto-pagina).

Boletas de un grupo completo: /panel/boletas/grupo/<id> genera todas, una por hoja. Es el flujo de fin de periodo.

PDF: se obtiene desde el mismo diálogo de impresión del navegador ("Guardar como PDF"). Sin dependencias de servidor y funciona igual sin internet.


9. Pruebas del cálculo

npm run test

19 casos cubren: redondeo, límites de escala, ponderación por criterio, actividades sin capturar, NE, re-normalización de pesos, ausencia de criterios, escala 0–100 y promedios simples y ponderados.

Es la parte del sistema con más pruebas a propósito: es la que una escuela va a cuestionar cuando un padre reclame una boleta.


10. Evaluación conceptual (preescolar)

School.gradingMode = CONCEPTUAL está en el modelo y conceptualLabel() traduce el porcentaje a etiquetas:

Logro Etiqueta
≥ 85% Destacado
≥ 65% Satisfactorio
≥ 45% En proceso
< 45% Requiere apoyo

Estado actual: la infraestructura existe; la boleta conceptual con observaciones por campo formativo (que es como la pide la NEM para preescolar) está en el roadmap. Ver Roadmap.

DOCUMENTO 06

Sistema de diseño

Cómo se ve y por qué. Código: src/app/globals.css, src/components/ui/primitives.tsx


1. Principios

  1. Densidad de información alta, ruido bajo. Control escolar trabaja con listas de cientos de alumnos. Cada píxel de padding decorativo es una fila menos en pantalla.
  2. El estado vacío enseña. Nunca "no hay datos". Siempre qué es esto, por qué está vacío y cuál es el siguiente paso, con el botón para darlo.
  3. Los errores dicen qué hacer. "El periodo está cerrado. Pide a dirección que lo reabra para poder corregir" en vez de "operación no permitida".
  4. Funciona en el celular de la maestra. No es una versión reducida: es la misma aplicación, con la navegación en cajón y los controles con área táctil suficiente.
  5. Se imprime bien. La boleta en papel sigue existiendo y se firma.

2. Tokens

El tema oscuro se define una vez, como un bloque de variables CSS. Los componentes nunca escriben bg-white dark:bg-slate-900; escriben bg-superficie.

Superficies y texto

Token Claro Oscuro Uso
--fondo #f6f7f9 #0b0f17 Fondo de la página
--superficie #ffffff #131926 Tarjetas, barras, modales
--superficie-2 #f2f4f7 #1a2130 Fondos secundarios, celdas alternas
--superficie-hover #eef1f5 #212a3b Estado hover
--borde #e2e6ec #263041 Bordes y separadores
--borde-fuerte #cbd2dc #35415a Bordes de campos
--texto #101828 #e8ecf3 Texto principal
--texto-suave #5a6473 #9aa5b8 Texto secundario
--texto-tenue #8a93a2 #6b7688 Metadatos, ayudas

Semánticos

Token Claro Oscuro Significado
--acento #2563eb #4b8bff Acción principal, selección
--exito #15803d #4ade80 Aprobado, publicado, activo
--alerta #b45309 #fbbf24 En riesgo, requiere atención
--peligro #b42318 #f87171 Reprobado, destructivo
--info #0369a1 #38bdf8 Informativo, planeado

Cada uno tiene su variante -suave para fondos.

En Tailwind se usan como bg-superficie, text-suave, border-borde, bg-acento-suave, text-peligro.


3. Escala tipográfica

Stack del sistema, sin fuentes remotas (requisito on-premise):

ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, …
Uso Tamaño Peso
Título de página text-xl / sm:text-2xl 600
Título de tarjeta text-sm 600
Cuerpo text-sm (14px) 400
Cuerpo denso (tablas) text-[13px] 400
Metadatos text-xs (12px) 400
Encabezado de tabla text-[12px] mayúsculas 600

Los números siempre llevan tabular-nums: en una columna de calificaciones, las cifras deben alinearse verticalmente.


4. Componentes

Primitivas (src/components/ui/primitives.tsx)

Componente Para qué
Card / CardHeader / CardBody Contenedor estándar de sección
Button / ButtonLink 5 variantes × 3 tamaños
Badge Estados y etiquetas, 6 tonos
Input / Select / Textarea Campos de formulario
Field Campo con etiqueta, ayuda y error
Alert Mensajes con tono semántico
PageHeader Título, descripción, migas, acción
EmptyState Vacío con explicación y siguiente paso
Table / Th / Td / Tr Tabla con scroll horizontal
StatCard Métrica de tablero
Avatar Foto o iniciales

De formulario (src/components/ui/form-parts.tsx)

Componente Para qué
SubmitButton Se deshabilita y muestra spinner mientras el action está en vuelo
FormFeedback Error general o confirmación
FormGrid 1 columna en móvil, 2–3 en escritorio
FormSection Sección con título y descripción
FormActions Fila de botones al pie

De dominio

Componente Para qué
Libreta Hoja de captura de calificaciones
PaseDeLista Captura de asistencia
BoletaVista Boleta imprimible
AppShell Navegación, selectores de contexto, menú de cuenta
Buscador / FiltroSelect Filtros que viven en la URL
CredencialTemporal Contraseña recién generada, copiable e imprimible
SelectorAlumno Cambio entre hijos en el portal familiar

5. Tema claro / oscuro

Tres opciones: claro, oscuro y automático (sigue al sistema operativo). La preferencia se guarda en localStorage.

Sin parpadeo: un script en el <head> aplica la clase .dark antes del primer pintado. Sin él, quien usa tema oscuro ve un destello blanco en cada carga.

Si el usuario elige "automático", un listener de matchMedia sigue los cambios del sistema en vivo (por ejemplo, al anochecer en macOS).

En móvil, el interruptor de la barra alterna claro/oscuro directo; las tres opciones están en el menú de cuenta.


6. Responsivo

Puntos de quiebre de Tailwind: sm 640, md 768, lg 1024, xl 1280.

Zona Móvil Escritorio
Navegación Cajón lateral con overlay Barra fija de 240 px
Selector de plantel En la barra superior En la barra superior
Selector de ciclo En el menú de cuenta En la barra superior
Tablas Scroll horizontal; columnas secundarias ocultas Completas
Formularios 1 columna 2–3 columnas
Hoja de captura Scroll horizontal, columna de alumno fija Completa

Detalle importante: los inputs tienen font-size: 16px en móvil. Por debajo de eso, iOS hace zoom automático al enfocar el campo y descoloca la pantalla.


7. Accesibilidad

Lo que está resuelto:

  • Contraste AA en ambos temas para texto y controles.
  • Foco visible con :focus-visible (anillo de 2 px del color de acento).
  • Roles ARIA en controles compuestos: radiogroup en el pase de lista, progressbar en las barras de avance, menu en el menú de cuenta.
  • aria-current="page" en la navegación activa.
  • Etiquetas en todos los campos; sr-only cuando la etiqueta visual sobra.
  • Área táctil mínima de 36 × 36 px en botones de icono.
  • prefers-reduced-motion desactiva animaciones y transiciones.
  • Los estados no dependen solo del color: llevan texto o icono.

Lo que falta y está en el roadmap:

  • Recorrido completo con lector de pantalla en la hoja de captura.
  • Navegación por teclado con flechas dentro de la tabla de captura (hoy funciona con Tab).
  • Modo de alto contraste.

8. Impresión

@media print {
  /* Papel siempre en claro, aunque la pantalla esté en oscuro */
  :root, .dark { --superficie: #fff; --texto: #000; … }
  .no-imprimir { display: none !important; }
  .salto-pagina { break-after: page; }
}
  • .no-imprimir — navegación, botones, filtros
  • .solo-imprimir — contenido exclusivo de papel
  • .salto-pagina — separa boletas de un lote

Probado en Chrome, Safari y Firefox, tamaño carta.


9. Micro-decisiones que importan

Decisión Por qué
El buscador escribe en la URL (?q=) Las secretarias comparten y guardan enlaces a búsquedas; el botón Atrás funciona como esperan
Rebote de 300 ms en la búsqueda Evita una consulta por tecla sin que se sienta lento
El avance de captura es una barra, no un número Se lee de un vistazo desde la puerta de la oficina
Los estados de calificación tienen color y número Daltonismo, y porque el número es el dato
El pase de lista arranca todos en presente Es como se pasa lista en un salón
La contraseña temporal se puede imprimir Muchas escuelas no tienen correo para todos; se entrega en papel
Los botones destructivos son iconos discretos, no botones rojos grandes Reducen el borrado accidental en pantallas densas
Los formularios muestran el error junto al campo No hay que buscar cuál de veinte campos falló
DOCUMENTO 07

Instalación en el servidor de la escuela (on-premise)

Todo dentro de la red de la escuela. La información de los alumnos no sale a internet


1. Qué se necesita

Equipo

Mínimo Recomendado
Procesador 2 núcleos 4 núcleos
Memoria 4 GB 8 GB
Disco 40 GB SSD 120 GB SSD
Sistema Ubuntu Server 22.04+, Debian 12+ Ubuntu Server 24.04 LTS

Una escuela de 500 alumnos corre sin problema en el mínimo. La computadora puede ser un equipo de escritorio dedicado; lo importante es que esté siempre encendida y conectada por cable, no por WiFi.

Software

Solo Docker:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Cierra sesión y vuelve a entrar para que tome el grupo

Red

  • IP fija en el servidor (por ejemplo 192.168.1.50).
  • Puertos 80 y 443 libres en ese equipo.
  • No hace falta abrir nada hacia internet, salvo que la escuela quiera consulta remota (ver sección 7).

2. Instalación

Paso 1 — Copiar el sistema

git clone <repositorio> /opt/contro-escolar
cd /opt/contro-escolar

Sin acceso a git, se copia la carpeta por USB.

Paso 2 — Configurar

cp .env.onprem.example .env

Generar los dos secretos:

echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
echo "AUTH_SECRET=$(openssl rand -base64 48)" >> .env

Editar .env y ajustar:

DOMINIO=:80                        # HTTP en red local por IP
APP_URL=http://192.168.1.50        # la IP real del servidor
ONPREM_TENANT_SLUG=local

Anota AUTH_SECRET en un lugar seguro. Si se pierde y se regenera, todas las sesiones abiertas se cierran (nada más se pierde, pero todos vuelven a entrar).

Paso 3 — Levantar

docker compose -f docker-compose.onprem.yml up -d --build

La primera vez tarda 3–5 minutos compilando la imagen.

Verificar:

docker compose -f docker-compose.onprem.yml ps
curl http://localhost/api/salud
# {"estado":"ok","modo":"onprem","baseDatos":"ok",…}

Paso 4 — Crear el esquema

docker compose -f docker-compose.onprem.yml exec app npx prisma migrate deploy

Paso 5 — Dar de alta la escuela

docker compose -f docker-compose.onprem.yml exec app npm run instalar

El asistente pregunta:

  1. Nombre de la institución
  2. Identificador (debe coincidir con ONPREM_TENANT_SLUG)
  3. Primer plantel: nivel, nombre, clave, CCT, número de grados
  4. Ciclo escolar: nombre, fechas, cuántos periodos
  5. Escala de calificación
  6. Correo del administrador

Al terminar imprime la URL y la contraseña temporal. Anótala: solo se muestra una vez.

Paso 6 — Entrar

Desde cualquier equipo de la escuela: http://192.168.1.50


3. Primeros pasos dentro del sistema

En este orden:

  1. Materias — el catálogo del plantel, marcando en qué grados se imparte cada una
  2. Docentes — el personal, creando su acceso al portal en el mismo paso
  3. Grupos — 1°A, 1°B… Al crearlos, marcar "Crear las materias del grado automáticamente" para que se generen las asignaciones desde el plan de estudios
  4. Asignaciones — asignar el docente de cada materia
  5. Alumnos — importar desde Excel (Panel · Alumnos · Importar)

Con eso el sistema está listo para capturar.


4. Operación diaria

cd /opt/contro-escolar

# Ver estado
docker compose -f docker-compose.onprem.yml ps

# Ver registros de la aplicación
docker compose -f docker-compose.onprem.yml logs -f app

# Reiniciar
docker compose -f docker-compose.onprem.yml restart app

# Detener todo
docker compose -f docker-compose.onprem.yml down

# Levantar de nuevo
docker compose -f docker-compose.onprem.yml up -d

Arranque automático al encender

Los contenedores llevan restart: always: si el servidor se reinicia o se va la luz, todo vuelve solo cuando Docker arranca.

Verificar que Docker arranque con el sistema:

sudo systemctl enable docker

5. Respaldos

Automáticos

El contenedor respaldos genera un volcado comprimido cada día a las 2 de la mañana en ./backups/, y conserva los últimos 30 días (RETENCION_DIAS).

ls -lh backups/
# contro-2026-08-05_0200.sql.gz   1.2M

Manual antes de algo delicado

docker compose -f docker-compose.onprem.yml exec db \
  pg_dump -U contro contro_escolar | gzip > backups/antes-de-actualizar.sql.gz

Copiar los respaldos fuera del servidor

Esto es lo más importante de toda la instalación. Un respaldo que vive en el mismo disco que la base no protege contra el fallo de ese disco.

Opciones, de más a menos recomendable:

# a) Disco USB montado en /mnt/respaldo
0 3 * * * rsync -a /opt/contro-escolar/backups/ /mnt/respaldo/contro/

# b) Otra computadora de la escuela
0 3 * * * rsync -az /opt/contro-escolar/backups/ usuario@192.168.1.51:/respaldos/

# c) Nube (si la escuela lo acepta) — cifrado en origen
0 3 * * * rclone sync /opt/contro-escolar/backups/ remoto:contro-escolar

Agregar con crontab -e.

Restaurar

# 1. Detener la aplicación (la base sigue arriba)
docker compose -f docker-compose.onprem.yml stop app

# 2. Restaurar
gunzip -c backups/contro-2026-08-05_0200.sql.gz | \
  docker compose -f docker-compose.onprem.yml exec -T db \
  psql -U contro -d contro_escolar

# 3. Levantar
docker compose -f docker-compose.onprem.yml start app

Práctica obligatoria: probar la restauración en un equipo de prueba al menos una vez por ciclo escolar. Un respaldo que nunca se restauró no es un respaldo, es una esperanza.


6. Actualizar

cd /opt/contro-escolar

# 1. Respaldar SIEMPRE antes
docker compose -f docker-compose.onprem.yml exec db \
  pg_dump -U contro contro_escolar | gzip > backups/pre-actualizacion-$(date +%F).sql.gz

# 2. Traer la versión nueva
git pull

# 3. Reconstruir
docker compose -f docker-compose.onprem.yml up -d --build

# 4. Aplicar migraciones de base de datos
docker compose -f docker-compose.onprem.yml exec app npx prisma migrate deploy

# 5. Verificar
curl http://localhost/api/salud

Cuándo actualizar: en periodo vacacional o un viernes por la tarde. Nunca durante la semana de captura de calificaciones.


7. HTTPS y acceso desde fuera

Opción A — Solo red local, HTTP (por defecto)

DOMINIO=:80

Los navegadores muestran "No seguro" en la barra. Es aceptable dentro de la red de la escuela, pero conviene explicárselo al personal para que no se alarme.

Opción B — Red local con HTTPS

DOMINIO=escuela.local

Caddy genera un certificado interno. Requiere que escuela.local resuelva a la IP del servidor (en el router de la escuela o en el hosts de cada equipo), e instalar la CA de Caddy en los navegadores. Es la opción más laboriosa.

Opción C — Dominio público con HTTPS automático

Para que los docentes capturen desde casa y las familias consulten en línea:

  1. Registro DNS: contro.miescuela.edu.mx → IP pública de la escuela
  2. Redirección de puertos 80 y 443 en el router hacia el servidor
  3. En .env:
    DOMINIO=contro.miescuela.edu.mx
    APP_URL=https://contro.miescuela.edu.mx
    
  4. docker compose -f docker-compose.onprem.yml restart proxy

Caddy gestiona Let's Encrypt solo, incluida la renovación.

Antes de hacer esto, léase Seguridad y privacidad. Exponer el sistema a internet cambia el modelo de amenaza: ya no basta la confianza en la red local.


8. Pasar de local a la nube (o al revés)

El sistema es el mismo. Se migran los datos:

# En el servidor local
docker compose -f docker-compose.onprem.yml exec db \
  pg_dump -U contro --clean --if-exists contro_escolar > migracion.sql

# Ajustar el slug del tenant al subdominio del SaaS
sed -i "s/'local'/'colegio-hidalgo'/g" migracion.sql

# En el servidor SaaS
psql "$DATABASE_URL" < migracion.sql

Después, cambiar DEPLOYMENT_MODE a saas y ROOT_DOMAIN en el destino.

Ver Despliegue SaaS para el detalle.


9. Problemas comunes

Síntoma Causa probable Solución
"Institución no encontrada" en el login ONPREM_TENANT_SLUG no coincide con el slug creado Corregir .env y restart app
El sitio no carga desde otras computadoras Cortafuegos del servidor sudo ufw allow 80,443/tcp
Bind for 0.0.0.0:80 failed Otro servicio usa el puerto 80 sudo lsof -i :80 y detenerlo, o cambiar DOMINIO=:8080
La app no arranca Falta AUTH_SECRET o POSTGRES_PASSWORD Revisar .env; los logs lo dicen explícitamente
Muy lento Poca memoria docker stats; subir a 8 GB
No se generan respaldos El contenedor respaldos está caído docker compose logs respaldos
Fechas corridas un día Zona horaria del servidor sudo timedatectl set-timezone America/Mexico_City

Diagnóstico general

docker compose -f docker-compose.onprem.yml ps        # ¿todo arriba?
docker compose -f docker-compose.onprem.yml logs --tail=100 app
curl -v http://localhost/api/salud                    # ¿responde?
docker stats --no-stream                              # ¿recursos?
df -h                                                 # ¿disco lleno?

10. Lista de verificación de puesta en marcha

  • Servidor con IP fija y conexión por cable
  • Docker instalado y habilitado al arranque
  • .env con POSTGRES_PASSWORD y AUTH_SECRET generados y anotados fuera del servidor
  • Zona horaria del servidor en America/Mexico_City
  • Migraciones aplicadas
  • Institución creada con npm run instalar
  • Contraseña del administrador cambiada
  • /api/salud responde ok
  • Se puede entrar desde otra computadora de la escuela
  • Respaldo automático verificado (existe un archivo en ./backups)
  • Copia de respaldos fuera del servidor configurada en cron
  • Restauración probada en un equipo de prueba
  • Personal capacitado (dirección, control escolar, 2–3 docentes piloto)
DOCUMENTO 08

Despliegue SaaS — school.atomtech.mx

Cómo se pone en producción el servicio multi-institución y cómo se opera


1. Arquitectura del despliegue

                    Internet
                        │
              *.school.atomtech.mx (DNS comodín)
                        │
               ┌────────▼────────┐
               │  Caddy          │  TLS automático por subdominio
               │  puertos 80,443 │  con verificación previa
               └────────┬────────┘
                        │
              ┌─────────▼─────────┐
              │  app  (×2)        │  Next.js standalone
              └─────────┬─────────┘
                        │
              ┌─────────▼─────────┐
              │  PostgreSQL 16    │
              └─────────┬─────────┘
                        │
              ┌─────────▼─────────┐
              │  respaldos        │  pg_dump diario
              └───────────────────┘

Dos réplicas de la aplicación. Como la sesión es un JWT en cookie, cualquier réplica atiende cualquier petición: no hace falta sesión pegajosa ni almacén compartido.


2. Servidor recomendado

Escuelas Alumnos totales Servidor
1–10 hasta 3,000 4 vCPU · 8 GB · 80 GB SSD
10–40 hasta 15,000 8 vCPU · 16 GB · 200 GB SSD
40+ 15,000+ Separar base de datos a instancia dedicada

Un VPS en México o el sur de Estados Unidos mantiene la latencia baja para el usuario final.


3. DNS

En el panel del dominio atomtech.mx:

school           A      <ip-del-servidor>
*.school         A      <ip-del-servidor>

El comodín es lo que permite dar de alta escuelas nuevas sin tocar DNS: cada institución vive en <slug>.school.atomtech.mx y su certificado se emite solo la primera vez que alguien la visita.

Verificar la propagación:

dig +short school.atomtech.mx
dig +short cualquier-cosa.school.atomtech.mx

Ambos deben devolver la misma IP.


4. Puesta en marcha

Paso 1 — Preparar el servidor

sudo apt update && sudo apt upgrade -y
sudo timedatectl set-timezone America/Mexico_City

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable

Paso 2 — Desplegar

sudo mkdir -p /opt/contro && sudo chown $USER /opt/contro
git clone <repositorio> /opt/contro/escolar
cd /opt/contro/escolar

cp .env.saas.example .env
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
echo "AUTH_SECRET=$(openssl rand -base64 48)" >> .env

Editar .env:

DEPLOYMENT_MODE=saas
ROOT_DOMAIN=school.atomtech.mx
RETENCION_DIAS=30

# Correo: en SaaS sí conviene, es como llegan las invitaciones
SMTP_HOST=smtp.tuservicio.com
SMTP_USER=…
SMTP_PASSWORD=…
SMTP_FROM=Contro Escolar <no-reply@school.atomtech.mx>

Levantar:

docker compose -f docker-compose.saas.yml up -d --build
docker compose -f docker-compose.saas.yml exec app npx prisma migrate deploy

Paso 3 — Verificar

curl https://school.atomtech.mx/api/salud
# {"estado":"ok","modo":"saas","baseDatos":"ok",…}

El certificado del dominio raíz se emite en el primer acceso; puede tardar unos segundos.


5. Dar de alta una escuela

docker compose -f docker-compose.saas.yml exec app npm run instalar

O desatendido, para integrarlo con el flujo de contratación:

docker compose -f docker-compose.saas.yml exec app \
  env INST_SLUG=colegio-hidalgo \
      INST_NOMBRE="Colegio Miguel Hidalgo" \
      INST_NIVEL=2 \
      INST_GRADOS=6 \
      INST_ADMIN_NOMBRE="Alicia Mendoza" \
      INST_ADMIN_EMAIL=direccion@colegiohidalgo.mx \
      INST_CONFIRMAR=1 \
      npm run instalar

Al terminar, la escuela entra en https://colegio-hidalgo.school.atomtech.mx con la contraseña temporal que imprime el comando.

No hay que tocar DNS, proxy ni reiniciar nada.

Dominio propio de la escuela

Si una institución quiere contro.colegiohidalgo.mx:

  1. La escuela crea un CNAME: contro.colegiohidalgo.mx → school.atomtech.mx
  2. Agregar el dominio a /api/tls/permitido (hoy solo autoriza subdominios del dominio raíz; hace falta una tabla TenantDomain — anotado en el roadmap)
  3. Caddy emite el certificado en el primer acceso

6. Operación

cd /opt/contro/escolar

docker compose -f docker-compose.saas.yml ps
docker compose -f docker-compose.saas.yml logs -f app
docker compose -f docker-compose.saas.yml logs -f proxy

# Escalar réplicas de la app
docker compose -f docker-compose.saas.yml up -d --scale app=4

Actualizar sin cortar el servicio

# 1. Respaldar
docker compose -f docker-compose.saas.yml exec db \
  pg_dump -U contro contro_escolar | gzip > backups/pre-$(date +%F-%H%M).sql.gz

# 2. Construir la imagen nueva
git pull
docker compose -f docker-compose.saas.yml build app

# 3. Migraciones (deben ser compatibles hacia atrás)
docker compose -f docker-compose.saas.yml exec app npx prisma migrate deploy

# 4. Reemplazar réplicas
docker compose -f docker-compose.saas.yml up -d --no-deps app

# 5. Verificar
curl https://school.atomtech.mx/api/salud
node scripts/humo.mjs https://demo.school.atomtech.mx

Regla de migraciones: durante el reemplazo conviven la versión vieja y la nueva. Toda migración debe funcionar con ambas: agregar columnas con valor por defecto, nunca renombrar ni borrar en el mismo despliegue que cambia el código. Renombrar es dos despliegues: agregar el campo nuevo y llenarlo; después, quitar el viejo.


7. Respaldos

Automáticos diarios a las 2:00 en ./backups, retención de 30 días.

Fuera del servidor (obligatorio):

# En crontab -e
0 4 * * * rclone sync /opt/contro/escolar/backups/ s3:atomtech-respaldos/contro-escolar/

Los respaldos contienen datos personales de menores de edad: el destino debe estar cifrado y con acceso restringido. Ver Seguridad.

Restaurar una sola institución

Los respaldos son de la base completa. Para restaurar una escuela sin tocar las demás:

# 1. Cargar el respaldo en una base temporal
createdb restauracion
gunzip -c backups/contro-2026-08-05.sql.gz | psql restauracion

# 2. Exportar solo esa institución
pg_dump restauracion --data-only \
  --table=tenants --table=schools --table=students … \
  | grep <tenant-id> > escuela.sql

# 3. Borrar y recargar en producción

Es un procedimiento delicado y poco frecuente. Si se vuelve común, conviene reevaluar la estrategia de tenancy — ver Multi-institución.


8. Monitoreo

Sonda básica

GET /api/salud devuelve 200 con {"estado":"ok"} o 503 si la base no responde. Es lo que consulta Caddy y lo que debe consultar el monitoreo externo (UptimeRobot, Better Stack, o un cron propio).

Alertas mínimas recomendadas

Alerta Umbral Por qué
Servicio caído 2 fallos seguidos de /api/salud Lo obvio
Disco > 80% Los respaldos y los registros llenan disco
Memoria > 85% sostenido Postgres empieza a paginar
Sin respaldo en 26 h — El fallo silencioso más peligroso
Certificado por vencer < 7 días Caddy renueva solo, pero conviene enterarse si falla

Consultas útiles

-- Instituciones activas y su uso
SELECT t.nombre, t.slug, t.plan,
       COUNT(DISTINCT s.id) AS alumnos,
       COUNT(DISTINCT u.id) AS usuarios
FROM tenants t
LEFT JOIN students s ON s."tenantId" = t.id AND s.estatus = 'ACTIVO'
LEFT JOIN users u    ON u."tenantId" = t.id AND u.activo
WHERE t.activo
GROUP BY t.id
ORDER BY alumnos DESC;

-- Actividad de la última semana
SELECT t.nombre, COUNT(*) AS eventos
FROM audit_logs a
JOIN tenants t ON t.id = a."tenantId"
WHERE a."createdAt" > now() - interval '7 days'
GROUP BY t.nombre
ORDER BY eventos DESC;

-- Escuelas que no han entrado en 30 días (riesgo de baja)
SELECT t.nombre, MAX(u."ultimoAcceso") AS ultimo
FROM tenants t
JOIN users u ON u."tenantId" = t.id
WHERE t.activo
GROUP BY t.id
HAVING MAX(u."ultimoAcceso") < now() - interval '30 days'
   OR MAX(u."ultimoAcceso") IS NULL;

9. Suspender una institución

UPDATE tenants SET activo = false WHERE slug = 'colegio-x';

Efecto inmediato: el login la rechaza con un mensaje claro, las sesiones abiertas se cortan en el siguiente clic, y el proxy deja de renovar su certificado. Los datos se conservan. Reactivar es cambiar el campo de vuelta.


10. Lista de verificación

  • DNS comodín *.school.atomtech.mx propagado
  • Cortafuegos: solo 22, 80, 443
  • Acceso SSH solo por llave, sin contraseña
  • Zona horaria America/Mexico_City
  • POSTGRES_PASSWORD y AUTH_SECRET generados y guardados en el gestor de secretos
  • Migraciones aplicadas
  • /api/salud responde ok sobre HTTPS
  • Certificado válido en el dominio raíz y en un subdominio de prueba
  • SMTP configurado y probado
  • Respaldo automático verificado
  • Copia de respaldos fuera del servidor, cifrada
  • Restauración probada en un servidor de pruebas
  • Monitoreo externo apuntando a /api/salud
  • Alerta de "sin respaldo en 26 h"
  • Institución de demostración creada para ventas
DOCUMENTO 09

Seguridad y privacidad

El sistema guarda datos personales de menores de edad


1. Qué datos maneja y su sensibilidad

Dato Sensibilidad Dónde
Nombre, CURP, fecha de nacimiento de menores Alta students
Domicilio y teléfono Alta students, guardians
Alergias, tipo de sangre, notas médicas Muy alta — dato de salud students
Contacto de emergencia Alta students
Calificaciones y asistencia Media — dato académico term_grades, attendances
Observaciones de conducta Alta students.observaciones
Datos de tutores, RFC Alta guardians
Contraseñas Solo hash bcrypt users.passwordHash

En términos de la LFPDPPP, los datos de salud son datos personales sensibles y requieren consentimiento expreso. Ver sección 8.


2. Autenticación

Aspecto Implementación
Almacenamiento de contraseñas bcrypt, coste 10
Sesión JWT HS256 en cookie httpOnly, SameSite=Lax, Secure en producción
Duración 12 horas configurables (SESSION_HOURS)
Enumeración de usuarios Mensaje genérico + comparación de tiempo constante aunque el usuario no exista
Contraseñas temporales Alfabeto sin caracteres ambiguos (l/1/I, O/0), generado con crypto.getRandomValues
Primer acceso debeCambiarPassword fuerza el cambio antes de usar el sistema
Requisitos de contraseña nueva Mínimo 8 caracteres, letras y números, distinta de la actual
Revocación Desactivar la cuenta la expulsa en el siguiente clic (se verifica en cada carga de página)

Lo que no está y por qué

Falta Estado
Segundo factor (2FA) Roadmap. Prioritario para roles ADMIN y DIRECTOR
Límite de intentos de login Pendiente y prioritario. Ver sección 9
Rotación de sesión al cambiar contraseña Pendiente: hoy la sesión vieja sigue válida hasta expirar
Recuperación de contraseña por correo Pendiente. Hoy la restablece control escolar desde el panel

3. Autorización

Cuatro capas, cada una independiente de las anteriores:

  1. Middleware — rechaza peticiones sin sesión válida antes de tocar la base
  2. getActiveContext — verifica usuario activo, institución correcta, contraseña vigente
  3. requirePermission — autoriza por permiso concreto en cada página
  4. Server actions — cada uno verifica por su cuenta; nunca asume que se llegó por la ruta esperada

Detalle en Roles y permisos.

Principio: ocultar un botón no es autorización. La barrera siempre está en el servidor.


4. Aislamiento entre instituciones

Ver Multi-institución para el detalle. Resumen:

  • tenantId obligatorio en toda consulta
  • Verificación cruzada sesión ↔ subdominio en cada carga de página
  • Los ids que llegan de un formulario se verifican contra el tenant antes de usarse
  • Los mensajes de error dicen "no existe", no "no tienes permiso": no confirmamos la existencia de recursos ajenos

Endurecimiento disponible: Row Level Security en Postgres. Documentado y listo para activar; hoy no está por el costo en el pool de conexiones.


5. Protección de la aplicación

Vector Mitigación
Inyección SQL Prisma parametriza todo; no hay SQL construido por concatenación
XSS React escapa por defecto; el único dangerouslySetInnerHTML es el script de tema, con contenido estático
CSRF Server Actions de Next verifican el origen; las cookies son SameSite=Lax
Clickjacking X-Frame-Options: SAMEORIGIN
Sniffing de tipo X-Content-Type-Options: nosniff
Fuga por referer Referrer-Policy: strict-origin-when-cross-origin
Cámara/micrófono/ubicación Permissions-Policy los bloquea
Huella del servidor poweredByHeader: false, Caddy quita Server
Subida de archivos Solo CSV en importación; límite de 4 MB y 1000 filas; se procesa en memoria, nunca se ejecuta

Lo que falta

Falta Impacto
Content-Security-Policy Media. Requiere nonces por el script de tema inline
Límite de peticiones por IP Alta. Ver sección 9
Cabecera HSTS en on-premise Baja. Está en SaaS; en local suele ser HTTP a propósito

6. Auditoría

Toda operación relevante queda en AuditLog: autor, acción, entidad, resumen legible, IP, User-Agent y marca de tiempo.

Las operaciones sensibles guardan además el antes y el después:

{
  "accion": "calificacion.ajustar",
  "resumen": "Ajuste manual en Matemáticas: 6 → 8",
  "datos": { "studentId": "…", "anterior": 6, "calculada": 6.2, "nueva": 8 }
}

Consultable en Panel · Bitácora por dirección y administración.

Limitación conocida: la bitácora vive en la misma base que los datos. Un atacante con acceso a Postgres podría borrarla. Para instituciones que lo requieran, exportar la bitácora a un destino de solo-anexado está en el roadmap.


7. Datos en tránsito y en reposo

En tránsito

  • SaaS: HTTPS obligatorio con certificados Let's Encrypt gestionados por Caddy, HSTS activo.
  • On-premise en red local: HTTP por defecto. Es una decisión consciente: el tráfico no sale de la red de la escuela y exigir TLS complicaría la instalación sin beneficio proporcional. Se documenta cómo activarlo (opciones B y C en Instalación local).
  • On-premise expuesto a internet: HTTPS obligatorio. La documentación lo marca explícitamente.

En reposo

  • La base de datos no está cifrada a nivel de columna. Se apoya en el cifrado de disco del servidor (LUKS en Linux, o el cifrado del proveedor de nube).
  • Los respaldos se generan sin cifrar en ./backups. La copia fuera del servidor debe cifrarse — es la recomendación explícita en ambos documentos de despliegue.

Pendiente para instituciones con requisitos altos: cifrar en la aplicación los campos de salud (alergias, notasMedicas, tipoSangre). Anotado en el roadmap.


8. Cumplimiento (México)

LFPDPPP — Ley Federal de Protección de Datos Personales

Lo que la ley exige y dónde estamos:

Requisito Estado
Aviso de privacidad ⚠️ La escuela debe redactarlo. El sistema no lo incluye
Consentimiento expreso para datos sensibles (salud) ⚠️ Responsabilidad de la escuela al inscribir
Derechos ARCO (acceso, rectificación, cancelación, oposición) Parcial: acceso y rectificación desde el panel; cancelación requiere intervención técnica
Medidas de seguridad ✅ Autenticación, autorización, auditoría, respaldos
Aviso de vulneraciones ⚠️ Procedimiento por definir en el contrato
Encargado del tratamiento ⚠️ En SaaS, AtomTech es encargado: hace falta contrato de tratamiento

Reparto de responsabilidades:

  • La escuela es responsable del tratamiento: define finalidades, obtiene consentimiento, publica el aviso de privacidad.
  • AtomTech es encargado en la modalidad SaaS: trata los datos por cuenta de la escuela, bajo contrato.
  • En on-premise, AtomTech no trata datos: solo provee el software. Es un argumento de venta real para escuelas sensibles al tema.

Acciones pendientes antes de vender

  1. Contrato de prestación de servicios con cláusula de tratamiento de datos.
  2. Plantilla de aviso de privacidad para que la escuela la adapte.
  3. Procedimiento documentado de atención a derechos ARCO.
  4. Procedimiento de notificación de vulneraciones (plazos y destinatarios).
  5. Definir el periodo de retención tras la baja de una institución.

9. Trabajo pendiente, priorizado

Antes del primer cliente de pago

# Tarea Por qué
1 Límite de intentos de login por IP y por cuenta Hoy se puede hacer fuerza bruta sin freno. Es el hueco más grande
2 Límite de peticiones general en el proxy Protege contra abuso y escaneo
3 Rotación de sesión al cambiar contraseña Una sesión robada sobrevive al cambio
4 Contrato de tratamiento de datos Requisito legal para operar como encargado
5 Plantilla de aviso de privacidad La escuela la necesita para cumplir

Siguiente trimestre

# Tarea
6 2FA para ADMIN y DIRECTOR
7 Content-Security-Policy con nonces
8 Cifrado de campos de salud en la aplicación
9 Exportación de bitácora a destino de solo-anexado
10 Recuperación de contraseña por correo con token de un solo uso
11 Auditoría de seguridad externa

Cuando lo pida un cliente

# Tarea
12 Row Level Security activo
13 Instancia dedicada (ya soportado vía on-premise)
14 Certificación ISO 27001

10. Respuesta a incidentes

Procedimiento mínimo mientras se formaliza uno completo:

Si se sospecha acceso no autorizado

  1. Contener — suspender la cuenta comprometida (activo = false).
  2. Invalidar sesiones — rotar AUTH_SECRET y reiniciar: cierra todas las sesiones del sistema. Es agresivo pero inmediato.
  3. Investigar — AuditLog filtrado por usuario y rango de fechas; registros de acceso de Caddy en /data/acceso.log.
  4. Evaluar el alcance — qué datos pudo ver o modificar esa cuenta según su rol.
  5. Notificar — a la escuela afectada, con hechos y alcance. Si hubo exposición de datos personales, la escuela debe notificar a los titulares.
  6. Corregir — cerrar el hueco, agregar la prueba que lo hubiera detectado.

Si se pierden datos

  1. Detener la aplicación (no la base).
  2. Identificar el último respaldo bueno.
  3. Restaurar en un servidor de pruebas y verificar.
  4. Restaurar en producción.
  5. Documentar qué se perdió entre el respaldo y el incidente.

Contactos

Pendiente de definir: responsable de seguridad en AtomTech, canal de reporte para las escuelas y tiempos de respuesta comprometidos.


11. Para el administrador de la escuela

Lo que hay que decirle al director en lenguaje llano:

  1. Cada persona tiene su cuenta. Compartir la de dirección hace imposible saber quién cambió qué.
  2. Al salir un empleado, se desactiva su cuenta el mismo día. Toma diez segundos en Panel · Usuarios.
  3. Los respaldos deben salir del servidor. Si el disco falla y el respaldo estaba ahí, se pierde todo.
  4. Probar la restauración una vez por ciclo. Un respaldo nunca probado no es un respaldo.
  5. El aviso de privacidad es responsabilidad de la escuela, y debe incluir los datos de salud que se capturan en el expediente.
  6. Si el sistema se expone a internet, ya no basta con la confianza en la red local: HTTPS obligatorio y contraseñas fuertes para todos.
DOCUMENTO 10

Interfaces del sistema

Endpoints HTTP disponibles y cómo se integraría con otros sistemas


1. Estado actual

El sistema es una aplicación web con Server Components y Server Actions: la mayoría de las operaciones no pasan por una API REST. Los datos se consultan en el servidor y llegan renderizados; las mutaciones van por Server Actions.

Es una decisión deliberada (Arquitectura §2): sin consumidores externos, una API REST sería código que mantener sin usuario.

Los endpoints HTTP que sí existen son los que necesitan clientes que no son el navegador.


2. Endpoints disponibles

GET /api/salud

Sonda de salud. La consultan el proxy, Docker y el monitoreo externo. Pública.

Respuesta 200

{
  "estado": "ok",
  "modo": "saas",
  "baseDatos": "ok",
  "latenciaMs": 16,
  "fecha": "2026-08-05T18:30:00.000Z"
}

Respuesta 503 — la base de datos no responde

{
  "estado": "degradado",
  "modo": "saas",
  "baseDatos": "error",
  "fecha": "2026-08-05T18:30:00.000Z"
}

No expone datos de negocio.


POST /api/auth/login

Autenticación por JSON. La usa la prueba de humo y será el punto de entrada de la app nativa.

Petición

{
  "email": "docente@colegio.mx",
  "password": "…",
  "institucion": "colegio-hidalgo"
}

institucion es opcional: en SaaS se resuelve por el subdominio de la petición; en on-premise se ignora.

Respuesta 200

{
  "ok": true,
  "usuario": {
    "nombre": "Laura Ramírez Cortés",
    "email": "docente@colegio.mx",
    "rol": "DOCENTE"
  },
  "inicio": "/docente"
}

Devuelve además la cookie de sesión contro_sesion (httpOnly, SameSite=Lax, Secure en producción). Es la misma que usa el formulario web: no hay dos sistemas de sesión que mantener sincronizados.

Respuesta 401

{ "ok": false, "error": "Correo o contraseña incorrectos." }

Pendiente: este endpoint no tiene límite de intentos. Es el punto 1 de Seguridad §9.


GET /api/tls/permitido?domain=…

Autorización de certificados bajo demanda para Caddy. Solo lo consulta el proxy por la red interna de Docker.

Devuelve 200 si el dominio corresponde a una institución activa bajo el dominio raíz; 404 en cualquier otro caso.

Detalle en Multi-institución §5.


3. Importación y exportación

Importación de alumnos (CSV)

Vive en la interfaz: Panel · Alumnos · Importar. Flujo en dos pasos — análisis con vista previa validada, y confirmación.

Columnas aceptadas

Columna Obligatoria Formato
nombre ✅ texto
apellido_paterno ✅ texto
apellido_materno — texto
curp — 18 caracteres, se valida
fecha_nacimiento — DD/MM/AAAA o AAAA-MM-DD
genero — M / F
matricula — se genera si va vacía
email — correo
telefono — texto
direccion — texto
tutor_nombre — crea el tutor y lo vincula
tutor_email — correo; reutiliza el tutor si ya existe
tutor_telefono — texto
tutor_parentesco — Madre, Padre, Tutor…

Los encabezados se normalizan (acentos, mayúsculas y espacios), así que Apellido Paterno y apellido_paterno funcionan igual.

Idempotencia: si una CURP ya existe en la institución, esa fila se omite. Se puede volver a subir el mismo archivo sin duplicar alumnos.

Límites: 4 MB y 1000 filas por archivo.

Hay plantilla descargable desde la misma pantalla.

Exportación

src/lib/csv.ts incluye toCSV() con BOM para que Excel abra los acentos correctamente.

Pendiente: los botones de exportación en los listados de alumnos, calificaciones y asistencia. La función está; falta conectarla a la interfaz. Anotado en el Roadmap.


4. Integraciones futuras

API REST para la app nativa

Cuando se construya la app (Móvil), los endpoints mínimos serían:

POST   /api/auth/login              ya existe
POST   /api/auth/logout
GET    /api/mi/perfil
GET    /api/mi/alumnos              tutores: sus hijos
GET    /api/mi/grupos               docentes: sus asignaciones
GET    /api/mi/calificaciones
GET    /api/mi/asistencia
GET    /api/mi/avisos
POST   /api/captura/puntajes        docente
POST   /api/captura/asistencia      docente

Autenticación con la misma cookie de sesión, o con token Bearer si la app lo requiere.

Principio de diseño: los endpoints se llaman /api/mi/… y devuelven lo que corresponde a quien pregunta, resuelto desde la sesión. No aceptan un ?alumnoId= que haya que autorizar: la autorización está en la forma del endpoint.

SIGED / control escolar estatal

La integración con el sistema oficial de la SEP es la petición más previsible de las escuelas. Hoy no hay una API pública documentada; lo realista es exportar en el formato de carga que cada estado acepte.

Requiere trabajo de campo: conseguir los formatos de al menos dos estados y construir el exportador. Anotado en el roadmap con prioridad alta porque elimina la doble captura, que es uno de los seis problemas del PRD.

Facturación (colegiaturas)

Cuando exista el módulo de finanzas, la integración natural es con un PAC para timbrar CFDI. El modelo ya guarda Guardian.rfc y esResponsablePago.

Mensajería

Avisos por WhatsApp Business API o correo. El modelo Announcement ya tiene audiencia y segmentación por grupo; falta el canal de salida.


5. Convenciones para endpoints nuevos

Si se agregan endpoints, seguir lo que ya hace el sistema:

Convención Ejemplo
Rutas y campos en español /api/mi/calificaciones, { "estado": "ok" }
Respuesta con ok booleano { "ok": false, "error": "…" }
Mensajes de error para humanos "El periodo está cerrado. Pide a dirección que lo reabra."
Validación con Zod al entrar Nunca confiar en el cuerpo de la petición
tenantId de la sesión, nunca del cuerpo La institución no es un parámetro
Errores 404 en vez de 403 para recursos ajenos No confirmar la existencia
Auditar toda mutación logAudit()
export const dynamic = "force-dynamic" Los datos son por usuario
DOCUMENTO 11

Móvil y app nativa

Cómo funciona hoy en el celular y cuál es el camino a una app nativa


1. Estado actual: web responsiva

El sistema ya funciona en el celular. No es una versión reducida: es la misma aplicación, adaptada.

Zona En el celular
Navegación Cajón lateral con overlay; se cierra solo al navegar
Selector de plantel En la barra superior
Selector de ciclo En el menú de cuenta (no cabe en la barra)
Tablas Scroll horizontal; las columnas secundarias se ocultan
Hoja de captura Scroll horizontal con la columna de alumno fija
Pase de lista Botones P/R/F/J de 36 px, cómodos con el pulgar
Formularios Una columna
Boleta Se ve y se imprime desde el navegador móvil

Detalles que importan en móvil y están resueltos:

  • Inputs a 16 px: por debajo, iOS hace zoom automático y descoloca la pantalla.
  • viewport-fit=cover y min-h-dvh: respeta la muesca y la barra inferior.
  • theme-color por esquema de color: la barra del navegador acompaña al tema.
  • Área táctil mínima de 36 × 36 px en todos los botones de icono.
  • Sin dependencia de hover: todo estado se alcanza con toque.

2. Fase 1 (hoy) — Instalable como aplicación

El sistema sirve un manifest.webmanifest con display: standalone, así que se puede agregar a la pantalla de inicio y abre sin barra de navegador, con su propio icono.

Android (Chrome): menú ⋮ → "Instalar aplicación" iOS (Safari): Compartir → "Agregar a pantalla de inicio"

Lo que esto da:

  • Icono propio en la pantalla de inicio
  • Se abre a pantalla completa
  • Sesión persistente

Lo que no da todavía:

  • Funcionamiento sin conexión
  • Notificaciones push
  • Cámara para foto del alumno

3. Fase 2 — PWA completa

El siguiente paso natural, sin salir de la web. Es la opción con mejor relación esfuerzo/beneficio y debería hacerse antes de considerar nativo.

Capacidad Qué habilita Esfuerzo
Service worker con caché La app abre aunque el WiFi de la escuela falle; los datos ya vistos siguen disponibles 3–5 días
Captura sin conexión El docente captura en un salón sin señal y se sincroniza al volver 2–3 semanas
Notificaciones push (Web Push) "Se publicaron las calificaciones de tu hijo". Funciona en Android e iOS 16.4+ 1–2 semanas
Iconos y splash completos Se ve como app de verdad al instalarla 1 día

Sobre la captura sin conexión

Es la capacidad que más pediría un docente, y también la más delicada: implica resolver conflictos cuando dos personas capturan lo mismo.

Propuesta: cola de operaciones en IndexedDB con la marca de tiempo del cliente; al sincronizar, gana la más reciente y las colisiones se registran en bitácora para revisión. Con la granularidad actual (un guardado = una hoja completa), el conflicto real es raro: dos docentes no capturan la misma asignación.


4. Fase 3 — App nativa

Cuándo tiene sentido

Solo si aparece al menos una de estas condiciones:

  1. Las escuelas piden notificaciones push en iOS con fiabilidad de app nativa.
  2. Se necesita la cámara para tomar la foto del alumno en el momento de la inscripción.
  3. Marketing: estar en las tiendas es un argumento de venta que cierra tratos.
  4. Los docentes rechazan la web y hay evidencia de que una app cambiaría eso.

Hasta entonces, no. Una app nativa es dos plataformas más que mantener, dos procesos de revisión en tiendas, y ciclos de publicación que frenan las correcciones urgentes.

Recomendación técnica: React Native con Expo

Opción Ventaja Desventaja
Expo / React Native Un código para iOS y Android; el equipo ya sabe React; actualizaciones OTA sin pasar por tienda Un runtime más
Flutter Rendimiento excelente Dart: lenguaje nuevo para el equipo
Nativo (Swift + Kotlin) Máximo control Dos bases de código
Capacitor (envolver la web) Muy rápido de hacer Se siente a web envuelta; poca ganancia sobre la PWA

Expo, por la actualización por aire: se puede corregir un error el mismo día sin esperar revisión de Apple. En un sistema que se usa intensivamente durante la semana de captura, eso importa.

Alcance sugerido de la app

Lo que sí:

Perfil Funciones
Tutor / alumno Calificaciones, asistencia, boleta, avisos, notificaciones push
Docente Pase de lista, captura de calificaciones, avisos de sus grupos

Lo que no: toda la administración. Control escolar trabaja con listas de cientos de alumnos, importaciones y boletas — eso es trabajo de escritorio y seguirá en la web. Meterlo en la app duplicaría el esfuerzo para un caso de uso que nadie tiene.

Lo que hace falta en el servidor

  1. API REST — los endpoints /api/mi/… bosquejados en API §4.
  2. Autenticación por token — refresh token de larga duración; nadie quiere escribir su contraseña cada 12 horas en el celular.
  3. Push — tabla de tokens de dispositivo, integración con FCM y APNs.
  4. Versionado de API — la app en la tienda puede ser vieja; el servidor debe seguir respondiéndole.

Estimación

Trabajo Tiempo
API REST y autenticación por token 2 semanas
Infraestructura de push 1 semana
App tutor/alumno 4 semanas
App docente 3 semanas
Publicación en tiendas 1–2 semanas
Total ≈ 11–13 semanas

5. Recomendación

AHORA      Web responsiva + instalable       ✅ ya funciona
           │
SIGUIENTE  PWA: caché, push, offline         ~1 mes · alto impacto
           │
DESPUÉS    App nativa (si hay demanda real)  ~3 meses · impacto por validar

La razón: el 90% del valor de una app (icono en la pantalla, pantalla completa, notificaciones, funcionar sin señal) se obtiene con la PWA, en un tercio del tiempo y sin dependencias de tiendas.

Conviene medir antes de decidir: en la bitácora se puede ver qué porcentaje de los accesos de docentes y tutores viene de móvil (AuditLog.userAgent). Si es alto y la PWA no basta, ahí sí hay caso para la app.


6. Pruebas en móvil

Antes de cada versión, verificar en dispositivo real:

  • Login e inicio en pantalla pequeña
  • Cajón de navegación abre, cierra y bloquea el scroll del fondo
  • Hoja de captura: scroll horizontal con la columna de alumno fija
  • Pase de lista completo con el pulgar
  • Los inputs no provocan zoom en iOS
  • Boleta legible y se imprime
  • Tema oscuro correcto, incluida la barra del navegador
  • Instalación en pantalla de inicio (Android e iOS)
  • Funciona en horizontal

Dispositivos de referencia: un Android gama media (lo que trae la mayoría del personal docente) y un iPhone reciente.

DOCUMENTO 12

Roadmap

Qué sigue, en qué orden y por qué. Estado a agosto de 2026


1. Lo que ya está (versión 1.0)

Construido, probado y funcionando:

Módulo Estado
Institución multi-plantel con reglas de calificación por plantel ✅
Ciclos escolares y periodos con estados de captura ✅
Grados, grupos, materias y plan de estudios ✅
Alumnos con expediente completo ✅
Tutores vinculados con parentesco y responsable de pago ✅
Docentes con adscripción a planteles ✅
Importación masiva de alumnos con vista previa ✅
Asignaciones materia × grupo × docente ✅
Criterios de evaluación con pesos ✅
Actividades y captura de puntajes ✅
Cálculo de calificación con ajuste manual auditado ✅
Publicación controlada a familias ✅
Asistencia con pase de lista ✅
Boletas individuales y por grupo, imprimibles ✅
Portal docente ✅
Portal de familias ✅
Avisos segmentados ✅
Usuarios, roles y permisos granulares ✅
Bitácora de auditoría ✅
Tema claro/oscuro, responsivo ✅
Despliegue SaaS y on-premise ✅
Respaldos automáticos ✅

Verificación: 61 pruebas de humo sobre rutas y permisos, 19 pruebas de la aritmética de calificaciones. npm test && npm run test:humo.


2. Antes del primer cliente de pago

No son funcionalidades: son requisitos para poder cobrar.

# Tarea Esfuerzo Por qué
1 Límite de intentos de login 2 días Hueco de seguridad real (Seguridad §9)
2 Límite de peticiones en el proxy 1 día Abuso y escaneo
3 Recuperación de contraseña por correo 3 días Hoy la restablece control escolar; no escala
4 Botones de exportación a Excel 2 días Lo pide toda escuela; la función ya existe en csv.ts
5 Contrato de tratamiento de datos — Requisito legal
6 Plantilla de aviso de privacidad — La escuela lo necesita
7 Manual del usuario con capturas 1 semana Reduce el costo de soporte

Total técnico: ≈ 2 semanas.


3. Trimestre 1 — Lo que las escuelas van a pedir primero

Ordenado por la frecuencia con que aparece en las entrevistas.

3.1 Colegiaturas y pagos · 4–6 semanas

El módulo que convierte el sistema de "útil" a "indispensable" para una escuela privada. Cuando el cobro vive aquí, nadie lo cambia.

  • Conceptos de cobro (inscripción, colegiatura mensual, materiales)
  • Estado de cuenta por alumno
  • Registro de pagos y recargos por mora
  • Recordatorios automáticos a responsables de pago
  • Reporte de cartera vencida
  • Timbrado CFDI vía PAC (fase posterior)

El modelo ya lo anticipa: Guardian.rfc y StudentGuardian.esResponsablePago.

3.2 Exportación al formato oficial (SIGED / estatal) · 3–4 semanas

Elimina la doble captura, que es uno de los seis problemas del PRD.

Requiere trabajo de campo previo: conseguir los formatos de carga de al menos dos estados. Sin eso no se puede estimar bien.

3.3 Horarios · 3 semanas

  • Definición de bloques por grupo
  • Detección de choques (docente en dos lugares a la vez)
  • Vista de horario por grupo, docente y aula
  • Impresión

Habilita después: asistencia por hora en vez de por día.

3.4 Reportes y analítica · 2 semanas

  • Alumnos en riesgo (promedio bajo o asistencia baja)
  • Comparativo entre grupos y materias
  • Evolución del promedio por periodo
  • Concentrado de calificaciones por grupo (el que pide dirección)
  • Constancias de estudios generadas

3.5 PWA completa · 4 semanas

Ver Móvil. Caché, notificaciones push y captura sin conexión.


4. Trimestre 2

Módulo Esfuerzo Notas
Mensajería escuela ↔ familia 3 semanas Bidireccional, con hilos por alumno
Conducta e incidencias 2 semanas Reportes, seguimiento, citatorios
Fotografías de alumnos 1 semana Subida y almacenamiento; credenciales imprimibles
Boleta conceptual NEM completa 2 semanas Preescolar: campos formativos con observaciones
Documentos del expediente 2 semanas Acta de nacimiento, CURP, certificados escaneados
2FA para roles administrativos 1 semana Seguridad §9
Historial académico y certificados 3 semanas Kardex multi-ciclo, promedio general acumulado

5. Más adelante

Módulo Cuándo tiene sentido
App nativa iOS/Android Si la PWA no basta y hay demanda medida
Biblioteca Escuelas con acervo formal
Transporte escolar Escuelas con rutas propias
Cafetería con monedero Escuelas grandes
Portal de admisiones Escuelas con proceso de selección
Evaluación docente Institucional, requiere política previa
Aula virtual / tareas en línea Compite con Classroom; probablemente mejor integrar que construir
Multi-idioma Escuelas bilingües con reportes en inglés

6. Deuda técnica conocida

Cosas que hoy funcionan pero conviene atender antes de que estorben.

# Deuda Impacto Cuándo
1 construirBoleta hace una consulta por alumno Lento al imprimir grupos muy grandes (500+) Cuando una escuela lo pida
2 Sin pruebas de integración de los server actions Un cambio en la autorización podría pasar desapercibido Antes del primer cliente
3 Sin Content-Security-Policy Defensa en profundidad ausente Trimestre 1
4 attendances crece sin partición A 5,000 alumnos convendría particionar por ciclo Cuando llegue esa escala
5 Sin cache de datos Postgres responde bien hoy Cuando la latencia lo justifique
6 La bitácora vive en la misma base que los datos Un atacante con acceso podría borrarla Cuando un cliente lo exija
7 Sin rotación de sesión al cambiar contraseña Una sesión robada sobrevive al cambio Antes del primer cliente
8 /api/tls/permitido solo admite subdominios del dominio raíz Bloquea dominios propios de escuela Cuando se venda esa opción

7. Métricas para decidir qué construir

Antes de comprometerse con un módulo grande, medir:

Pregunta Dónde se responde
¿Los docentes capturan en el sistema? AuditLog con acción calificacion.capturar por docente
¿Las familias entran? User.ultimoAcceso de rol TUTOR
¿Desde móvil o escritorio? AuditLog.userAgent
¿Se cierran los periodos a tiempo? Term.estatus contra fechaCierreCaptura
¿Cuántos ajustes manuales hay? AuditLog con acción calificacion.ajustar
¿Qué pantallas se usan? Requiere instrumentación — pendiente

Falta lo más básico: analítica de uso. Sin ella, el roadmap se decide por intuición. Instrumentar eventos de página (respetando la privacidad, sin terceros) debería estar entre las primeras tareas.


8. Criterios de priorización

Cuando haya que elegir entre dos cosas:

  1. ¿Bloquea a un cliente que ya paga? → primero.
  2. ¿Es un riesgo de seguridad o de pérdida de datos? → primero.
  3. ¿Cuántas escuelas lo pidieron? → más peso que la opinión interna.
  4. ¿Ahorra tiempo en la semana de captura? → alto valor: es el momento de máxima tensión y donde se gana o pierde la renovación.
  5. ¿Se puede resolver con documentación en vez de código? → hacer eso primero.
  6. ¿Genera dependencia? (colegiaturas, historial académico) → alto valor comercial.
DOCUMENTO 13

Operación

Procedimientos para el día a día: alta de escuelas, soporte, incidencias y ciclo escolar


1. Alta de una escuela nueva (SaaS)

Antes de instalar

Reunir con la escuela:

  • Nombre legal y nombre comercial
  • RFC (si van a facturar colegiaturas después)
  • Niveles que operan: preescolar, primaria, secundaria…
  • CCT de cada plantel
  • Escala de calificación de cada nivel
  • Cuántos periodos de evaluación y cómo los llaman
  • Fechas de inicio y fin del ciclo
  • Correo y nombre del administrador
  • Lista de alumnos en Excel
  • Lista de docentes

Los últimos dos son los que más tardan en llegar. Pedirlos primero.

Instalación

docker compose -f docker-compose.saas.yml exec app npm run instalar

Entregar al administrador: URL, usuario y contraseña temporal.

Acompañamiento del primer ciclo

Sesión Duración Con quién Contenido
1 1 h Dirección y control escolar Estructura: planteles, ciclo, periodos, grados, materias
2 1 h Control escolar Importar alumnos, dar de alta docentes, crear grupos y asignaciones
3 45 min Docentes (grupal) Portal docente: criterios, actividades, captura, publicación
4 30 min Control escolar Boletas, cierre de periodo, bitácora

La sesión 3 es la crítica. Si los docentes no la toman, el sistema no se usa.


2. Calendario del ciclo escolar

Lo que hay que hacer y cuándo. Sirve de guion para el soporte proactivo.

Julio–agosto · Preparación

  • Crear el ciclo nuevo con sus periodos
  • Marcar el ciclo anterior como cerrado
  • Revisar y actualizar el catálogo de materias
  • Crear los grupos del ciclo (generando sus materias del plan)
  • Reinscribir alumnos que continúan; dar de alta los nuevos
  • Asignar docente a cada materia
  • Dar de baja al personal que salió
  • Crear accesos a docentes nuevos

Agosto · Arranque

  • Abrir el primer periodo
  • Verificar que no haya materias sin docente
  • Renumerar las listas de cada grupo (orden alfabético)
  • Publicar el aviso de bienvenida
  • Enviar accesos a las familias

Durante cada periodo

  • Semana 1: recordar a los docentes que definan sus criterios
  • A media: revisar el avance de captura en el tablero
  • Última semana: recordatorio de fecha límite
  • Al cierre: pasar el periodo a "en revisión"

Fin de periodo

  • Verificar 100% de captura
  • Publicar calificaciones a familias
  • Imprimir boletas por grupo
  • Cerrar el periodo
  • Abrir el siguiente

Fin de ciclo

  • Cerrar el último periodo
  • Imprimir boletas finales
  • Marcar egresados
  • Respaldo completo etiquetado como fin de ciclo
  • Marcar el ciclo como cerrado

3. Soporte

Niveles

Nivel Quién Qué resuelve
1 Control escolar de la escuela Contraseñas, altas de alumnos, dudas de uso
2 Soporte AtomTech Errores, configuración, capacitación
3 Desarrollo Fallos del sistema, pérdida de datos

La mayoría de los tickets son de nivel 1 y los resuelve la propia escuela si está bien capacitada. Por eso las sesiones de acompañamiento importan.

Tiempos sugeridos

Severidad Ejemplo Respuesta Solución
Crítica El sistema no carga; se perdieron datos 1 h 4 h
Alta No se puede capturar; boletas incorrectas 4 h 1 día
Media Un módulo falla pero hay alternativa 1 día 1 semana
Baja Duda de uso, mejora 2 días Roadmap

Durante la semana de cierre de periodo, todo sube un nivel de severidad.

Preguntas frecuentes de nivel 1

Pregunta Respuesta
"Olvidé mi contraseña" Control escolar la restablece en Panel · Usuarios
"No veo mis grupos" (docente) Falta asignarle materias, o el ciclo activo no es el correcto
"No puedo capturar" El periodo está cerrado o en revisión. Dirección lo reabre
"El padre no ve las calificaciones" Falta publicarlas: botón "Publicar a familias"
"Un alumno no aparece en la lista" No está inscrito al grupo, o su estatus no es Activo
"La calificación no coincide" Revisar si es ajuste manual (etiqueta "ajustada") y la bitácora
"Se ve mal en el celular" Verificar que no esté en modo escritorio del navegador

4. Incidencias

El sistema no responde

# 1. ¿Están arriba los contenedores?
docker compose -f docker-compose.saas.yml ps

# 2. ¿Qué dicen los registros?
docker compose -f docker-compose.saas.yml logs --tail=200 app

# 3. ¿La base responde?
docker compose -f docker-compose.saas.yml exec db pg_isready -U contro

# 4. ¿Hay disco?
df -h

# 5. Reinicio de la aplicación (no de la base)
docker compose -f docker-compose.saas.yml restart app

Una escuela reporta datos incorrectos

  1. No tocar nada todavía. Primero entender.
  2. Buscar en la bitácora: Panel · Bitácora, filtrar por tipo y fecha.
  3. Si fue un ajuste manual, AuditLog.datos tiene el valor anterior.
  4. Corregir desde la interfaz, no desde la base de datos.
  5. Si hay que tocar la base, respaldar antes y documentar qué se hizo.

Sospecha de acceso no autorizado

Seguir el procedimiento de Seguridad §10.


5. Respaldos: verificación mensual

Una vez al mes, para cada instalación:

  • Existe un respaldo de las últimas 24 h
  • El archivo pesa lo esperado (no 0 bytes)
  • Hay copia fuera del servidor
  • Restauración probada en un entorno de pruebas

La restauración de prueba es lo que se salta todo el mundo y lo único que demuestra que el respaldo sirve.

# En un servidor de pruebas
createdb prueba_restauracion
gunzip -c contro-2026-08-05.sql.gz | psql prueba_restauracion

# Verificar que los datos estén
psql prueba_restauracion -c "SELECT COUNT(*) FROM students;"
psql prueba_restauracion -c "SELECT COUNT(*) FROM term_grades;"

6. Modelo comercial

Cifras de referencia para conversación, no comprometidas.

Planes SaaS

Plan Alumnos Mensual Incluye
Esencial ≤ 200 $1,900 MXN Un plantel, todos los módulos, soporte por correo
Profesional ≤ 600 $3,900 MXN Multi-plantel, soporte prioritario, capacitación inicial
Institucional 600+ Cotización Dominio propio, integraciones, acompañamiento

Implementación inicial: $8,000–$15,000 MXN según el volumen de datos a migrar.

On-premise

Concepto Precio
Licencia anual $24,000–$60,000 MXN según alumnos
Implementación e instalación $15,000–$30,000 MXN
Soporte remoto Incluido en la licencia
Visita presencial Por evento

Argumentos de venta

Frente a Argumento
Excel Trazabilidad, boletas automáticas, portal para padres, cero doble captura
Plataformas internacionales Precio en pesos, vocabulario mexicano, soporte en horario local, opción on-premise
Sistemas nacionales antiguos Interfaz moderna, funciona en celular, tema oscuro, instalación en una hora
Desarrollo a la medida Ya existe, ya está probado, y se actualiza sin cotizar cada cambio

La opción on-premise es el diferenciador. Ninguna plataforma internacional la ofrece, y hay escuelas que no ponen los datos de sus alumnos en la nube por política. Ese segmento no tiene alternativa moderna hoy.


7. Indicadores del negocio

Indicador Objetivo Consulta
Escuelas activas crecimiento mensual SELECT COUNT(*) FROM tenants WHERE activo
Alumnos totales — SELECT COUNT(*) FROM students WHERE estatus='ACTIVO'
Adopción docente > 90% Docentes con capturas / docentes activos
Adopción familiar > 60% por periodo users rol TUTOR con ultimoAcceso reciente
Escuelas en riesgo de baja 0 Sin accesos en 30 días
Tickets por escuela al mes < 3 Sistema de tickets (pendiente de elegir)
-- Escuelas sin actividad reciente: llamarlas antes de que se den de baja
SELECT t.nombre, t.slug, MAX(u."ultimoAcceso") AS ultimo_acceso
FROM tenants t
LEFT JOIN users u ON u."tenantId" = t.id
WHERE t.activo
GROUP BY t.id
HAVING MAX(u."ultimoAcceso") < now() - interval '30 days'
    OR MAX(u."ultimoAcceso") IS NULL
ORDER BY ultimo_acceso NULLS FIRST;

8. Entrega de datos a una escuela que se va

Debe estar en el contrato y cumplirse sin fricción: es lo que da confianza para contratar.

# Exportar todo lo de una institución
docker compose exec db pg_dump -U contro contro_escolar \
  --data-only --column-inserts > escuela-completa.sql

# Y en formato legible para humanos: CSV de alumnos y calificaciones

Entregar:

  1. Volcado SQL completo de sus datos
  2. CSV de alumnos, calificaciones y asistencia
  3. PDF de las boletas del ciclo en curso

Después del periodo de retención acordado, borrar:

DELETE FROM tenants WHERE slug = 'escuela-x';

Las cascadas del esquema eliminan todo lo asociado.

DOCUMENTO 14

Preguntas abiertas y siguientes pasos

Lo que hace falta decidir contigo antes de seguir, y en qué orden avanzar


1. Lo que construí bajo supuestos

El documento de la entrevista tenía cinco líneas. Para poder entregar algo funcional tomé decisiones y las anoto aquí para que las confirmes o corrijas. Ninguna es difícil de cambiar; algunas sí conviene resolverlas antes del primer cliente real.

# Supuse Alternativa Qué cambiaría
1 Mercado: escuelas privadas mexicanas de 150–1,500 alumnos Públicas, o de otro país El vocabulario, la boleta, los formatos oficiales
2 Educación básica y media (preescolar a bachillerato) Universidad Créditos, semestres, materias optativas, kardex
3 Evaluación por criterios con pesos (Examen 40%…) Rúbricas, o solo calificación directa El módulo de captura
4 La escala es por plantel, no por materia Por materia Un campo más en Subject
5 Un alumno está en un solo grupo por ciclo Materias sueltas (modelo de prepa/universidad) La inscripción cambia de grupo a materia
6 Asistencia por materia y día Por día completo, o por hora Simplificaría o complicaría el pase de lista
7 Sin colegiaturas en la 1.0 Con finanzas desde el inicio 4–6 semanas de trabajo
8 Boleta con formato propio, no el oficial SEP Formato oficial exacto Requiere el formato de cada estado
9 Un tenant por institución, con sus planteles dentro Un tenant por plantel Cambia la facturación y el aislamiento
10 Nombre del producto: Contro Escolar Otro Textos y logotipo

2. Preguntas que necesitan tu respuesta

Bloqueantes — antes de vender

P1 · ¿Qué escuela es la primera? Todo lo demás se decide mejor con una escuela concreta enfrente: su escala, sus periodos, su boleta, su nivel. ¿Ya hay una candidata? ¿La de la entrevista?

P2 · ¿SaaS o servidor local para ese primer cliente? Cambia la prioridad del trabajo pendiente. Si es SaaS, urge el límite de intentos de login y la recuperación de contraseña. Si es local, urge el manual de instalación en video y el procedimiento de respaldo fuera del servidor.

P3 · ¿La boleta debe verse como el formato oficial? Hoy es un formato propio, limpio y completo. Si la escuela entrega la oficial de la SEP, necesito un PDF de la boleta real que usan para replicarla.

P4 · ¿Quién da el soporte de primer nivel? Si es AtomTech, hay que elegir herramienta de tickets y definir horarios. Si es la escuela, hay que producir el manual y las sesiones de capacitación.

P5 · ¿Hay presupuesto y plazo para colegiaturas? Es el módulo que más se va a pedir y el que genera dependencia. 4–6 semanas.

Importantes — se pueden resolver en paralelo

P6 · Niveles a soportar en la 1.0. Está listo para preescolar, primaria, secundaria y bachillerato. ¿Hay que cubrir universidad? Si sí, es un modelo distinto (créditos, optativas, semestres) y convendría tratarlo como producto aparte.

P7 · ¿Escuelas con más de un turno? Hoy el turno es un campo del plantel. Si una escuela opera matutino y vespertino con alumnos distintos, se resuelve con dos planteles. ¿Es aceptable?

P8 · Marca y dominio. Confirmaste school.atomtech.mx. ¿El producto se llama "Contro Escolar"? ¿Hay logotipo y paleta de AtomTech que deba usar?

P9 · Correo saliente. Para invitaciones y recuperación de contraseña hace falta SMTP. ¿Qué servicio? (Resend, Amazon SES, Google Workspace…) Hoy el sistema funciona sin correo: muestra las contraseñas en pantalla para imprimir.

P10 · Precios. Los del documento de operación son referencia mía. ¿Cuál es el rango real que manejas para el mercado que conoces?

Estratégicas — para conversar

P11 · ¿Vender on-premise o solo SaaS? On-premise es tu diferenciador frente a las plataformas internacionales, pero tiene un costo operativo real: cada instalación es un servidor que alguien debe mantener. ¿Vale la pena ese costo por el segmento que abre?

P12 · ¿Producto propio o desarrollo a la medida? El código está listo para replicarse rápido (una escuela nueva son 5 minutos). ¿La estrategia es vender el mismo producto a muchas escuelas, o usarlo como base para proyectos a la medida? La respuesta cambia cuánta configurabilidad conviene construir.

P13 · App nativa. Mi recomendación es PWA primero y nativo solo si hay demanda medida (Móvil). ¿Coincides, o hay una razón comercial para estar en las tiendas antes?


3. Siguientes pasos propuestos

Esta semana

  1. Probar el sistema. Levántalo, entra con los cuatro roles, captura calificaciones, imprime una boleta. Es la mejor forma de encontrar lo que falta. Instrucciones en el README.
  2. Responder P1 y P2. Todo lo demás se ordena mejor con esas dos.
  3. Conseguir datos reales de una escuela: lista de alumnos, catálogo de materias, una boleta impresa. Vale más que diez decisiones de diseño.

Semanas 2–3 · Cerrar la 1.0

Del Roadmap §2, lo que falta para poder cobrar:

  • Límite de intentos de login (2 días)
  • Límite de peticiones en el proxy (1 día)
  • Recuperación de contraseña por correo (3 días)
  • Exportación a Excel en los listados (2 días)
  • Manual del usuario con capturas de pantalla (1 semana)
  • Contrato y aviso de privacidad (legal, en paralelo)

Semana 4 · Piloto

  • Instalar en la primera escuela con sus datos reales
  • Cuatro sesiones de acompañamiento (Operación §1)
  • Acompañar de cerca la primera semana de captura
  • Anotar todo lo que la gente pregunte: cada pregunta es una mejora de interfaz o de documentación

Mes 2 · Ajustar y decidir

  • Corregir lo que salió del piloto
  • Decidir el siguiente módulo con datos, no con intuición
  • Si el piloto funcionó: segunda y tercera escuela

4. Qué NO hacer todavía

Cosas que parecen urgentes y no lo son:

Tentación Por qué esperar
Construir más módulos Ninguna escuela lo usa aún. Cada módulo sin usuario es deuda
App nativa La web ya funciona en celular. Medir antes
Rediseñar la interfaz Está probada y consistente. Cambiarla sin retroalimentación real es opinión
Optimizar rendimiento Responde bien con 408 alumnos. Optimizar sin medir es adivinar
Microservicios, Kubernetes, cache distribuido Una escuela no genera ese tráfico. Complejidad sin beneficio
Automatizar el alta de escuelas con formulario de autoservicio Con menos de diez escuelas, un comando basta

5. Riesgos que veo

Riesgo Probabilidad Impacto Qué haría
Los docentes no adoptan el sistema Media Muy alto Capacitación presencial en el piloto; permitir capturar solo la calificación final sin desglose (ya soportado)
La boleta no cumple lo que pide la escuela Alta Alto Conseguir el formato real antes del piloto
Falla en la semana de captura Baja Muy alto Respaldos verificados, monitoreo, y estar disponible esa semana
La escuela pide colegiaturas como condición Media Medio Tener la estimación lista y decidir si entra en el trato
El on-premise se vuelve un costo operativo alto Media Medio Limitar a escuelas con alguien de sistemas, o cobrar el soporte aparte
Un competidor local con más recursos Media Medio El diferenciador es on-premise + precio en pesos + rapidez de respuesta

6. Lo que hay entregado

Contro-School/
├── README.md                    arranque en 5 minutos
├── docs/                        14 documentos + sitio HTML navegable
│   ├── 00-vision-y-prd.md
│   ├── 01-arquitectura.md
│   ├── 02-modelo-de-datos.md
│   ├── 03-multi-tenancy.md
│   ├── 04-roles-y-permisos.md
│   ├── 05-modelo-academico.md
│   ├── 06-diseno.md
│   ├── 07-instalacion-local.md
│   ├── 08-despliegue-saas.md
│   ├── 09-seguridad.md
│   ├── 10-api.md
│   ├── 11-movil-y-app-nativa.md
│   ├── 12-roadmap.md
│   ├── 13-operacion.md
│   └── 14-preguntas-y-siguientes-pasos.md   ← este
│
├── prisma/                      esquema, migración y datos de demostración
├── src/                         la aplicación
├── scripts/                     instalación, respaldo y pruebas
├── deploy/                      proxy y respaldos automáticos
├── Dockerfile
├── docker-compose.yml           desarrollo
├── docker-compose.onprem.yml    servidor de la escuela
└── docker-compose.saas.yml      school.atomtech.mx

Verificado:

  • Compilación de producción limpia, 40 rutas
  • 19 pruebas de la aritmética de calificaciones
  • 11 pruebas de integridad de datos (aislamiento por institución, escalas, recálculo contra lo guardado)
  • 61 pruebas de humo: rutas, permisos y bloqueos entre portales
  • 37 verificaciones de contenido renderizado
  • Imagen Docker de producción compilada y verificada (661 MB)
  • Datos de demostración: 2 planteles, 18 grupos, 408 alumnos, 5,472 calificaciones, asistencia de 4 semanas

Para leer la documentación con formato:

npm run docs:serve
DOCUMENTO 15

Publicar en school.atomtech.mx

Pasos concretos para poner Contro Escolar en línea sin tocar el sitio actual de Atom Tech


1. Lo que hay hoy

atomtech.mx 191.96.56.135 — Hostinger, hosting compartido, sitio estático con Apache
Nameservers ns1.dns-parking.com · ns2.dns-parking.com — el DNS se administra en hPanel de Hostinger
school.atomtech.mx No existe todavía

2. Lo que vamos a montar

                        atomtech.mx  ──────────▶  Hostinger compartido
                                                  (tu sitio actual, intacto)

              school.atomtech.mx  ──┐
                                    ├──────────▶  VPS con Contro Escolar
            *.school.atomtech.mx  ──┘             (Docker: app + Postgres + Caddy)

atomtech.mx no se toca. Solo se agregan dos registros DNS que apuntan a otro servidor. Si algo sale mal con el VPS, tu sitio principal ni se entera.


3. Por qué no puede ir en el hosting que ya tienes

Esto es lo primero que hay que decidir, y no tiene vuelta de hoja:

Contro Escolar necesita Hostinger compartido ofrece
Un proceso Node.js corriendo de forma permanente PHP por petición
PostgreSQL MySQL
Docker No disponible
Puertos y certificados por subdominio Gestión limitada

El hosting compartido está pensado para sitios estáticos y PHP. Contro Escolar es una aplicación con servidor propio y base de datos; necesita un VPS.

La buena noticia: el sitio de Atom Tech se queda exactamente donde está. El VPS solo atiende school.atomtech.mx y sus subdominios.

Cuál contratar

Como ya eres cliente de Hostinger, lo más simple es un VPS de Hostinger: mismo panel, misma factura, mismo soporte. Cualquier otro proveedor (DigitalOcean, Vultr, Hetzner) funciona igual — los pasos son los mismos a partir del punto 5.

Escuelas Alumnos totales Plan de referencia
1–10 hasta 3,000 2 vCPU · 8 GB RAM · 100 GB SSD
10–40 hasta 15,000 4–8 vCPU · 16 GB RAM · 200 GB SSD

Para arrancar con las primeras escuelas, el primero sobra. Al contratar, elige Ubuntu 24.04 LTS como sistema y guarda la IP que te asignen: la vas a necesitar en el paso 4.


4. Paso 1 · Los registros DNS en Hostinger

Entra a hPanel → Dominios → atomtech.mx → DNS / Nameservers → Administrar registros DNS y agrega estos dos:

Tipo Nombre Apunta a TTL
A school La IP de tu VPS 300
A *.school La misma IP 300

El segundo registro es indispensable. El comodín es lo que hace que cada escuela nueva viva en sucolegio.school.atomtech.mx sin que tengas que tocar el DNS ni reiniciar nada. Sin él solo funcionaría la portada.

Ojo con una trampa de hPanel: no crees school.atomtech.mx desde la sección Sitios web → Subdominios. Eso crearía una carpeta en el hosting compartido y apuntaría el subdominio ahí, ignorando tu VPS. Solo agrega los registros DNS de la tabla.

TTL de 300 segundos (5 minutos) mientras configuras, para que los cambios se propaguen rápido. Cuando todo funcione puedes subirlo a 3600.

Comprobar que propagó

Desde tu Mac, unos minutos después:

dig +short school.atomtech.mx
dig +short cualquier-cosa.school.atomtech.mx

Ambos deben devolver la IP de tu VPS. Si aún sale vacío, espera y repite; la propagación suele tardar entre 5 minutos y 1 hora.


5. Paso 2 · Preparar el servidor

Conéctate por SSH con los datos que te dio el proveedor:

ssh root@LA-IP-DE-TU-VPS

Y ejecuta:

# Sistema al día y hora de México
apt update && apt upgrade -y
timedatectl set-timezone America/Mexico_City

# Docker
curl -fsSL https://get.docker.com | sh

# Cortafuegos: solo SSH y web
ufw allow OpenSSH
ufw allow 80,443/tcp
ufw --force enable

Antes de seguir, si vas a operar esto en serio: crea un usuario que no sea root, agrégalo al grupo docker, sube tu llave SSH y desactiva el acceso por contraseña. Es media hora que se paga sola.


6. Paso 3 · Subir el código

mkdir -p /opt/contro && cd /opt/contro
git clone <URL-DE-TU-REPOSITORIO> escolar
cd escolar

Si el repositorio no está en línea todavía, súbelo por SFTP desde tu Mac:

# Desde tu Mac, en la carpeta del proyecto
rsync -az --exclude node_modules --exclude .next --exclude .env \
  ./ root@LA-IP-DE-TU-VPS:/opt/contro/escolar/

7. Paso 4 · Configurar

cd /opt/contro/escolar
cp .env.saas.example .env

# Genera los dos secretos y agrégalos
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
echo "AUTH_SECRET=$(openssl rand -base64 48)" >> .env

Abre .env (nano .env) y confirma que diga:

DEPLOYMENT_MODE=saas
ROOT_DOMAIN=school.atomtech.mx
RETENCION_DIAS=30

Guarda AUTH_SECRET fuera del servidor, en tu gestor de contraseñas. Si lo pierdes y lo regeneras, todas las sesiones abiertas se cierran — nada más se pierde, pero todo el mundo tiene que volver a entrar.


8. Paso 5 · Levantar

docker compose -f docker-compose.saas.yml up -d --build

La primera vez tarda entre 5 y 10 minutos: compila la imagen, genera la presentación y la documentación, y arranca la base de datos.

Después, crea el esquema:

docker compose -f docker-compose.saas.yml exec app npx prisma migrate deploy

Comprueba que todo esté arriba:

docker compose -f docker-compose.saas.yml ps
curl -I http://localhost/api/salud

9. Paso 6 · El certificado HTTPS

No hay que hacer nada: Caddy emite los certificados solo, la primera vez que alguien visita cada dirección.

Abre en tu navegador:

https://school.atomtech.mx

La primera carga tarda unos segundos mientras se emite el certificado. Después debe verse el candado y la portada comercial con la presentación.

Cómo se protege la emisión

Caddy pregunta a la aplicación antes de emitir cada certificado, para que nadie pueda apuntar un dominio ajeno a tu servidor y agotar la cuota de Let's Encrypt. Puedes verificar que esa protección funciona:

# La portada: autorizada
curl -s -o /dev/null -w "%{http_code}\n" \
  "http://localhost/api/tls/permitido?domain=school.atomtech.mx"      # 200

# Una escuela que no existe: rechazada
curl -s -o /dev/null -w "%{http_code}\n" \
  "http://localhost/api/tls/permitido?domain=inventada.school.atomtech.mx"  # 404

10. Paso 7 · Dar de alta la primera escuela

docker compose -f docker-compose.saas.yml exec app npm run instalar

El asistente pregunta nombre de la institución, identificador (el subdominio), primer plantel, ciclo escolar, escala de calificación y correo del administrador. Al terminar imprime la dirección y una contraseña temporal.

Anótala: solo se muestra una vez.

Esa escuela queda en suidentificador.school.atomtech.mx, con su propio certificado, sin tocar DNS ni reiniciar nada.

Para una demostración de ventas, conviene tener una escuela de ejemplo:

docker compose -f docker-compose.saas.yml exec app \
  env INST_SLUG=demo \
      INST_NOMBRE="Colegio Demostración" \
      INST_NIVEL=2 \
      INST_ADMIN_NOMBRE="Demostración" \
      INST_ADMIN_EMAIL=demo@atomtech.mx \
      INST_CONFIRMAR=1 \
      npm run instalar

11. Paso 8 · Correo saliente

Sin esto el sistema funciona, pero las contraseñas hay que entregarlas a mano (el panel las muestra en pantalla para imprimirlas). Para que se envíen solas, agrega a .env los datos de una cuenta de correo — puedes crear no-reply@atomtech.mx desde hPanel:

SMTP_HOST=smtp.hostinger.com
SMTP_PORT=587
SMTP_USER=no-reply@atomtech.mx
SMTP_PASSWORD=la-contraseña-de-esa-cuenta
SMTP_FROM=Contro Escolar <no-reply@atomtech.mx>

Y reinicia: docker compose -f docker-compose.saas.yml restart app

El envío de correo está declarado en la configuración pero el módulo aún no está construido — es uno de los pendientes de Roadmap §2. Deja los datos listos para cuando se active.


12. Paso 9 · Respaldos fuera del servidor

El sistema ya respalda solo, todos los días a las 2 de la mañana, en /opt/contro/escolar/backups. Eso no basta: si falla el disco del VPS, el respaldo se va con él.

Configura una copia hacia otro lado. Con rclone (funciona con Google Drive, Dropbox, S3 y demás):

apt install -y rclone
rclone config        # sigue el asistente para conectar tu nube

crontab -e
# Agrega esta línea:
0 4 * * * rclone sync /opt/contro/escolar/backups/ midestino:contro-escolar/

Los respaldos contienen datos personales de menores de edad: el destino debe estar cifrado y con acceso restringido. Ver Seguridad §7.


13. Verificación final

  • dig +short school.atomtech.mx devuelve la IP del VPS
  • dig +short loquesea.school.atomtech.mx devuelve la misma IP
  • https://school.atomtech.mx abre con candado y muestra la portada
  • La presentación se ve incrustada y avanza con las flechas
  • Los dos descargables se bajan y abren
  • https://demo.school.atomtech.mx entra al acceso de esa escuela
  • Puedes entrar con el administrador y te pide cambiar la contraseña
  • https://atomtech.mx sigue funcionando igual que siempre
  • AUTH_SECRET y POSTGRES_PASSWORD guardados fuera del servidor
  • Existe un archivo en backups/ y la copia externa está en cron
  • Restauración probada al menos una vez

14. Actualizar más adelante

cd /opt/contro/escolar

# 1. Respaldar SIEMPRE antes
docker compose -f docker-compose.saas.yml exec db \
  pg_dump -U contro contro_escolar | gzip > backups/pre-$(date +%F-%H%M).sql.gz

# 2. Traer los cambios y reconstruir
git pull
docker compose -f docker-compose.saas.yml up -d --build

# 3. Migraciones de base de datos
docker compose -f docker-compose.saas.yml exec app npx prisma migrate deploy

# 4. Verificar
curl -s https://school.atomtech.mx/api/salud

npm run build regenera la presentación y la documentación automáticamente, así que el material comercial de la portada nunca queda viejo.

Cuándo actualizar: en vacaciones o un viernes por la tarde. Nunca durante la semana de captura de calificaciones.


15. Problemas comunes

Síntoma Causa probable Solución
school.atomtech.mx no resuelve DNS sin propagar, o el registro se creó como subdominio de hosting Espera y revisa con dig; confirma que sea un registro A en la zona DNS
Sale el sitio de Atom Tech en vez de la portada El registro apunta a 191.96.56.135 Corrige el A de school a la IP del VPS
«No es seguro» / sin certificado El puerto 80 está cerrado, o el DNS aún no propaga ufw status; Let's Encrypt necesita el 80 abierto para validar
Una escuela nueva no tiene certificado Falta el registro comodín *.school Agrégalo en hPanel
Bind for 0.0.0.0:80 failed Otro servicio ocupa el puerto lsof -i :80 y detenlo
La app no arranca Falta AUTH_SECRET o POSTGRES_PASSWORD docker compose logs app lo dice explícitamente
Fechas corridas un día Zona horaria del servidor timedatectl set-timezone America/Mexico_City

Diagnóstico general

cd /opt/contro/escolar
docker compose -f docker-compose.saas.yml ps
docker compose -f docker-compose.saas.yml logs --tail=100 app
docker compose -f docker-compose.saas.yml logs --tail=50 proxy
df -h

16. Alternativa: sin VPS

Si por ahora prefieres no administrar un servidor, hay un camino intermedio: publicar solo la portada comercial (que es HTML estático) en el hosting que ya tienes, y dejar la aplicación para cuando haya una escuela contratada.

# En tu Mac
npm run presentacion
npm run docs:bundle

Sube por FTP a la carpeta del subdominio en Hostinger:

  • docs/presentacion/contro-escolar-presentacion.html
  • docs/contro-escolar-documentacion.html

Eso te da material para vender de inmediato, pero no la aplicación: para que una escuela use el sistema hace falta el VPS. Es una escala, no un destino.

DOCUMENTO 16

Subir la landing a school.atomtech.mx

Cómo crear el subdominio y publicar la portada comercial en el hosting que ya tienes


1. Qué vas a publicar

La carpeta landing/ que genera el proyecto: ~420 KB de archivos estáticos. HTML, CSS, un logotipo y una tipografía. Nada más.

landing/
├── index.html            la portada
├── presentacion.html     las 14 láminas
├── documentacion.html    los 17 documentos técnicos
├── icono.svg
├── .htaccess             HTTPS y caché
└── marca/
    └── hanken-grotesk-latin.woff2

Como es estático, cabe en tu hosting compartido de Hostinger. No necesitas VPS ni base de datos: eso solo hace falta el día que una escuela vaya a usar la aplicación de verdad.


2. El dominio está registrado en otro lado, y no importa

atomtech.mx está registrado en InterNetX (Alemania), no en Hostinger. Aun así, todo el subdominio se administra desde hPanel y no hay que tocar nada en el registrador.

La razón es que registrar un dominio y responder su DNS son dos cosas distintas, y ya están repartidas así:

Quién Qué controla Dónde está
Registrador La propiedad y la renovación del dominio InterNetX
Nameservers Quién contesta las consultas de DNS Hostinger (ns1/ns2.dns-parking.com)
Hosting Los archivos del sitio Hostinger

Cuando compraste el hosting, apuntaste los nameservers del dominio a Hostinger. Desde ese momento la zona DNS vive en hPanel: los registros A, el correo, las verificaciones de Google y Apple, todo. InterNetX solo guarda el apunte de «este dominio lo resuelve Hostinger».

Por eso crear school.atomtech.mx es un trámite de hPanel y nada más.

Compruébalo tú mismo

dig atomtech.mx NS +short
# ns1.dns-parking.com.
# ns2.dns-parking.com.      ← los de Hostinger

dig @ns1.dns-parking.com atomtech.mx +noall +comments | grep flags
# flags: qr aa rd           ← "aa" = respuesta autoritativa

Ese aa es la prueba: Hostinger no está reenviando la consulta, es la fuente de verdad del DNS de tu dominio.

Cuándo sí tendrías que entrar a InterNetX

Solo en dos casos, y ninguno aplica hoy:

  • Si quisieras mover el DNS a otro proveedor (Cloudflare, por ejemplo). Ahí cambiarías los nameservers.
  • Al renovar el dominio.

No cambies los nameservers en InterNetX. Si los tocas, se cae el sitio, el correo y todo lo demás de golpe.

Estado actual

atomtech.mx 191.96.56.135 — Hostinger, hosting compartido
Zona DNS En Hostinger, autoritativa
Correo mx1/mx2.hostinger.com con SPF configurado
school.atomtech.mx No existe todavía

3. Generar la landing

En tu Mac, dentro del proyecto:

npm run landing

Eso regenera la presentación, la documentación y arma la carpeta landing/.

Compruébala antes de subir nada:

npx serve landing

Abre http://localhost:3000 y revisa que se vea la portada, que la presentación avance dentro del marco y que los dos descargables bajen.


4. Crear el subdominio en Hostinger

El menú «Dominios» del panel principal NO es el correcto. Ese es el de registro y transferencia de dominios; ahí no hay subdominios. Los subdominios viven dentro del panel del sitio web.

La ruta correcta

  1. hPanel → menú lateral Sitios web
  2. Localiza atomtech.mx en la lista y entra con Administrar (o Panel)
  3. Ya dentro del sitio, en el menú lateral: Dominios → Subdominios
  4. En Crear un nuevo subdominio:
Campo Valor
Subdominio school
Dominio atomtech.mx
Carpeta personalizada déjala como viene
  1. Crear

Hostinger hace tres cosas solo: crea el registro DNS, crea la carpeta en el servidor y habilita el subdominio.

Si aun así no aparece «Subdominios»

Casi siempre es el plan: el plan Single de Hostinger no incluye subdominios (Premium permite 100 y Business 300). Compruébalo en hPanel → Inicio, donde dice qué plan tienes.

Con plan Single hay dos salidas:

Salida Qué implica
Subir a Premium Es lo que quieres si vas a usar school.atomtech.mx en serio
Publicar en una subcarpeta La landing queda en atomtech.mx/school, sin subdominio

La subcarpeta funciona hoy mismo y no cuesta nada: subes el contenido de landing/ a /public_html/school del sitio principal y queda en línea. La dirección es menos vistosa para vender, pero el material es el mismo.

Un registro DNS por sí solo no basta en hosting compartido: hace falta que el servidor sepa qué carpeta servir para ese nombre, y eso es justo lo que hace la función de subdominios. Por eso «Administrar DNS» no sustituye a este paso.

Anota la carpeta que te muestra. Normalmente es:

/public_html/school

o bien

/domains/school.atomtech.mx/public_html

Varía según la antigüedad de la cuenta; usa la que aparezca en pantalla.

Si más adelante montas la aplicación en un VPS, tendrás que borrar este subdominio y crear en su lugar registros DNS A apuntando al VPS. Están los pasos en Publicar la aplicación. Por ahora, para solo la landing, esta es la vía correcta y más simple.


5. Subir los archivos

Opción A · Administrador de archivos de hPanel

La más rápida si no quieres instalar nada.

  1. Comprime el contenido de landing/ (no la carpeta, lo de dentro):

    cd landing && zip -r ../landing.zip . && cd ..
    
  2. En hPanel: Archivos → Administrador de archivos

  3. Entra a la carpeta del subdominio del paso 4

  4. Subir archivos → landing.zip

  5. Clic derecho sobre el zip → Extraer

  6. Borra el zip

El administrador de archivos oculta los archivos que empiezan con punto. Activa Mostrar archivos ocultos para confirmar que .htaccess quedó arriba; sin él no se fuerza HTTPS.

Opción B · FTP desde tu Mac

Mejor si vas a actualizar la landing seguido. Los datos de FTP están en hPanel → Archivos → Cuentas FTP.

brew install lftp    # solo la primera vez

lftp -u USUARIO_FTP,CONTRASEÑA ftp://ftp.atomtech.mx -e "
  mirror -R --delete --verbose landing/ /public_html/school;
  bye"

mirror -R sube, --delete borra en el servidor lo que ya no exista en local. Ajusta la ruta destino a la que te dio hPanel.


6. Activar el certificado HTTPS

  1. hPanel → Seguridad → SSL
  2. Busca school.atomtech.mx en la lista
  3. Instalar SSL (si no se instaló solo)

Tarda entre unos minutos y una hora. Mientras tanto el sitio funciona por HTTP.


7. Comprobar

Abre https://school.atomtech.mx y verifica:

  • Se ve la portada con el logotipo de Atom Tech
  • La tipografía es Hanken Grotesk, no la del sistema
  • La presentación se ve dentro del marco y avanza con las flechas
  • «Abrir a pantalla completa» abre presentacion.html
  • Los dos descargables se bajan y abren
  • Se ve bien en el celular
  • Hay candado de HTTPS
  • https://atomtech.mx sigue funcionando igual que siempre

Desde la terminal:

curl -sI https://school.atomtech.mx | head -3
dig +short school.atomtech.mx        # debe dar 191.96.56.135

8. Actualizar la landing

Cada vez que cambies un texto de la presentación o de la portada:

npm run landing

Y vuelve a subir el contenido de landing/. Con lftp es un comando:

lftp -u USUARIO_FTP,CONTRASEÑA ftp://ftp.atomtech.mx -e "
  mirror -R --delete landing/ /public_html/school; bye"

9. Cuando la aplicación esté lista

Hoy la portada no tiene botón de «Entrar a mi escuela»: sin aplicación publicada, un botón que lleva a un error vende en contra.

El día que montes la aplicación en un VPS, regenera con la URL:

URL_APP=https://app.school.atomtech.mx npm run landing

Eso agrega el botón de acceso y la nota del pie para las escuelas que ya usan el sistema.

Tendrás que decidir cómo repartir los nombres. Dos caminos:

Landing Aplicación
A school.atomtech.mx (compartido) app.school.atomtech.mx (VPS)
B atomtech.mx/school o similar school.atomtech.mx (VPS)

La A no rompe nada de lo que hagas hoy: la landing se queda donde está y la aplicación entra por otro subdominio. La B es más limpia de cara al cliente —cada escuela en sucolegio.school.atomtech.mx— pero obliga a mover la landing después.

Si crees que vas a vender pronto, la B te ahorra una mudanza. Si quieres material en línea esta semana, arranca con la A.


10. Problemas comunes

Síntoma Causa probable Solución
«No se encontró el sitio» DNS sin propagar Espera 15–30 min; revisa con dig +short school.atomtech.mx
Sale un listado de archivos Falta index.html en la raíz, o no se extrajo bien el zip Verifica que index.html esté al primer nivel de la carpeta
La letra se ve distinta .htaccess no se subió, o el servidor no reconoce .woff2 Activa «Mostrar archivos ocultos» y confirma que esté; el .htaccess ya declara el tipo
El marco de la presentación sale vacío Falta presentacion.html Vuelve a subir la carpeta completa
El logotipo no aparece Estás viendo una versión vieja: el logotipo ahora va dentro del index.html Vuelve a subir index.html y recarga con ⌘⇧R
«No es seguro» SSL sin instalar hPanel → Seguridad → SSL
Cambié algo y sigo viendo lo viejo Caché del navegador Recarga con ⌘⇧R