Inicio|Manual de usuario
TimeSchool

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.

Next.js 16 (App Router)Prisma 7 + PostgreSQLSupabase Auth + RLSTypeScript estricto
Versión 1.0·timeschool.es·Para uso interno — técnicos y desarrolladores

Índice de contenidos

01Stack tecnológico02Variables de entorno03Arquitectura de la BBDD04Enumeraciones (Enums)05Modelo Teacher06Modelo Subject07Modelo ClassGroup08Modelo Room09Modelo Lesson10Modelo Schedule y Slot11Modelos de Ausencias12Modelos de Configuración13Relaciones entre tablas14Autenticación y RBAC15Capa de acceso a datos16API Routes17Algoritmos de scheduling18Despliegue y operaciones

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

Email

  • — 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.es

DATABASE_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ósitoTipo de PK
TeacherFicha de profesor + credenciales Auth + RBACcuid()
SubjectAsignatura: horas/semana, color, tipo de aulacuid()
ClassGroupGrupo de clase: curso, tutor, franja horariacuid()
SubjectClassGroupJoin N:M Subject ↔ ClassGroup con profesor overridecuid() + unique(subjectId, classGroupId)
StudentAlumno individual (datos RGPD — solo ADMIN)cuid()
RoomAula física: capacidad y tipocuid()
LessonAgrupación lógica: quién enseña qué a quiéncuid()
ScheduleInstancia de horario (DRAFT/ACTIVE/ARCHIVED)cuid()
ScheduleSlotCuándo ocurre una Lesson: día + hora + ciclocuid() + unique(scheduleId, dayOfWeek, startHour, lessonId)
TimeFrameMarco horario: periodos/día, hora inicio, recreoscuid()
SchedulingConfigSingleton de pesos del optimizador SAid="default"
HolidayDías festivos/no lectivoscuid()
AbsenceAusencia de profesor: fecha, horas, motivocuid()
SubstitutionGuardia asignada: sustituto + hora + aulacuid()
SupervisionZoneZona de recreo (patio, entrada…)cuid() + unique(name)
BreakSupervisionAsignación profesor → zona por día/recreocuid() + unique(scheduleId, dayOfWeek, breakIndex, zoneId)
SubjectBlockBloque de optativas que van en paralelocuid()
DepartmentDepartamento didácticocuid() + unique(name)
DepartmentMembershipJoin N:M Teacher ↔ Department con rolcuid() + 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.

ADMINDIRECTIVOTEACHER

DeptRole

Rol dentro de un departamento. JEFE: único por departamento activo.

JEFEMIEMBRO

RoomType

Tipo de aula. El scheduler filtra aulas por tipo para cada Subject.roomType.

CLASSROOMLABGYMLIBRARYOTHER

ScheduleStatus

Estado del horario. Solo un horario puede estar ACTIVE al mismo tiempo.

DRAFTACTIVEARCHIVED

WeekCycle

Ciclo de semana de un ScheduleSlot. Permite horarios alternos (semana A/B).

EVERY_WEEKWEEK_AWEEK_B

DayOfWeek

Día de la semana para slots y supervisiones. Solo días lectivos (L–V).

