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.
# 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
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
Entra como docente → abre una materia → captura calificaciones → publica
Entra como tutor → mira la boleta de su hijo
Entra como control escolar → Boletas → imprime un grupo completo
Cambia entre plantel Primaria y Secundaria en la barra superior
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 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
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.
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):
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.
La boleta se arma dos veces. Una en el sistema oficial (SIGED / control
escolar estatal) y otra "bonita" para entregar a los padres.
Los expedientes están en papel. Cuando se necesita el teléfono de un
tutor durante una emergencia, alguien corre al archivero.
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.
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.
Hay escuelas que no quieren su información en la nube. Sea por política
interna, por desconfianza o porque su internet es malo.
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
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.
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.
Nunca perder una calificación. Toda modificación queda en bitácora con
autor, fecha, valor anterior y valor nuevo.
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.
Funciona sin internet perfecto. Sin fuentes remotas, sin CDNs, sin
dependencias externas en tiempo de ejecución.
Español mexicano, no traducciones. "Boleta", "grupo", "control escolar",
"CCT", "tutor". El vocabulario que ya usan.
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)
Ventaja competitiva frente a las plataformas internacionales: precio en pesos,
vocabulario mexicano, y la opción on-premise que ninguna de ellas ofrece.
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.
Sesión en JWT dentro de cookie httpOnly
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:
Cada criterio ("Examen 40%") vale su peso.
Dentro del criterio, el logro es puntos obtenidos / puntos posibles de las
actividades capturadas.
Una actividad sin capturar no cuenta ni a favor ni en contra.
NE (no entregó) sí cuenta como cero sobre los puntos máximos.
Si un criterio no tiene nada capturado, su peso se reparte entre los demás
(re-normalización sobre el peso efectivo).
Las actividades sin criterio se agrupan con el peso restante hasta 100%.
El porcentaje resultante se mapea a la escala del plantel:
escalaMin + (porcentaje/100) × (escalaMax − escalaMin).
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.
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.
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
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.
Si termina en .school.atomtech.mx, el prefijo es el slug.
En desarrollo también funciona colegio.localhost:3000.
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).
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.
// 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:
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:
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.
Las tres barreras de aplicación cubren el modelo de amenaza actual.
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):
Esperar el periodo de retención acordado en el contrato.
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:
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:
Hay sesión con institución.
La asignación existe y es de esta institución.
El periodo pertenece al ciclo de esa asignación.
Quien pide es el docente de esa asignación, o es staff con
calificacion.publicar.
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
Editar PERMISSIONS en src/lib/rbac.ts.
Si es un permiso nuevo, agregarlo al tipo Permission.
TypeScript señalará cualquier uso inconsistente.
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:
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.
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:
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
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.
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.
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".
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.
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):
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
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.
docker compose -f docker-compose.onprem.yml exec app npm run instalar
El asistente pregunta:
Nombre de la institución
Identificador (debe coincidir con ONPREM_TENANT_SLUG)
Primer plantel: nivel, nombre, clave, CCT, número de grados
Ciclo escolar: nombre, fechas, cuántos periodos
Escala de calificación
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:
Materias — el catálogo del plantel, marcando en qué grados se imparte cada una
Docentes — el personal, creando su acceso al portal en el mismo paso
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
Asignaciones — asignar el docente de cada materia
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:
Registro DNS: contro.miescuela.edu.mx → IP pública de la escuela
Redirección de puertos 80 y 443 en el router hacia el servidor
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.
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.
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>
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.
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:
Middleware — rechaza peticiones sin sesión válida antes de tocar la base
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)
⚠️ 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
Contrato de prestación de servicios con cláusula de tratamiento de datos.
Plantilla de aviso de privacidad para que la escuela la adapte.
Procedimiento documentado de atención a derechos ARCO.
Procedimiento de notificación de vulneraciones (plazos y destinatarios).
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
Contener — suspender la cuenta comprometida (activo = false).
Invalidar sesiones — rotar AUTH_SECRET y reiniciar: cierra todas las
sesiones del sistema. Es agresivo pero inmediato.
Investigar — AuditLog filtrado por usuario y rango de fechas; registros
de acceso de Caddy en /data/acceso.log.
Evaluar el alcance — qué datos pudo ver o modificar esa cuenta según su rol.
Notificar — a la escuela afectada, con hechos y alcance. Si hubo
exposición de datos personales, la escuela debe notificar a los titulares.
Corregir — cerrar el hueco, agregar la prueba que lo hubiera detectado.
Si se pierden datos
Detener la aplicación (no la base).
Identificar el último respaldo bueno.
Restaurar en un servidor de pruebas y verificar.
Restaurar en producción.
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:
Cada persona tiene su cuenta. Compartir la de dirección hace imposible
saber quién cambió qué.
Al salir un empleado, se desactiva su cuenta el mismo día. Toma diez
segundos en Panel · Usuarios.
Los respaldos deben salir del servidor. Si el disco falla y el respaldo
estaba ahí, se pierde todo.
Probar la restauración una vez por ciclo. Un respaldo nunca probado no es
un respaldo.
El aviso de privacidad es responsabilidad de la escuela, y debe incluir
los datos de salud que se capturan en el expediente.
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.
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.
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:
Las escuelas piden notificaciones push en iOS con fiabilidad de app nativa.
Se necesita la cámara para tomar la foto del alumno en el momento de la
inscripción.
Marketing: estar en las tiendas es un argumento de venta que cierra tratos.
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.
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
API REST — los endpoints /api/mi/… bosquejados en API §4.
Autenticación por token — refresh token de larga duración; nadie quiere
escribir su contraseña cada 12 horas en el celular.
Push — tabla de tokens de dispositivo, integración con FCM y APNs.
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.
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:
¿Bloquea a un cliente que ya paga? → primero.
¿Es un riesgo de seguridad o de pérdida de datos? → primero.
¿Cuántas escuelas lo pidieron? → más peso que la opinión interna.
¿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.
¿Se puede resolver con documentación en vez de código? → hacer eso primero.
¿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.
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.
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:
Volcado SQL completo de sus datos
CSV de alumnos, calificaciones y asistencia
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
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.
Responder P1 y P2. Todo lo demás se ordena mejor con esas dos.
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.
atomtech.mxno 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.
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
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.
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:
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:
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
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:
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
hPanel → menú lateral Sitios web
Localiza atomtech.mx en la lista y entra con Administrar (o Panel)
Ya dentro del sitio, en el menú lateral: Dominios → Subdominios
En Crear un nuevo subdominio:
Campo
Valor
Subdominio
school
Dominio
atomtech.mx
Carpeta personalizada
déjala como viene
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.
Comprime el contenido de landing/ (no la carpeta, lo de dentro):
cd landing && zip -r ../landing.zip . && cd ..
En hPanel: Archivos → Administrador de archivos
Entra a la carpeta del subdominio del paso 4
Subir archivos → landing.zip
Clic derecho sobre el zip → Extraer
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
hPanel → Seguridad → SSL
Busca school.atomtech.mx en la lista
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:
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