Manual Técnico
Documentación técnica completa: esquema de base de datos, modelos Prisma, relaciones, autenticación, capa de API y arquitectura de despliegue.
Índice de contenidos
Sección 01
Stack tecnológico
TimeSchool es una aplicación web full-stack con renderizado en servidor. Toda la lógica de negocio vive en el servidor; el cliente recibe HTML hidratado o respuestas JSON.
Framework
- — Next.js 16.2 (App Router)
- — React 19
- — TypeScript 5
Base de datos
- — PostgreSQL (Supabase)
- — Prisma 7 ORM
- — @prisma/adapter-pg (pgbouncer)
Autenticación
- — Supabase Auth (JWT)
- — @supabase/ssr 0.10
- — RLS a nivel de PostgreSQL
Frontend
- — TailwindCSS v4
- — shadcn/ui + Radix
- — SWR (client fetching)
- — lucide-react
- — Resend (prioridad)
- — Gmail SMTP (fallback)
- — SMTP genérico
- — Console (dev)
IA
- — Anthropic Claude SDK 0.100
- — claude-haiku-4-5
- — Structured output
Utilidades
- — jsPDF + jspdf-autotable
- — ExcelJS (import XLSX)
- — Driver.js (tours)
- — date-fns
Testing
- — Vitest
- — Unit tests en /tests
- — Sin tests E2E por ahora
Sección 02
Variables de entorno
Todas las variables se definen en .env.local (desarrollo) o en los secrets de Vercel/Railway (producción). Ninguna variable SUPABASE_SERVICE_ROLE_KEY o GEMINI_API_KEY debe exponerse al cliente.
# ── Base de datos ────────────────────────────────────────────────────
DATABASE_URL=postgresql://...@pooler.supabase.com:6543/postgres?pgbouncer=true
# Usado por Prisma Migrate y prisma db push (session mode, sin pgbouncer)
DIRECT_URL=postgresql://...@db.supabase.com:5432/postgres
# ── Supabase Auth ─────────────────────────────────────────────────────
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... # Clave pública — OK en cliente
SUPABASE_SERVICE_ROLE_KEY=eyJ... # ⚠ Solo servidor — NUNCA al cliente
# ── IA ────────────────────────────────────────────────────────────────
GEMINI_API_KEY=AIza... # ⚠ Solo servidor
# ── Email (prioridad: Resend > Gmail > SMTP > Console) ────────────────
RESEND_API_KEY=re_...
GMAIL_USER=centro@gmail.com
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
SMTP_HOST=mail.ies.es
SMTP_PORT=587
SMTP_USER=noreply@ies.es
SMTP_PASS=...
SMTP_SECURE=false
EMAIL_FROM=noreply@timeschool.es
# ── App ───────────────────────────────────────────────────────────────
NEXT_PUBLIC_SITE_URL=https://timeschool.es
NEXT_PUBLIC_APP_URL=https://timeschool.esDATABASE_URL apunta al pooler de Supabase (puerto 6543, transaction mode). DIRECT_URL apunta al puerto directo (5432). Prisma usa DATABASE_URL en runtime y DIRECT_URL en migraciones vía prisma.config.ts.
Sección 03
Arquitectura de la BBDD
TimeSchool usa PostgreSQL alojado en Supabase con Prisma 7 como ORM. El adaptador @prisma/adapter-pg permite usar el pooler de Supabase (pgbouncer en transaction mode) sin cambiar la sintaxis de Prisma.
Tablas del sistema
| Tabla (modelo Prisma) | Propósito | Tipo de PK |
|---|---|---|
| Teacher | Ficha de profesor + credenciales Auth + RBAC | cuid() |
| Subject | Asignatura: horas/semana, color, tipo de aula | cuid() |
| ClassGroup | Grupo de clase: curso, tutor, franja horaria | cuid() |
| SubjectClassGroup | Join N:M Subject ↔ ClassGroup con profesor override | cuid() + unique(subjectId, classGroupId) |
| Student | Alumno individual (datos RGPD — solo ADMIN) | cuid() |
| Room | Aula física: capacidad y tipo | cuid() |
| Lesson | Agrupación lógica: quién enseña qué a quién | cuid() |
| Schedule | Instancia de horario (DRAFT/ACTIVE/ARCHIVED) | cuid() |
| ScheduleSlot | Cuándo ocurre una Lesson: día + hora + ciclo | cuid() + unique(scheduleId, dayOfWeek, startHour, lessonId) |
| TimeFrame | Marco horario: periodos/día, hora inicio, recreos | cuid() |
| SchedulingConfig | Singleton de pesos del optimizador SA | id="default" |
| Holiday | Días festivos/no lectivos | cuid() |
| Absence | Ausencia de profesor: fecha, horas, motivo | cuid() |
| Substitution | Guardia asignada: sustituto + hora + aula | cuid() |
| SupervisionZone | Zona de recreo (patio, entrada…) | cuid() + unique(name) |
| BreakSupervision | Asignación profesor → zona por día/recreo | cuid() + unique(scheduleId, dayOfWeek, breakIndex, zoneId) |
| SubjectBlock | Bloque de optativas que van en paralelo | cuid() |
| Department | Departamento didáctico | cuid() + unique(name) |
| DepartmentMembership | Join N:M Teacher ↔ Department con rol | cuid() + unique(teacherId, departmentId) |
Convenciones generales
IDs: todos usan cuid() (Collision-resistant Unique ID). Seguro para URLs, sin colisiones entre entornos. Excepción: SchedulingConfig.id = "default" (singleton).
Timestamps: createdAt DateTime @default(now()) en casi todos los modelos. updatedAt DateTime @updatedAt en entidades que cambian frecuentemente (Teacher, Schedule, TimeFrame, Department, SchedulingConfig).
Soft delete: Teacher usa isActive Boolean @default(true). No hay borrado lógico en otras entidades.
JSON fields: availability, preferredHours (Teacher), breaks, dayOverrides (TimeFrame) se almacenan como Json de PostgreSQL. Validación a nivel de aplicación, no de BBDD.
Sección 04
Enumeraciones (Enums)
Prisma genera enums de PostgreSQL nativos. Cambiar un valor de enum en producción requiere migración manual.
UserRole
Rol del profesor en el sistema. Determina qué rutas y acciones puede ejecutar.
DeptRole
Rol dentro de un departamento. JEFE: único por departamento activo.
RoomType
Tipo de aula. El scheduler filtra aulas por tipo para cada Subject.roomType.
ScheduleStatus
Estado del horario. Solo un horario puede estar ACTIVE al mismo tiempo.
WeekCycle
Ciclo de semana de un ScheduleSlot. Permite horarios alternos (semana A/B).
DayOfWeek
Día de la semana para slots y supervisiones. Solo días lectivos (L–V).
Sección 05
Modelo Teacher
Tabla central del sistema. Unifica la ficha académica del profesor con sus credenciales de acceso (Supabase Auth) y su rol RBAC. Un registro Teacher puede existir antes de que el profesor tenga cuenta de acceso (authUserId = null).
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único del profesor. |
| name | String | — | — | Nombre completo. |
| String | — | — | Correo electrónico. Único. Usado como fallback de vinculación Auth. | |
| maxHoursWeek | Int | — | 20 | Máximo de horas lectivas semanales. El scheduler respeta este límite. |
| maxSupervisionsWeek | Int | — | 2 | Máximo de guardias de recreo por semana. |
| maxConsecutiveHours | Int | — | 3 | Máximo de periodos consecutivos sin descanso. |
| maxGapsPerWeek | Int | — | 2 | Máximo de huecos (periodos libres entre clases) semanales permitidos. |
| minActiveDays | Int | — | 1 | Mínimo de días con al menos una clase a la semana. |
| maxActiveDays | Int | — | 5 | Máximo de días con clase. Permite concentrar jornada. |
| minRestHoursBetweenDays | Int | — | 11 | Horas mínimas entre última clase de un día y primera del siguiente (normativa laboral). |
| maxTeachingHoursPerDay | Int | — | 5 | Máximo de periodos lectivos en un mismo día. |
| preferredHours | Json | — | {} | Mapa { DayOfWeek: number[] } con valores -2..2. -2=imposible, -1=no deseado, 0=neutral, 1=deseado, 2=muy deseado. Vacío → se deriva de availability. |
| availability | Json | — | {} | Mapa { DayOfWeek: number[] } con valores -1/0/1. 1=disponible, 0=no preferido, -1=no disponible. Longitud = periodsPerDay del TimeFrame. |
| createdAt | DateTime | — | now() | Fecha de creación del registro. |
| updatedAt | DateTime | — | @updatedAt | Última modificación (auto-gestionado por Prisma). |
| authUserId | String? | ✓ | — | UUID de auth.users en Supabase. Null hasta el primer login SSO (JIT linking). |
| role | UserRole | — | TEACHER | Rol efectivo para RBAC. Determina el nivel de acceso al sistema. |
| isActive | Boolean | — | true | Soft delete. false = no puede acceder aunque tenga authUserId válido. |
| charges | String[] | — | [] | Etiquetas decorativas (COORD_TIC, COORD_IGUALDAD…). Sin lógica de negocio, solo visualización. |
| tutorialsSeen | String[] | — | [] | IDs de tours interactivos completados. Persiste entre dispositivos. |
Índices
@@index([email]) · @@index([role]) · @unique en email y authUserId
Sección 06
Modelo Subject
Define una asignatura académica con sus parámetros de scheduling. La relación con grupos de clase es N:M a través de SubjectClassGroup (join table explícita). La relación con el profesor "por defecto" es directa, pero puede sobrescribirse por grupo en SubjectClassGroup.teacherId.
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| name | String | — | — | Nombre de la asignatura. |
| hoursPerWeek | Int | — | 3 | Número de periodos lectivos semanales que debe tener cada grupo. |
| color | String | — | #6366f1 | Color HEX para visualización en el grid del horario. |
| maxDailyLessonsPerSubject | Int | — | 1 | Máximo de sesiones de esta asignatura en un mismo día por grupo. El penalizador SA lo usa. |
| spacingFactor | Int | — | 3 | Días mínimos deseados entre sesiones de la misma asignatura. Peso en penalizador de espaciado. |
| roomType | RoomType? | ✓ | — | Tipo de aula requerida. null = heurística legacy por nombre. |
| needsDoublePeriod | Boolean? | ✓ | — | Si true: el scheduler DEBE colocar al menos un bloque de 2h consecutivas. |
| allowDoublePeriod | Boolean? | ✓ | — | Si true: no penaliza 2 sesiones el mismo día. null = heurística por nombre. |
| teacherId | String? | ✓ | — | FK a Teacher. Profesor predeterminado. Puede sobrescribirse en SubjectClassGroup. |
| departmentId | String? | ✓ | — | FK a Department. onDelete: SetNull (si se borra el dpto, queda null). |
Sección 07
Modelo ClassGroup
Representa un grupo de clase (ej. 1ºA ESO). Es la unidad central del scheduling junto con Teacher y Subject. Cada slot del horario pertenece a una Lesson que referencia uno o más ClassGroups.
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| name | String | — | — | Nombre del grupo (ej. "1ºA ESO", "2ºBACH-B"). |
| grade | Int | — | — | Curso numérico (1, 2, 3, 4…). Usado por SubjectBlock para agrupar por nivel. |
| studentCount | Int | — | 25 | Número de alumnos. Usado para validar capacidad de aulas. |
| createdAt | DateTime | — | now() | Fecha de creación. |
| roomId | String? | ✓ | — | FK a Room. Aula base del grupo. onDelete: SetNull. |
| tutorId | String? | ✓ | — | FK a Teacher. Tutor del grupo. onDelete: SetNull. |
| timeFrameId | String? | ✓ | — | FK a TimeFrame. null = usa el TimeFrame marcado como isDefault. |
Sección 08
Modelo Room
Espacio físico del centro. El scheduler asigna aulas a las Lessons según el roomType de la Subject. Un Room puede aparecer en múltiples Lessons simultáneas en ciclos de semana distintos (A/B), pero no en el mismo día/hora.
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| name | String | — | — | Nombre del espacio (ej. "Laboratorio 1", "Pista deportiva"). |
| capacity | Int | — | 30 | Aforo máximo. Se compara con ClassGroup.studentCount. |
| type | RoomType | — | CLASSROOM | Tipo de espacio. Debe coincidir con Subject.roomType para la asignación. |
Sección 09
Modelo Lesson
Una Lesson es la unidad de programación: agrupa quién enseña qué a quién y dónde. Es un modelo N:M que permite representar desdobles, codocencia y grupos mixtos sin duplicar datos.
// Caso simple: 1 profesor · 1 asignatura · 1 grupo · 1 aula
Lesson {
teachers: [AnaGarcía]
subjects: [Matemáticas]
classGroups: [1ºA ESO]
rooms: [Aula 12]
}
// Desdoble: mismo grupo dividido en dos subgrupos simultáneos
Lesson {
teachers: [ProfeA, ProfeB]
subjects: [Inglés, Inglés]
classGroups: [2ºA ESO]
rooms: [Aula 3, Aula 5]
}
// Grupo mixto / optativa
Lesson {
teachers: [ProfeC]
subjects: [TIC]
classGroups: [4ºA ESO, 4ºB ESO] // alumnos de dos grupos
rooms: [Aula Informática 1]
}| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| name | String? | ✓ | — | Nombre descriptivo opcional. Si es null, se deriva de sus subjects en la UI. |
| createdAt | DateTime | — | now() | Fecha de creación. |
Lesson no tiene FK directas a Teacher/Subject/Room/ClassGroup. Todas esas relaciones son N:M implícitas de Prisma (tablas intermedias sin nombre propio). Para consultas de rendimiento, usar include selectivo.
Sección 10
Modelos Schedule y ScheduleSlot
Schedule — Instancia de horario
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| name | String | — | — | Nombre descriptivo (ej. "Horario 2024-25 Q1"). |
| academicYear | String | — | — | Año académico (ej. "2024-2025"). Solo informativo. |
| status | ScheduleStatus | — | DRAFT | Estado del horario. Solo uno puede ser ACTIVE. El paso a ACTIVE archiva el anterior. |
| createdAt | DateTime | — | now() | Fecha de creación. |
| updatedAt | DateTime | — | @updatedAt | Última modificación. |
ScheduleSlot — Posición temporal de una Lesson
Un ScheduleSlot responde a "¿cuándo?" ocurre una Lesson dentro de un Schedule. Toda información sobre quién, qué, dónde está en la Lesson y sus relaciones N:M.
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| dayOfWeek | DayOfWeek | — | — | Día de la semana (MONDAY…FRIDAY). |
| startHour | Int | — | — | Periodo de inicio 0-based (0 = 1ª hora). Nota: el campo se llama "Hour" pero representa periodos. |
| endHour | Int | — | — | Periodo final (excluido). Para clase de 1 periodo: endHour = startHour + 1. |
| notes | String? | ✓ | — | Notas libres visibles en el editor. |
| createdAt | DateTime | — | now() | Fecha de creación. |
| locked | Boolean | — | false | Si true: el slot no se borra al regenerar ni lo mueve el optimizador SA. |
| weekCycle | WeekCycle | — | EVERY_WEEK | Ciclo de semana. EVERY_WEEK: siempre. WEEK_A/WEEK_B: semanas alternas. |
| isBreak | Boolean | — | false | Marca el slot como recreo (bloque decorativo, no clase). |
| isSubstituteDuty | Boolean | — | false | Marca el slot como guardia de sustitución (generado por el módulo de ausencias). |
| scheduleId | String | — | — | FK a Schedule. onDelete: Cascade. |
| lessonId | String | — | — | FK a Lesson. onDelete: Cascade. |
Restricción única
@@unique([scheduleId, dayOfWeek, startHour, lessonId])
// Un schedule no puede tener la misma lesson dos veces el mismo día a la misma hora.
// No impide que dos lessons distintas coincidan (colisión detectada por la app, no por la BBDD).Sección 11
Modelos de Ausencias y Supervisiones
Absence — Ausencia de profesor
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| teacherId | String | — | — | FK a Teacher (profesor ausente). Sin onDelete (no borrar ausencias al desactivar profesor). |
| date | DateTime | — | — | Fecha de la ausencia. Se almacena como medianoche UTC (solo la parte de fecha es relevante). |
| startHour | Int | — | — | Primer periodo afectado (0-based, igual que ScheduleSlot.startHour). |
| endHour | Int | — | — | Último periodo afectado + 1. |
| reason | String? | ✓ | — | Motivo de la ausencia (Enfermedad, Formación, Permiso, Otro). |
| resolved | Boolean | — | false | true cuando todas las horas de la ausencia han sido cubiertas. |
| absenceGroupId | String? | ✓ | — | UUID generado en la app para agrupar ausencias creadas desde un rango de fechas. null = ausencia individual. |
| createdAt | DateTime | — | now() | Fecha de registro. |
Substitution — Guardia asignada
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| absenceId | String | — | — | FK a Absence. onDelete: Cascade (si se borra la ausencia, se borra la guardia). |
| originalTeacherId | String | — | — | FK a Teacher (quien falta). Relación nombrada "OriginalTeacher". |
| substituteTeacherId | String | — | — | FK a Teacher (quien cubre). Relación nombrada "SubstituteTeacher". |
| roomId | String? | ✓ | — | FK a Room. Aula donde se realiza la guardia (puede diferir del horario normal). |
| date | DateTime | — | — | Fecha de la guardia. |
| hour | Int | — | — | Periodo de la guardia (0-based). |
| createdAt | DateTime | — | now() | Fecha de asignación. |
SupervisionZone y BreakSupervision
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| SupervisionZone.id | String | — | cuid() | ID de la zona. |
| SupervisionZone.name | String | — | — | Nombre único de la zona (patio, entrada…). |
| SupervisionZone.description | String? | ✓ | — | Descripción de la ubicación. |
| BreakSupervision.scheduleId | String | — | — | FK a Schedule. onDelete: Cascade. |
| BreakSupervision.teacherId | String | — | — | FK a Teacher. onDelete: Cascade. |
| BreakSupervision.zoneId | String | — | — | FK a SupervisionZone. onDelete: Cascade. |
| BreakSupervision.dayOfWeek | DayOfWeek | — | — | Día de la semana. |
| BreakSupervision.breakIndex | Int | — | — | Índice 0-based del recreo (0 = primer recreo del día). |
Sección 12
Modelos de Configuración y TimeFrame
SchedulingConfig — Singleton de pesos SA
Siempre existe exactamente un registro con id = "default". Los pesos van de 0 (ignorar) a 5 (restricción casi dura).
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| weightConsecutiveHours | Int | — | 3 | Penalización por horas consecutivas sin descanso del profesor. |
| weightGaps | Int | — | 4 | Penalización por huecos libres en medio de la jornada del profesor. |
| weightPreferences | Int | — | 3 | Penalización por ignorar preferencias horarias del profesor. |
| weightStudentGaps | Int | — | 5 | Penalización por huecos libres del alumno (normativa legal). |
| weightDoublePeriods | Int | — | 2 | Bonificación por respetar/crear bloques dobles. |
| weightDailyHours | Int | — | 3 | Penalización por exceder el máximo diario de una asignatura. |
| weightHomeRoom | Int | — | 2 | Penalización por usar aulas distintas al aula base del grupo. |
| weightActiveDays | Int | — | 3 | Penalización por exceder/no alcanzar los días activos deseados. |
| weightSpacing | Int | — | 3 | Penalización por concentrar la misma asignatura en días consecutivos. |
| weightRoomStability | Int | — | 2 | Penalización por cambiar de aula para la misma asignatura en distintos días. |
| weightMinRest | Int | — | 5 | Penalización por incumplir el descanso mínimo de 11h entre jornadas. |
| saIterations | Int | — | 12000 | Número máximo de iteraciones del Simulated Annealing. Rango: 1.000–500.000. |
TimeFrame — Marco horario
| Campo | Tipo | Null | Default | Descripción |
|---|---|---|---|---|
| id | String | — | cuid() | Identificador único. |
| name | String | — | — | Nombre único (ej. "ESO Mañana"). |
| periodsPerDay | Int | — | 7 | Número de periodos lectivos por día base. Rango: 1–10. |
| startTime | String | — | "08:00" | Hora de inicio del primer periodo en formato HH:MM. |
| periodDuration | Int | — | 55 | Duración en minutos de cada periodo lectivo. |
| breaks | Json | — | [] | Array<{ afterPeriod: number; duration: number; label?: string }>. afterPeriod es 1-based. |
| dayOverrides | Json | — | {} | { DayOfWeek: { periodsPerDay?, startTime?, periodDuration?, breaks? } }. Permite horarios distintos por día. |
| isDefault | Boolean | — | false | Marco predeterminado del centro. Los grupos sin timeFrameId lo usan. Solo uno puede ser true. |
| createdAt | DateTime | — | now() | Fecha de creación. |
| updatedAt | DateTime | — | @updatedAt | Última modificación. |
Ejemplo de breaks: [{"afterPeriod":3,"duration":25,"label":"Recreo"}]
Ejemplo de dayOverrides: {"WEDNESDAY":{"periodsPerDay":5}}
Sección 13
Relaciones entre tablas
El diagrama a continuación muestra las relaciones principales. Las líneas sólidas son FK directas; las punteadas son N:M implícitas gestionadas por Prisma.
Teacher ──1:N──► Subject (teacherId → Subject.teacherId)
Teacher ──1:N──► ClassGroup (como tutor: tutorId)
Teacher ──N:M──► Lesson (tabla implícita _LessonToTeacher)
Teacher ──N:M──► Department (a través de DepartmentMembership)
Teacher ──1:N──► Absence (AbsentTeacher)
Teacher ──1:N──► Substitution (OriginalTeacher + SubstituteTeacher)
Teacher ──1:N──► BreakSupervision
Teacher ──1:N──► SubjectClassGroup (override de profesor por grupo)
Subject ──N:M──► ClassGroup (a través de SubjectClassGroup)
Subject ──N:M──► Lesson (tabla implícita _LessonToSubject)
Subject ──N:M──► SubjectBlock (tabla implícita _SubjectToSubjectBlock)
Subject ──N:1──► Department (departmentId)
ClassGroup ──N:1──► TimeFrame (timeFrameId)
ClassGroup ──N:1──► Room (aula base: roomId)
ClassGroup ──N:M──► Lesson (tabla implícita _ClassGroupToLesson)
ClassGroup ──N:M──► SubjectBlock (tabla implícita _ClassGroupToSubjectBlock)
Lesson ──1:N──► ScheduleSlot (lessonId)
Lesson ──N:M──► Room (tabla implícita _LessonToRoom)
Lesson ──N:M──► Student (tabla implícita _LessonToStudent)
Schedule ──1:N──► ScheduleSlot (scheduleId, Cascade)
Schedule ──1:N──► BreakSupervision (scheduleId, Cascade)
Absence ──1:N──► Substitution (absenceId, Cascade)
Department ──1:N──► Subject (departmentId, SetNull)
Department ──1:N──► DepartmentMembership (Cascade)
SupervisionZone ──1:N──► BreakSupervision (Cascade)Tablas N:M implícitas generadas por Prisma
| Tabla en PostgreSQL | Modelos relacionados |
|---|---|
| _LessonToTeacher | Lesson ↔ Teacher |
| _LessonToSubject | Lesson ↔ Subject |
| _LessonToRoom | Lesson ↔ Room |
| _ClassGroupToLesson | ClassGroup ↔ Lesson |
| _LessonToStudent | Lesson ↔ Student |
| _SubjectToSubjectBlock | Subject ↔ SubjectBlock |
| _ClassGroupToSubjectBlock | ClassGroup ↔ SubjectBlock |
Las tablas N:M implícitas no tienen campos extra. Si necesitas metadatos en la relación (ej. el rol en DepartmentMembership o el profesor override en SubjectClassGroup), Prisma requiere una join table explícita con su propio modelo.
Sección 14
Autenticación y RBAC
Flujo de autenticación
1. Usuario envía credenciales → Supabase Auth
2. Supabase devuelve JWT → se almacena en cookie httpOnly
3. proxy.ts (middleware Next.js) intercepta CADA petición:
a. Refresca la cookie con createServerClient(@supabase/ssr)
b. Verifica si la ruta es pública (PUBLIC_PATHS)
c. Si no hay sesión y ruta privada → redirect /login
d. Si hay sesión y es /login → redirect /
4. Server Components/Route Handlers llaman a getAuthSession():
a. Obtiene el user de Supabase Auth
b. resolveTeacher(): busca Teacher WHERE authUserId = user.id
AND isActive = true. Si no existe vínculo, intenta el JIT-link
(ver sección siguiente); el email NUNCA se usa para un usuario
ya vinculado.
c. Devuelve AuthSession { authUserId, email, teacherId, name, role }
5. Funciones guard aplican RBAC:
requireAuth() → cualquier profesor activo
requireStaff() → ADMIN o DIRECTIVO
requireAdmin() → solo ADMINJIT Linking (Just-In-Time)
El primer inicio de sesión vincula automáticamente auth.users.id → Teacher.authUserId buscando por email. Condiciones: el email del usuario debe estar confirmado en Supabase (email_confirmed_at), y debe existir un Teacher activo con ese email sin authUserId previo. El update es condicionado y atómico (updateMany), de modo que dos logins simultáneos no pueden producir un doble vínculo, y un Teacher ya vinculado a otra cuenta nunca puede ser reclamado por email.
Esto permite que el admin cree fichas de profesor antes de que ellos hagan login, sin necesidad de preconfigurar nada en Supabase.
Guards disponibles
| Campo/Relación | Modelo destino | Cardinalidad | onDelete | Descripción |
|---|---|---|---|---|
| requireAuth() | AuthSession | Server Component | redirect /login | Cualquier profesor activo. Falla → redirect /login o /unauthorized. |
| requireStaff() | AuthSession | Server Component | redirect /unauthorized | ADMIN o DIRECTIVO. Para páginas de edición. |
| requireAdmin() | AuthSession | Server Component | redirect /unauthorized | Solo ADMIN. Para panel de administración y datos RGPD. |
| requireAuthApi() | ApiGuardResult | Route Handler | 401 JSON | Variante para API routes. Devuelve { session, error } en vez de redirect. |
| requireStaffApi() | ApiGuardResult | Route Handler | 403 JSON | ADMIN o DIRECTIVO. Para endpoints de escritura. |
| requireAdminApi() | ApiGuardResult | Route Handler | 403 JSON | Solo ADMIN. Para datos de alumnos y operaciones de admin. |
Row Level Security (RLS)
Supabase tiene RLS habilitado a nivel de PostgreSQL. Las políticas aseguran que un profesor con role = TEACHER solo puede leer sus propios datos aunque tenga acceso directo a la BBDD. La aplicación usa el SERVICE_ROLE_KEY (bypass RLS) únicamente en Server Components y Route Handlers autenticados.
Sección 15
Capa de acceso a datos
Cliente Prisma singleton
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
function createPrismaClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! })
return new PrismaClient({ adapter, log: ['warn', 'error'] })
}
// Patrón singleton para evitar múltiples conexiones en dev (hot reload)
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prismaPatrón de uso en Route Handlers
// Ejemplo: GET /api/teachers
import { prisma } from '@/lib/prisma'
import { requireAuthApi } from '@/lib/auth/session'
export async function GET() {
const { session, error } = await requireAuthApi()
if (error) return error // 401/403 automático
const teachers = await prisma.teacher.findMany({
where: { isActive: true },
orderBy: { name: 'asc' },
select: { id: true, name: true, email: true, role: true }
})
return Response.json(teachers)
}
// Ejemplo: PATCH con mutación
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
const { session, error } = await requireStaffApi() // ADMIN o DIRECTIVO
if (error) return error
const body = await req.json()
const teacher = await prisma.teacher.update({
where: { id: params.id },
data: body
})
return Response.json(teacher)
}Services layer
Bajo /services hay una capa de servicio que encapsula lógica de negocio compleja (ej. guardService.ts para scoring de sustitutos, scheduleService.ts para generación). Los Route Handlers delegan en los services y solo manejan HTTP.
Comandos de BBDD
# Desarrollo: sincronizar schema sin migraciones
npm run db:push # prisma db push (destructivo si hay cambios de tipo)
# Producción: usar migraciones versionadas
npm run db:migrate # prisma migrate dev --name <nombre>
# Introspección visual
npm run db:studio # Abre Prisma Studio en localhost:5555
# Seed con datos de demo (IES Villa de Aspe)
npm run seed # npx tsx prisma/seed.tsEn producción, usar siempre prisma migrate deploy (no db push). El db:push puede eliminar columnas/tablas sin aviso.
Sección 16
API Routes
Todas las rutas viven bajo app/api/. Siguen REST con Next.js Route Handlers. Responden siempre JSON. El patrón de error estándar es { "error": "mensaje" }.
| Ruta | Métodos | Guard | Descripción |
|---|---|---|---|
| /api/teachers | GET, POST | Auth / Staff | CRUD profesores |
| /api/teachers/[id] | PATCH, DELETE | Staff | Actualizar/eliminar profesor |
| /api/subjects | GET, POST | Auth / Staff | CRUD asignaturas |
| /api/subjects/[id] | PATCH, DELETE | Staff | Actualizar/eliminar asignatura |
| /api/classgroups | GET, POST | Auth / Staff | CRUD grupos de clase |
| /api/classgroups/[id] | PATCH, DELETE | Staff | Actualizar/eliminar grupo |
| /api/rooms | GET, POST | Auth / Staff | CRUD aulas |
| /api/rooms/[id] | PATCH, DELETE | Staff | Actualizar/eliminar aula |
| /api/schedules | GET, POST | Auth / Staff | Listar/crear horarios |
| /api/schedules/[id] | GET, PATCH, DELETE | Auth / Staff | Obtener/actualizar/eliminar horario |
| /api/schedules/generate | POST | Staff | Generación Greedy MRV |
| /api/schedules/[id]/generate-sa | POST | Staff | Optimización Simulated Annealing (streaming) |
| /api/schedules/[id]/evaluate | GET | Auth | Evaluar penalización del horario |
| /api/schedules/[id]/penalty | GET | Auth | Desglose de penalización por categoría |
| /api/schedules/[id]/slots/[slotId] | PATCH, DELETE | Staff | Editar/eliminar slot individual |
| /api/schedules/validate-slot | POST | Auth | Validar movimiento de slot antes de aplicar |
| /api/schedules/[id]/break-supervisions | POST | Staff | Asignar supervisiones de recreo (greedy) |
| /api/absences | GET, POST | Auth / Staff | Listar/registrar ausencias |
| /api/absences/[id] | PATCH, DELETE | Staff | Actualizar/eliminar ausencia |
| /api/absences/[id]/recommend | GET | Staff | Ranking de sustitutos candidatos |
| /api/absences/[id]/substitution | POST | Staff | Asignar sustituto (envía email) |
| /api/absences/group/[groupId] | DELETE | Staff | Eliminar grupo de ausencias (rango de fechas) |
| /api/time-frames | GET, POST | Auth / Staff | CRUD franjas horarias |
| /api/time-frames/[id] | PATCH, DELETE | Staff | Actualizar/eliminar franja |
| /api/departments | GET, POST | Auth / Staff | CRUD departamentos |
| /api/departments/[id]/members | PATCH | Staff | Añadir/eliminar miembros del departamento |
| /api/subject-blocks | GET, POST | Auth / Staff | CRUD bloques de optativas |
| /api/supervision-zones | GET, POST | Auth / Staff | CRUD zonas de recreo |
| /api/scheduling-config | GET, PATCH | Auth / Staff | Leer/actualizar pesos SA |
| /api/holidays | GET, POST | Auth / Staff | CRUD festivos |
| /api/ai/analyze/[scheduleId] | POST | Staff | Análisis IA de calidad (Claude Haiku, rate-limit 30s) |
| /api/admin/users/[id] | PATCH, DELETE | Admin | Cambiar rol / desactivar usuario |
| /api/admin/users/[id]/invite | POST | Admin | Enviar invitación por email |
| /api/admin/import | POST | Admin | Importación masiva XLSX/CSV |
| /api/admin/test-email | POST | Admin | Email de prueba del proveedor configurado |
| /api/admin/subject-tuning | POST | Admin | Auditoría masiva de flags de periodos dobles |
| /api/me/tutorials | POST | Auth | Marcar tour como visto |
Sección 17
Algoritmos de scheduling
Fase 1 — Greedy MRV (Generación inicial)
Implementado en algorithms/scheduler.ts. Genera el horario desde cero.
1. Construir lista de "assignments" (lesson × group × semana)
2. Ordenar por MRV: menor número de slots válidos disponibles primero
3. Para cada assignment:
a. Calcular slots válidos (sin colisión de teacher/group/room/disponibilidad)
b. Elegir el slot con menor penalización (heurística greedy)
c. Asignar y bloquear ese slot para las siguientes iteraciones
4. Los assignments no resueltos se reportan como "unresolved"
Complejidad: O(n × m) donde n = assignments, m = slots posiblesFase 2 — Simulated Annealing (Optimización)
Implementado en algorithms/simulatedAnnealing.ts. Toma el horario generado y lo mejora iterativamente.
Parámetros:
T_inicial = 100.0 // Temperatura inicial
T_final = 0.1 // Temperatura final
α (alpha) = 0.995 // Factor de enfriamiento geométrico
iterations = saIterations (config)
Por cada iteración:
1. Proponer movimiento aleatorio:
- SWAP: intercambiar dos slots del mismo teacher
- MOVE: mover un slot a un hueco libre
- SHIFT: mover slot a día distinto
2. Calcular ΔPenalty = penalty(nuevo) - penalty(actual)
3. Si ΔPenalty < 0: aceptar siempre (mejora)
4. Si ΔPenalty ≥ 0: aceptar con probabilidad e^(-ΔPenalty/T)
5. Decrementar T *= alpha cada iteración
6. Slots locked = true nunca se muevenFunción de penalización (11 criterios)
Implementada en algorithms/penaltyCalculator.ts. Cada criterio retorna un valor numérico multiplicado por su peso:
penalty = Σ (penaltyCategory_i × weight_i)
Categorías:
consecutiveHours → Σ teacher: max(0, bloques_consecutivos - maxConsecutiveHours)
gaps → Σ teacher/día: periodos_libres_entre_clases
preferences → Σ slot: -preferredHours[day][period] (invertido)
studentGaps → Σ group/día: periodos_libres_entre_clases
doublePeriods → -Σ subject: bloques_dobles (bonificación → negativa)
dailyHours → Σ group/subject/día: max(0, sesiones - maxDailyLessons)
homeRoom → Σ slot: 1 si aula ≠ aula_base_del_grupo
activeDays → Σ teacher: |días_activos - target_días_activos|
spacing → Σ subject/group: penaliza días consecutivos con la misma asignatura
roomStability → Σ subject/group: número de aulas distintas usadas
minRest → Σ teacher/día: max(0, horas_descanso_requeridas - horas_descanso_reales)Detección de colisiones
algorithms/constraintEngine.ts evalúa si un slot candidato es válido comprobando:
Para cada slot candidato (lesson, day, hour, weekCycle):
1. Teacher no tiene otro slot en (day, hour) compatible con weekCycle
2. ClassGroup no tiene otro slot en (day, hour) compatible con weekCycle
3. Room no tiene otro slot en (day, hour) compatible con weekCycle
4. hour ≤ periodsPerDay del TimeFrame del grupo
5. No es periodo de recreo del TimeFrame
Compatibilidad de weekCycle:
EVERY_WEEK vs EVERY_WEEK → colisión
EVERY_WEEK vs WEEK_A/B → colisión (EVERY ocupa ambas semanas)
WEEK_A vs WEEK_B → NO colisión (semanas distintas)Sección 18
Despliegue y operaciones
Plataforma de producción
TimeSchool está optimizado para despliegue en Vercel (Next.js nativo) o Railway (Docker). El Dockerfile en la raíz produce una imagen Alpine con el servidor Next.js en modo standalone.
Scripts de npm disponibles
npm run dev # Servidor de desarrollo (puerto 3000)
npm run build # prisma generate && next build
npm run start # Servidor de producción
npm run db:push # Sincronizar schema (⚠ dev only)
npm run db:migrate# Crear migración Prisma
npm run db:studio # Interfaz visual Prisma Studio
npm run seed # Datos de demo (IES Villa de Aspe)
npm run test # Tests unitarios con Vitest
npm run test:coverage # Cobertura de testsBuild de producción
# En Vercel: automático en cada push a main
# En Railway / servidor propio:
docker build -t timeschool .
docker run -p 3000:3000 \
-e DATABASE_URL=... \
-e NEXT_PUBLIC_SUPABASE_URL=... \
# ... resto de variables ...
timeschoolChecklist de puesta en marcha
El primer usuario Admin debe crearse directamente en la BBDD (UPDATE "Teacher" SET role = 'ADMIN' WHERE email = '...') o mediante Prisma Studio. No hay una ruta de registro público — todo acceso requiere que el Admin cree la ficha previamente.