MONDAYTUESDAYWEDNESDAYTHURSDAYFRIDAY

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).

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único del profesor.
nameString——Nombre completo.
emailString——Correo electrónico. Único. Usado como fallback de vinculación Auth.
maxHoursWeekInt—20Máximo de horas lectivas semanales. El scheduler respeta este límite.
maxSupervisionsWeekInt—2Máximo de guardias de recreo por semana.
maxConsecutiveHoursInt—3Máximo de periodos consecutivos sin descanso.
maxGapsPerWeekInt—2Máximo de huecos (periodos libres entre clases) semanales permitidos.
minActiveDaysInt—1Mínimo de días con al menos una clase a la semana.
maxActiveDaysInt—5Máximo de días con clase. Permite concentrar jornada.
minRestHoursBetweenDaysInt—11Horas mínimas entre última clase de un día y primera del siguiente (normativa laboral).
maxTeachingHoursPerDayInt—5Máximo de periodos lectivos en un mismo día.
preferredHoursJson—{}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.
availabilityJson—{}Mapa { DayOfWeek: number[] } con valores -1/0/1. 1=disponible, 0=no preferido, -1=no disponible. Longitud = periodsPerDay del TimeFrame.
createdAtDateTime—now()Fecha de creación del registro.
updatedAtDateTime—@updatedAtÚltima modificación (auto-gestionado por Prisma).
authUserIdString?✓—UUID de auth.users en Supabase. Null hasta el primer login SSO (JIT linking).
roleUserRole—TEACHERRol efectivo para RBAC. Determina el nivel de acceso al sistema.
isActiveBoolean—trueSoft delete. false = no puede acceder aunque tenga authUserId válido.
chargesString[]—[]Etiquetas decorativas (COORD_TIC, COORD_IGUALDAD…). Sin lógica de negocio, solo visualización.
tutorialsSeenString[]—[]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.

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
nameString——Nombre de la asignatura.
hoursPerWeekInt—3Número de periodos lectivos semanales que debe tener cada grupo.
colorString—#6366f1Color HEX para visualización en el grid del horario.
maxDailyLessonsPerSubjectInt—1Máximo de sesiones de esta asignatura en un mismo día por grupo. El penalizador SA lo usa.
spacingFactorInt—3Días mínimos deseados entre sesiones de la misma asignatura. Peso en penalizador de espaciado.
roomTypeRoomType?✓—Tipo de aula requerida. null = heurística legacy por nombre.
needsDoublePeriodBoolean?✓—Si true: el scheduler DEBE colocar al menos un bloque de 2h consecutivas.
allowDoublePeriodBoolean?✓—Si true: no penaliza 2 sesiones el mismo día. null = heurística por nombre.
teacherIdString?✓—FK a Teacher. Profesor predeterminado. Puede sobrescribirse en SubjectClassGroup.
departmentIdString?✓—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.

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
nameString——Nombre del grupo (ej. "1ºA ESO", "2ºBACH-B").
gradeInt——Curso numérico (1, 2, 3, 4…). Usado por SubjectBlock para agrupar por nivel.
studentCountInt—25Número de alumnos. Usado para validar capacidad de aulas.
createdAtDateTime—now()Fecha de creación.
roomIdString?✓—FK a Room. Aula base del grupo. onDelete: SetNull.
tutorIdString?✓—FK a Teacher. Tutor del grupo. onDelete: SetNull.
timeFrameIdString?✓—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.

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
nameString——Nombre del espacio (ej. "Laboratorio 1", "Pista deportiva").
capacityInt—30Aforo máximo. Se compara con ClassGroup.studentCount.
typeRoomType—CLASSROOMTipo 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]
}
CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
nameString?✓—Nombre descriptivo opcional. Si es null, se deriva de sus subjects en la UI.
createdAtDateTime—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

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
nameString——Nombre descriptivo (ej. "Horario 2024-25 Q1").
academicYearString——Año académico (ej. "2024-2025"). Solo informativo.
statusScheduleStatus—DRAFTEstado del horario. Solo uno puede ser ACTIVE. El paso a ACTIVE archiva el anterior.
createdAtDateTime—now()Fecha de creación.
updatedAtDateTime—@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.

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
dayOfWeekDayOfWeek——Día de la semana (MONDAY…FRIDAY).
startHourInt——Periodo de inicio 0-based (0 = 1ª hora). Nota: el campo se llama "Hour" pero representa periodos.
endHourInt——Periodo final (excluido). Para clase de 1 periodo: endHour = startHour + 1.
notesString?✓—Notas libres visibles en el editor.
createdAtDateTime—now()Fecha de creación.
lockedBoolean—falseSi true: el slot no se borra al regenerar ni lo mueve el optimizador SA.
weekCycleWeekCycle—EVERY_WEEKCiclo de semana. EVERY_WEEK: siempre. WEEK_A/WEEK_B: semanas alternas.
isBreakBoolean—falseMarca el slot como recreo (bloque decorativo, no clase).
isSubstituteDutyBoolean—falseMarca el slot como guardia de sustitución (generado por el módulo de ausencias).
scheduleIdString——FK a Schedule. onDelete: Cascade.
lessonIdString——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

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
teacherIdString——FK a Teacher (profesor ausente). Sin onDelete (no borrar ausencias al desactivar profesor).
dateDateTime——Fecha de la ausencia. Se almacena como medianoche UTC (solo la parte de fecha es relevante).
startHourInt——Primer periodo afectado (0-based, igual que ScheduleSlot.startHour).
endHourInt——Último periodo afectado + 1.
reasonString?✓—Motivo de la ausencia (Enfermedad, Formación, Permiso, Otro).
resolvedBoolean—falsetrue cuando todas las horas de la ausencia han sido cubiertas.
absenceGroupIdString?✓—UUID generado en la app para agrupar ausencias creadas desde un rango de fechas. null = ausencia individual.
createdAtDateTime—now()Fecha de registro.

Substitution — Guardia asignada

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
absenceIdString——FK a Absence. onDelete: Cascade (si se borra la ausencia, se borra la guardia).
originalTeacherIdString——FK a Teacher (quien falta). Relación nombrada "OriginalTeacher".
substituteTeacherIdString——FK a Teacher (quien cubre). Relación nombrada "SubstituteTeacher".
roomIdString?✓—FK a Room. Aula donde se realiza la guardia (puede diferir del horario normal).
dateDateTime——Fecha de la guardia.
hourInt——Periodo de la guardia (0-based).
createdAtDateTime—now()Fecha de asignación.

SupervisionZone y BreakSupervision

CampoTipoNullDefaultDescripción
SupervisionZone.idString—cuid()ID de la zona.
SupervisionZone.nameString——Nombre único de la zona (patio, entrada…).
SupervisionZone.descriptionString?✓—Descripción de la ubicación.
BreakSupervision.scheduleIdString——FK a Schedule. onDelete: Cascade.
BreakSupervision.teacherIdString——FK a Teacher. onDelete: Cascade.
BreakSupervision.zoneIdString——FK a SupervisionZone. onDelete: Cascade.
BreakSupervision.dayOfWeekDayOfWeek——Día de la semana.
BreakSupervision.breakIndexInt——Í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).

CampoTipoNullDefaultDescripción
weightConsecutiveHoursInt—3Penalización por horas consecutivas sin descanso del profesor.
weightGapsInt—4Penalización por huecos libres en medio de la jornada del profesor.
weightPreferencesInt—3Penalización por ignorar preferencias horarias del profesor.
weightStudentGapsInt—5Penalización por huecos libres del alumno (normativa legal).
weightDoublePeriodsInt—2Bonificación por respetar/crear bloques dobles.
weightDailyHoursInt—3Penalización por exceder el máximo diario de una asignatura.
weightHomeRoomInt—2Penalización por usar aulas distintas al aula base del grupo.
weightActiveDaysInt—3Penalización por exceder/no alcanzar los días activos deseados.
weightSpacingInt—3Penalización por concentrar la misma asignatura en días consecutivos.
weightRoomStabilityInt—2Penalización por cambiar de aula para la misma asignatura en distintos días.
weightMinRestInt—5Penalización por incumplir el descanso mínimo de 11h entre jornadas.
saIterationsInt—12000Número máximo de iteraciones del Simulated Annealing. Rango: 1.000–500.000.

TimeFrame — Marco horario

CampoTipoNullDefaultDescripción
idString—cuid()Identificador único.
nameString——Nombre único (ej. "ESO Mañana").
periodsPerDayInt—7Número de periodos lectivos por día base. Rango: 1–10.
startTimeString—"08:00"Hora de inicio del primer periodo en formato HH:MM.
periodDurationInt—55Duración en minutos de cada periodo lectivo.
breaksJson—[]Array<{ afterPeriod: number; duration: number; label?: string }>. afterPeriod es 1-based.
dayOverridesJson—{}{ DayOfWeek: { periodsPerDay?, startTime?, periodDuration?, breaks? } }. Permite horarios distintos por día.
isDefaultBoolean—falseMarco predeterminado del centro. Los grupos sin timeFrameId lo usan. Solo uno puede ser true.
createdAtDateTime—now()Fecha de creación.
updatedAtDateTime—@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 PostgreSQLModelos relacionados
_LessonToTeacherLesson ↔ Teacher
_LessonToSubjectLesson ↔ Subject
_LessonToRoomLesson ↔ Room
_ClassGroupToLessonClassGroup ↔ Lesson
_LessonToStudentLesson ↔ Student
_SubjectToSubjectBlockSubject ↔ SubjectBlock
_ClassGroupToSubjectBlockClassGroup ↔ 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 ADMIN

JIT 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ónModelo destinoCardinalidadonDeleteDescripción
requireAuth()AuthSessionServer Componentredirect /loginCualquier profesor activo. Falla → redirect /login o /unauthorized.
requireStaff()AuthSessionServer Componentredirect /unauthorizedADMIN o DIRECTIVO. Para páginas de edición.
requireAdmin()AuthSessionServer Componentredirect /unauthorizedSolo ADMIN. Para panel de administración y datos RGPD.
requireAuthApi()ApiGuardResultRoute Handler401 JSONVariante para API routes. Devuelve { session, error } en vez de redirect.
requireStaffApi()ApiGuardResultRoute Handler403 JSONADMIN o DIRECTIVO. Para endpoints de escritura.
requireAdminApi()ApiGuardResultRoute Handler403 JSONSolo 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 = prisma

Patró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.ts

En 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" }.

RutaMétodosGuardDescripción
/api/teachersGET, POSTAuth / StaffCRUD profesores
/api/teachers/[id]PATCH, DELETEStaffActualizar/eliminar profesor
/api/subjectsGET, POSTAuth / StaffCRUD asignaturas
/api/subjects/[id]PATCH, DELETEStaffActualizar/eliminar asignatura
/api/classgroupsGET, POSTAuth / StaffCRUD grupos de clase
/api/classgroups/[id]PATCH, DELETEStaffActualizar/eliminar grupo
/api/roomsGET, POSTAuth / StaffCRUD aulas
/api/rooms/[id]PATCH, DELETEStaffActualizar/eliminar aula
/api/schedulesGET, POSTAuth / StaffListar/crear horarios
/api/schedules/[id]GET, PATCH, DELETEAuth / StaffObtener/actualizar/eliminar horario
/api/schedules/generatePOSTStaffGeneración Greedy MRV
/api/schedules/[id]/generate-saPOSTStaffOptimización Simulated Annealing (streaming)
/api/schedules/[id]/evaluateGETAuthEvaluar penalización del horario
/api/schedules/[id]/penaltyGETAuthDesglose de penalización por categoría
/api/schedules/[id]/slots/[slotId]PATCH, DELETEStaffEditar/eliminar slot individual
/api/schedules/validate-slotPOSTAuthValidar movimiento de slot antes de aplicar
/api/schedules/[id]/break-supervisionsPOSTStaffAsignar supervisiones de recreo (greedy)
/api/absencesGET, POSTAuth / StaffListar/registrar ausencias
/api/absences/[id]PATCH, DELETEStaffActualizar/eliminar ausencia
/api/absences/[id]/recommendGETStaffRanking de sustitutos candidatos
/api/absences/[id]/substitutionPOSTStaffAsignar sustituto (envía email)
/api/absences/group/[groupId]DELETEStaffEliminar grupo de ausencias (rango de fechas)
/api/time-framesGET, POSTAuth / StaffCRUD franjas horarias
/api/time-frames/[id]PATCH, DELETEStaffActualizar/eliminar franja
/api/departmentsGET, POSTAuth / StaffCRUD departamentos
/api/departments/[id]/membersPATCHStaffAñadir/eliminar miembros del departamento
/api/subject-blocksGET, POSTAuth / StaffCRUD bloques de optativas
/api/supervision-zonesGET, POSTAuth / StaffCRUD zonas de recreo
/api/scheduling-configGET, PATCHAuth / StaffLeer/actualizar pesos SA
/api/holidaysGET, POSTAuth / StaffCRUD festivos
/api/ai/analyze/[scheduleId]POSTStaffAnálisis IA de calidad (Claude Haiku, rate-limit 30s)
/api/admin/users/[id]PATCH, DELETEAdminCambiar rol / desactivar usuario
/api/admin/users/[id]/invitePOSTAdminEnviar invitación por email
/api/admin/importPOSTAdminImportación masiva XLSX/CSV
/api/admin/test-emailPOSTAdminEmail de prueba del proveedor configurado
/api/admin/subject-tuningPOSTAdminAuditoría masiva de flags de periodos dobles
/api/me/tutorialsPOSTAuthMarcar 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 posibles

Fase 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 mueven

Funció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 tests

Build 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 ...
  timeschool

Checklist de puesta en marcha

1Crear proyecto en Supabase → copiar DATABASE_URL, DIRECT_URL, ANON_KEY, SERVICE_ROLE_KEY
2Configurar variables de entorno en Vercel/Railway
3Ejecutar prisma migrate deploy (o db:push en primer despliegue)
4Ejecutar npm run seed si se quieren datos de demo
5Configurar proveedor de email (RESEND_API_KEY recomendado)
6Configurar GEMINI_API_KEY para el análisis IA
7Crear el primer usuario Admin: ir a /login → crear cuenta → actualizar role en Supabase o directamente en BBDD
8Verificar dominio en Google Search Console (meta tag ya incluido en layout.tsx)

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.

TimeSchool · Manual Técnico v1.0

Para uso interno — técnicos y desarrolladores

© 2026 TimeSchool · timeschool.es