Ir al contenido

TalentBricksAI usa PostgreSQL con Prisma ORM. Los modelos se definen en app/schema.prisma.

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │────▶│ Enrollment │◀────│ Course │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Certificate │ │LessonProgress│◀───│ Lesson │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Review │◀────────────────────────│ Course │
└─────────────┘ └─────────────┘
┌─────────────┐
│ Quiz │◀───┐
└─────────────┘ │
│ │
│ │
▼ │
┌──────────────┐ │
┌─────────────────▶│QuizQuestion │ │
│ └──────────────┘ │
│ │ │
│ │ │
┌─────────────┐ │ ▼ │
│ User │──────┼──────────────────┌──────────────┐ │
└─────────────┘ │ │ QuizAnswer │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────┘
QuizAttempt

El modelo Instructor representa a los instructores que crean y enseñan cursos. Los instructores son entidades separadas de los usuarios.

model Instructor {
id String @id @default(uuid())
name String
slug String @unique
title String?
bio String? @db.Text
avatarUrl String?
linkedinUrl String?
githubUrl String?
twitterUrl String?
isPublished Boolean @default(true)
courses Course[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
CampoTipodescripción
idStringUUID único
nameStringNombre completo del instructor
slugStringURL-friendly identifier, único
titleString?Título profesional (ej. “Senior Engineer”)
bioString?Biografía del instructor
avatarUrlString?URL de la foto de perfil
isPublishedBooleanSolo instructores publicados son visibles

Relaciones:

  • Un instructor puede tener muchos cursos (courses)
  • Los cursos pueden referenciar a un instructor (instructor)

Rutas públicas:

  • /instructors - Lista de todos los instructores
  • /instructors/:slug - Perfil público del instructor
model Course {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
slug String @unique
description String
thumbnail String?
price Int // en centavos (USD)
currency String @default("USD")
difficulty Difficulty @default(BEGINNER)
category String
isPublished Boolean @default(false)
scheduledPublishAt DateTime?
isArchived Boolean @default(false)
instructorName String @default("TalentBricksAI")
creditCostPerLesson Int @default(50)
// Relación con Instructor
instructorId String?
instructor Instructor? @relation(fields: [instructorId], references: [id])
lessons Lesson[]
quizzes Quiz[]
enrollments Enrollment[]
certificates Certificate[]
reviews Review[]
organizationEnrollments OrganizationEnrollment[]
wishlistItems WishlistItem[]
calendarEvents CalendarEvent[]
}
CampoTipodescripción
idIntID autoincremental
slugStringURL-friendly identifier, único
priceIntPrecio en centavos (2900 = $29.00)
difficultyEnumBEGINNER, INTERMEDIATE, ADVANCED
isPublishedBooleanSolo cursos publicados son visibles
instructorNameStringNombre del instructor (legacy)
instructorIdString?Referencia al instructor
instructorInstructor?Relación con el modelo Instructor
creditCostPerLessonIntCréditos por lección (default 50 = $0.50)
model Lesson {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
courseId Int
course Course @relation(fields: [courseId], references: [id])
title String
description String?
order Int
duration Int // en segundos
storageVideoKey String? // Provider-agnostic storage key (e.g. lessons/{id}/videos/{uuid}.mp4)
content String? // markdown content
isPreview Boolean @default(false)
isPublished Boolean @default(true)
scheduledPublishAt DateTime?
progress LessonProgress[]
quizzes Quiz[]
comments Comment[]
resources LessonResource[]
videoBookmarks VideoBookmark[]
lessonNotes LessonNote[]
}
CampoTipodescripción
orderIntOrden de la leccion en el curso
durationIntDuracion en segundos
storageVideoKeyString?Clave en el proveedor de almacenamiento (S3/Azure). En dev puede ser una URL directa de video MP4.
isPreviewBooleanLecciones preview son accesibles sin enrollment
isPublishedBooleanfalse = borrador, invisible para estudiantes
scheduledPublishAtDateTime?Fecha en que el job publica la lección automáticamente
model Enrollment {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
userId Int
user User @relation(fields: [userId], references: [id])
courseId Int
course Course @relation(fields: [courseId], references: [id])
completedAt DateTime?
progress LessonProgress[]
@@unique([userId, courseId])
}

El constraint @@unique([userId, courseId]) asegura que un usuario sólo puede tener una inscripcion por curso.

model LessonProgress {
id Int @id @default(autoincrement())
updatedAt DateTime @updatedAt
userId Int
lessonId Int
lesson Lesson @relation(fields: [lessonId], references: [id])
enrollmentId Int
enrollment Enrollment @relation(fields: [enrollmentId], references: [id])
watchedSeconds Int @default(0)
isCompleted Boolean @default(false)
@@unique([userId, lessonId])
}
CampoTipodescripción
watchedSecondsIntSegundos vistos del video
isCompletedBooleanLeccion marcada como completada
model Certificate {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
courseId Int
course Course @relation(fields: [courseId], references: [id])
verificationCode String @unique @default(uuid())
// Snapshot data (capturado al momento de generacion)
studentName String
courseTitle String
instructorName String
totalDurationMinutes Int
@@unique([userId, courseId])
@@index([verificationCode])
}
CampoTipodescripción
verificationCodeStringUUID único para verificación pública
studentNameStringNombre del estudiante (snapshot)
courseTitleStringTítulo del curso (snapshot)
instructorNameStringNombre del instructor (snapshot)
totalDurationMinutesIntDuracion total en minutos (snapshot)
model Review {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rating Int // 1-5 estrellas
title String
comment String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
courseId Int
@@unique([userId, courseId]) // Una resena por usuario por curso
@@index([courseId]) // Búsqueda rápida por curso
}
CampoTipodescripción
ratingIntCalificacion de 1 a 5 estrellas
titleStringTítulo corto de la resena
commentStringComentario detallado
userIdStringUsuario que escribio la resena
courseIdIntCurso al que pertenece la resena
model Quiz {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
courseId Int
course Course @relation(fields: [courseId], references: [id])
lessonId Int?
lesson Lesson? @relation(fields: [lessonId], references: [id])
title String
description String?
order Int
passingScore Int @default(70) // Porcentaje 0-100
timeLimit Int? // Límite en segundos (null = sin límite)
isRequired Boolean @default(false)
isFinalExam Boolean @default(false)
questions QuizQuestion[]
attempts QuizAttempt[]
@@index([courseId, order])
@@index([lessonId])
}
CampoTipoDescripción
courseIdIntCurso al que pertenece el quiz
lessonIdInt?Lección asociada (null = examen final)
passingScoreIntPuntuación mínima para aprobar (%)
timeLimitInt?Límite de tiempo en segundos (null = sin límite)
isRequiredBooleanSi el quiz es obligatorio para avanzar
isFinalExamBooleanMarca si es un examen final del curso
model QuizQuestion {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quizId Int
quiz Quiz @relation(fields: [quizId], references: [id])
question String
order Int
explanation String? // Explicación mostrada después de responder
answers QuizAnswer[]
@@index([quizId, order])
}
CampoTipoDescripción
questionStringTexto de la pregunta
orderIntOrden de la pregunta en el quiz
explanationStringExplicación opcional mostrada tras responder
model QuizAnswer {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
questionId Int
question QuizQuestion @relation(fields: [questionId], references: [id])
answer String
isCorrect Boolean
order Int
@@index([questionId])
}
CampoTipoDescripción
answerStringTexto de la respuesta
isCorrectBooleanSi es la respuesta correcta
orderIntOrden de visualización
model QuizAttempt {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
quizId Int
quiz Quiz @relation(fields: [quizId], references: [id])
score Int // Puntuación 0-100
passed Boolean // Si aprobó (score >= passingScore)
timeSpent Int? // Tiempo empleado en segundos
answers String // JSON: { questionId: answerId }
@@index([userId, quizId])
@@index([quizId])
}
CampoTipoDescripción
scoreIntPuntuación obtenida (0-100)
passedBooleanSi aprobó el quiz
timeSpentInt?Tiempo empleado en segundos
answersStringJSON con las respuestas del usuario
model ReferralCode {
id String @id @default(uuid())
createdAt DateTime @default(now())
code String @unique
userId String
user User @relation(fields: [userId], references: [id])
clicks Int @default(0)
signups Int @default(0)
conversions Int @default(0)
referrals ReferralSignup[]
@@index([code])
@@index([userId])
}
CampoTipodescripción
codeStringcódigo único de 8 caracteres (ej: “abc12def”)
clicksIntnúmero de clicks en el link de referido
signupsIntnúmero de usuarios que se registraron
conversionsIntnúmero de usuarios que compraron
model ReferralSignup {
id String @id @default(uuid())
createdAt DateTime @default(now())
referralCodeId String
referralCode ReferralCode @relation(fields: [referralCodeId], references: [id])
referredUserId String @unique
convertedAt DateTime?
rewardClaimed Boolean @default(false)
source String?
@@index([referralCodeId])
@@index([referredUserId])
}
CampoTipodescripción
referredUserIdStringID del usuario que se registró (único)
convertedAtDateTime?Fecha de la primera compra
rewardClaimedBooleanSi el referidor ya reclamo su recompensa
sourceString?Fuente de la referencia (utm_source)
model PromoCode {
id String @id @default(uuid())
createdAt DateTime @default(now())
code String @unique
discountType DiscountType
discountValue Int // Porcentaje o centavos
maxUses Int?
currentUses Int @default(0)
expiresAt DateTime?
courseIds String[] // Cursos especificos (vacio = todos)
newUsersOnly Boolean @default(false)
minPurchase Int? // Minimo en centavos
isActive Boolean @default(true)
@@index([code])
@@index([isActive])
}
enum DiscountType {
PERCENTAGE // Descuento porcentual (10 = 10%)
FIXED_AMOUNT // Monto fijo en centavos (500 = $5.00)
}
CampoTipodescripción
codeStringcódigo promocional (ej: “VERANO20”)
discountTypeEnumPERCENTAGE o FIXED_AMOUNT
discountValueIntValor del descuento
maxUsesInt?Limite de usos (null = ilimitado)
currentUsesIntUsos actuales
courseIdsString[]IDs de cursos validos (vacio = todos)
newUsersOnlyBooleanSolo para usuarios nuevos
minPurchaseInt?Compra minima requerida
model UserReward {
id String @id @default(uuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
rewardType String // DISCOUNT_PERCENTAGE o DISCOUNT_FIXED
value Int // Valor del descuento
description String?
expiresAt DateTime
isRedeemed Boolean @default(false)
redeemedAt DateTime?
@@index([userId])
@@index([isRedeemed])
@@index([expiresAt])
}
enum RewardType {
DISCOUNT_PERCENTAGE // Descuento porcentual
DISCOUNT_FIXED // Monto fijo
}
CampoTipodescripción
rewardTypeStringTipo de recompensa
valueIntValor (porcentaje o centavos)
descriptionString?descripción de como se obtuvo
expiresAtDateTimeFecha de expiracion
isRedeemedBooleanSi ya fue usada
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
// Información básica
email String? @unique
username String? @unique
isAdmin Boolean @default(false)
mustChangePassword Boolean @default(false)
// Autenticación de dos factores (2FA)
twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
twoFactorVerifiedAt DateTime?
twoFactorBackupCodes String[]
// Perfil
showProfilePublic Boolean @default(true)
showInLeaderboards Boolean @default(true)
displayName String?
bio String?
avatarUrl String?
timezone String? @default("UTC")
linkedinUrl String?
githubUrl String?
twitterUrl String?
// Preferencias
emailNotifications Boolean @default(true)
defaultPlaybackSpeed Float @default(1.0)
autoplayNextLesson Boolean @default(true)
// Suscripción y pagos
subscriptionStatus String?
subscriptionPlan String?
paymentProcessorUserId String? @unique
datePaid DateTime?
credits Int @default(10)
creditLimit Int @default(10)
lastCreditReset DateTime?
// Referidos
referralCode String? @unique
referralCodes ReferralCode[]
referredBy ReferralSignup? @relation("ReferredUsers")
rewards UserReward[]
// Relaciones de cursos
enrollments Enrollment[]
lessonProgress LessonProgress[]
certificates Certificate[]
reviews Review[]
quizAttempts QuizAttempt[]
// Instructor
instructorProfile Instructor?
// Comentarios
comments Comment[]
commentVotes CommentVote[]
// B2B
organizationMemberships OrganizationMember[]
// Sesiones
userSessions UserSession[]
// Engagement
wishlistItems WishlistItem[]
calendarEvents CalendarEvent[]
videoBookmarks VideoBookmark[]
lessonNotes LessonNote[]
// Template
gptResponses GptResponse[]
tasks Task[]
files File[]
contactFormMessages ContactFormMessage[]
}
CampoTipodescripción
showProfilePublicBooleanSi true, el perfil es público y visible para otros usuarios
showInLeaderboardsBooleanSi true, el usuario aparece en rankings y tablas de clasificacion
displayNameString?Nombre para mostrar en el perfil (opcional)
bioString?Biografia del usuario (max 500 caracteres)
avatarUrlString?URL de la imagen de perfil almacenada en S3
timezoneString?Zona horaria del usuario (default: “UTC”)
linkedinUrlString?URL del perfil de LinkedIn
githubUrlString?URL del perfil de GitHub
twitterUrlString?URL del perfil de Twitter
CampoTipodescripción
emailNotificationsBooleanSi true, el usuario recibe notificaciones por email
defaultPlaybackSpeedFloatVelocidad de reproduccion predeterminada (0.5 - 2.0)
autoplayNextLessonBooleanSi true, reproduce automaticamente la siguiente leccion
CampoTipodescripción
mustChangePasswordBooleanSi true, el usuario debe cambiar su contraseña antes de acceder a la aplicación. Se activa cuando un admin establece o cambia la contraseña del usuario
twoFactorEnabledBooleanSi true, el usuario tiene 2FA activado con una app autenticadora (TOTP)
twoFactorSecretString?Secreto TOTP cifrado con AES-256-GCM. Solo almacenado en el servidor
twoFactorVerifiedAtDateTime?Marca de tiempo de la última verificación 2FA exitosa. La sesión expira a las 24 horas
twoFactorBackupCodesString[]Array de códigos de respaldo hasheados (SHA-256). Hasta 8 códigos de un solo uso
Ventana de terminal
# Iniciar PostgreSQL (dejar corriendo)
wasp db start
# Crear migración después de cambiar schema.prisma
wasp db migrate-dev
# Abrir Prisma Studio (GUI para ver datos)
wasp db studio
# Ejecutar seed script
wasp db seed
const courses = await context.entities.Course.findMany({
where: { isPublished: true },
include: {
lessons: {
orderBy: { order: "asc" },
},
},
});

Obtener enrollments de un usuario con progreso

Sección titulada «Obtener enrollments de un usuario con progreso»
const enrollments = await context.entities.Enrollment.findMany({
where: { userId: context.user.id },
include: {
course: {
include: {
lessons: true,
},
},
progress: true,
},
});
const enrollment = await context.entities.Enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
include: {
course: { include: { lessons: true } },
progress: { where: { isCompleted: true } },
},
});
const totalLessons = enrollment.course.lessons.length;
const completedLessons = enrollment.progress.length;
const progressPercent = Math.round((completedLessons / totalLessons) * 100);

Los índices están optimizados para las consultas más frecuentes en la aplicación. Se utilizan índices simples y compuestos para maximizar el rendimiento.

@@index([isPublished])
@@index([isArchived])
@@index([category])
@@index([instructorId])
@@index([difficulty])
@@index([createdAt])
@@index([price])
// Índices compuestos para rendimiento
@@index([isPublished, category]) // Listado de cursos por categoría
@@index([isPublished, createdAt]) // Listados paginados de cursos

Propósito:

  • [isPublished, category] - Filtrar cursos publicados por categoría (10x más rápido)
  • [isPublished, createdAt] - Ordenar cursos publicados por fecha de creación
@@index([courseId, order])

Propósito:

  • Ordenar lecciones dentro de un curso (orden secuencial)
@@unique([userId, courseId])
@@index([userId])
@@index([courseId])
@@index([createdAt])
// Índices compuestos para rendimiento
@@index([userId, completedAt]) // Dashboard de cursos completados

Propósito:

  • [userId, completedAt] - Dashboard del usuario para mostrar cursos completados y en progreso
@@unique([userId, lessonId])
// Índices compuestos para rendimiento
@@index([userId, isCompleted]) // Seguimiento de progreso del usuario
@@index([enrollmentId, isCompleted]) // Cálculo de finalización de curso

Propósito:

  • [userId, isCompleted] - Tracking de progreso por usuario (10x más rápido)
  • [enrollmentId, isCompleted] - Calcular porcentaje de finalización de curso
@@unique([userId, courseId])
@@index([verificationCode])
// Índices compuestos para rendimiento
@@index([userId, createdAt]) // Lista de certificados del usuario

Propósito:

  • [userId, createdAt] - Mostrar certificados del usuario ordenados por fecha
@@index([lessonId, createdAt])
@@index([userId])
@@index([parentId])
@@index([isDeleted])
// Índices compuestos para rendimiento
@@index([userId, createdAt]) // Historial de actividad del usuario

Propósito:

  • [lessonId, createdAt] - Mostrar comentarios de una lección ordenados por fecha
  • [userId, createdAt] - Historial de comentarios del usuario
@@unique([userId, courseId])
@@index([courseId])
// Índices compuestos para rendimiento
@@index([courseId, createdAt]) // Reseñas paginadas de curso

Propósito:

  • [courseId, createdAt] - Mostrar reseñas de un curso ordenadas por fecha
@@unique([userId, commentId])
@@index([commentId])

Propósito:

  • Un usuario solo puede votar una vez por comentario
  • Lookup rápido de votos por comentario

Después de agregar los índices compuestos (migración 20260315143904_add_performance_indexes):

QueryAntesDespuésMejora
Course listing por categoría~500ms~50ms10x
Dashboard de usuario~1s~100ms10x
Cálculo de progreso~200ms~20ms10x
Lista de certificados~150ms~15ms10x

El modelo Comment representa las preguntas y respuestas en las lecciones. Soporta comentarios anidados (threads), timestamps de video, y marcas especiales como respuesta del instructor o solución.

model Comment {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
lessonId Int
content String @db.Text
videoTimestamp Int?
parentComment Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
parentId Int?
replies Comment[] @relation("CommentReplies")
isInstructorResponse Boolean @default(false)
markedAsSolution Boolean @default(false)
isEdited Boolean @default(false)
isDeleted Boolean @default(false)
deletedAt DateTime?
votes CommentVote[]
@@index([lessonId, createdAt])
@@index([userId])
@@index([parentId])
@@index([isDeleted])
}
CampoTipoDescripción
idIntID único autoincremental
userIdStringID del usuario que creó el comentario
lessonIdIntID de la lección a la que pertenece
contentStringContenido del comentario (10-2000 caracteres)
videoTimestampInt?Timestamp del video en segundos (opcional)
parentIdInt?ID del comentario padre (para respuestas anidadas)
isInstructorResponseBooleanMarca si es respuesta oficial del instructor
markedAsSolutionBooleanMarca si fue marcado como solución a la pregunta
isEditedBooleanIndica si el comentario fue editado
isDeletedBooleanSoft delete - preserva estructura de threads
deletedAtDateTime?Fecha de eliminación (soft delete)

Características:

  • Threading: Soporta respuestas anidadas mediante parentId
  • Video timestamps: Los comentarios pueden vincular a momentos específicos del video
  • Badges especiales: Instructor response y Solution para destacar respuestas útiles
  • Soft delete: Preserva la estructura de conversaciones al eliminar
  • Edición limitada: Solo 15 minutos después de creación para autores

El modelo CommentVote gestiona los votos (upvote/downvote) en los comentarios.

model CommentVote {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId Int
value Int // 1 for upvote, -1 for downvote
@@unique([userId, commentId])
@@index([commentId])
}
CampoTipoDescripción
idIntID único autoincremental
userIdStringID del usuario que votó
commentIdIntID del comentario votado
valueIntValor del voto: 1 (upvote) o -1 (downvote)

Reglas de negocio:

  • Un usuario solo puede votar una vez por comentario (constraint único)
  • Los usuarios no pueden votar sus propios comentarios
  • Hacer clic en el mismo voto lo elimina (toggle)
  • Rate limiting: máximo 50 votos por hora por usuario

El modelo LessonResource gestiona archivos descargables asociados a lecciones (PDFs, slides, código fuente, etc.).

model LessonResource {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
lessonId Int
title String
description String?
fileName String
fileType String // MIME type
fileSize Int // Bytes
s3Key String // lessons/{lessonId}/resources/{uuid}.ext
order Int @default(0)
@@index([lessonId])
}
CampoTipoDescripción
idIntID único autoincremental
lessonIdIntID de la lección asociada
titleStringTítulo del recurso
descriptionString?Descripción opcional del recurso
fileNameStringNombre original del archivo
fileTypeStringMIME type (application/pdf, etc.)
fileSizeIntTamaño del archivo en bytes
s3KeyStringRuta en S3: lessons/{lessonId}/resources/{uuid}
orderIntOrden de visualización

Reglas de negocio:

  • Los recursos se eliminan automáticamente cuando se elimina la lección (cascade delete)
  • Archivos almacenados en S3 con URLs firmadas (expiración de 1 hora)
  • Tamaño máximo: 50MB por archivo
  • Tipos permitidos: PDF, PowerPoint, Word, ZIP, RAR, código, imágenes
  • Solo estudiantes inscritos pueden descargar recursos de lecciones protegidas
  • Lecciones preview: recursos de acceso público

Relación con Lesson:

model Lesson {
// ...
resources LessonResource[]
// ...
}

El modelo WishlistItem permite a los usuarios guardar cursos para ver después, con una nota personal opcional.

model WishlistItem {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
courseId Int
note String? @db.Text
@@unique([userId, courseId])
@@index([userId])
@@index([courseId])
}
CampoTipoDescripción
userIdStringReferencia al usuario dueño del ítem
courseIdIntReferencia al curso guardado
noteString?Nota personal (máx. 500 caracteres)

Restricción @@unique([userId, courseId]) garantiza un único ítem por combinación usuario-curso.

El modelo Instructor ahora incluye un vínculo opcional a una cuenta de usuario mediante userId. Esto permite:

  • Verificar permisos de instructor para marcar respuestas oficiales
  • Vincular la identidad del instructor con una cuenta activa
  • Gestionar contenido y respuestas desde la misma cuenta
model Instructor {
// ...
userId String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
// ...
}
model User {
// ...
instructorProfile Instructor?
// ...
}

Tabla central para gestionar todos los planes de precio (personales y de equipos) desde el Admin Panel. Los Price IDs de Stripe se almacenan aquí — no en variables de entorno.

model PricingPlan {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
planId String @unique // "monthly-subscription", "team-50"
type PlanType
status PlanStatus @default(DRAFT)
priceInCents Int
currency String @default("USD")
billingPeriod BillingPeriod
creditAmount Int? // Para planes de créditos
maxSeats Int? // Para planes de equipos (null = ilimitado)
stripeProductId String?
stripePriceId String?
lemonSqueezyVariantId String?
polarProductId String?
isBestDeal Boolean @default(false)
displayOrder Int @default(0)
createdBy String?
lastModifiedBy String?
priceHistory PricingPlanHistory[]
@@index([planId])
@@index([type, status])
}
enum PlanType {
PERSONAL_SUBSCRIPTION // Suscripciones personales (/pricing)
PERSONAL_CREDITS // Compra de créditos (/pricing)
TEAM_SUBSCRIPTION // Planes de equipos (/teams)
}
enum PlanStatus {
DRAFT // No visible ni disponible para compra
ACTIVE // Visible y disponible para compra
ARCHIVED // Ya no disponible (usuarios existentes grandfathered)
HIDDEN // Oculto en pricing page pero operable
}
enum BillingPeriod {
ONETIME // Pago único (créditos)
MONTHLY // Suscripción mensual
ANNUAL // Suscripción anual
}
CampoTipoDescripción
planIdString uniqueIdentificador textual: monthly-subscription, team-50, etc.
typePlanTypeClasifica el plan para filtrar en /pricing vs /teams
statusPlanStatusControla visibilidad y disponibilidad
priceInCentsIntPrecio en centavos USD (1900 = $19.00)
billingPeriodBillingPeriodDetermina si es recurrente (MONTHLY/ANNUAL) o único (ONETIME)
creditAmountInt?Solo para PERSONAL_CREDITS — cuántos créditos otorga la compra
maxSeatsInt?Solo para TEAM_SUBSCRIPTION — null significa ilimitado (Enterprise)
stripePriceIdString?Price ID de Stripe (price_xxx). Se setea desde el Admin Panel
stripeProductIdString?Product ID de Stripe. Se rellena automáticamente al hacer Sync
isBestDealBooleanMuestra badge “Mejor oferta” en la página de precios
displayOrderIntOrden de aparición en la página de precios

Planes activos actuales:

planIdtypebillingPeriodstatus
monthly-subscriptionPERSONAL_SUBSCRIPTIONMONTHLYACTIVE
annual-subscriptionPERSONAL_SUBSCRIPTIONANNUALACTIVE
credits-10PERSONAL_CREDITSONETIMEACTIVE
team-10TEAM_SUBSCRIPTIONMONTHLYACTIVE
team-50TEAM_SUBSCRIPTIONMONTHLYACTIVE
team-100TEAM_SUBSCRIPTIONMONTHLYACTIVE
enterpriseTEAM_SUBSCRIPTIONMONTHLYACTIVE
team-10-annualTEAM_SUBSCRIPTIONANNUALDRAFT
team-50-annualTEAM_SUBSCRIPTIONANNUALDRAFT
team-100-annualTEAM_SUBSCRIPTIONANNUALDRAFT
hobbyPERSONAL_SUBSCRIPTIONMONTHLYARCHIVED
proPERSONAL_SUBSCRIPTIONMONTHLYARCHIVED

Auditoría de cambios de precio y estado. Se crea un registro automáticamente cada vez que cambia priceInCents o status de un plan.

model PricingPlanHistory {
id String @id @default(uuid())
createdAt DateTime @default(now())
plan PricingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
planId String
priceInCents Int
currency String
status PlanStatus
creditAmount Int?
maxSeats Int?
changedBy String
changeReason String?
@@index([planId, createdAt])
}
CampoTipoDescripción
priceInCentsIntSnapshot del precio al momento del cambio
statusEnumSnapshot del estado al momento del cambio
changedByStringEmail del admin que realizó el cambio
changeReasonString?Razón opcional del cambio (visible en Admin Panel)

El modelo DemoRequest almacena solicitudes de demo de empresas interesadas en la plataforma para capacitación de equipos (B2B/Teams). Esta información se captura desde la página /teams.

model DemoRequest {
id String @id @default(uuid())
createdAt DateTime @default(now())
companyName String
contactName String
email String
phone String?
teamSize String
message String?
isContacted Boolean @default(false)
contactedAt DateTime?
notes String?
}
CampoTipoDescripción
idStringUUID único
createdAtDateTimeTimestamp de creación de la solicitud
companyNameStringNombre de la empresa
contactNameStringNombre de la persona de contacto
emailStringEmail corporativo del contacto
phoneString?Teléfono opcional
teamSizeStringTamaño del equipo (opciones: 1-10, 11-50, 51-100, 101-500, 500+)
messageString?Mensaje opcional con detalles sobre necesidades de capacitación
isContactedBooleanFlag para marcar si ya se contactó (default: false)
contactedAtDateTime?Timestamp de cuando se contactó al lead
notesString?Notas internas del equipo de ventas

Reglas de negocio:

  • Email requerido y validado con regex básico
  • teamSize viene de opciones predefinidas en el formulario
  • Los campos de gestión interna (isContacted, contactedAt, notes) no son visibles para el usuario
  • TODO: Integración con CRM (HubSpot, Salesforce)
  • TODO: Notificación automática al equipo de ventas

Operaciones:

  • submitDemoRequest - Crea una nueva solicitud desde el formulario público
  • Futura: getAdminDemoRequests - Lista solicitudes en panel de administración
  • Futura: updateDemoRequestStatus - Marca como contactado/agrega notas

Origen de datos:

  • Formulario en /teams (sección #demo-form)
  • Validación con React Hook Form + Zod
  • Componente: TeamsDemoForm.tsx

Modelo para equipos B2B. Incluye un pool de créditos compartido entre todos los miembros (Opción D — economía de créditos pura).

model Organization {
id String @id @default(uuid())
name String
domain String?
// Seat management
maxSeats Int
usedSeats Int @default(0)
// Credits pool (Opción D)
credits Int @default(0) // Balance actual del pool
monthlyCredits Int @default(0) // Asignación mensual según plan
creditsCap Int @default(0) // Límite máximo (3 × monthlyCredits)
// Subscription
subscriptionStatus String?
subscriptionPlan String?
paymentProcessorCustomerId String? @unique
datePaid DateTime?
}
CampoTipoDescripción
creditsIntBalance actual del pool de créditos del equipo
monthlyCreditsIntCréditos que se añaden cada mes según el plan
creditsCapIntMáximo acumulable = monthlyCredits × 3

Estructura de créditos por plan (tasa $1 = 100 cr):

PlanPreciocr/mesCap (3×)
Team 10$99/mo9,90029,700
Team 50$399/mo39,900119,700
Team 100$699/mo69,900209,700

Lógica de deducción:

  1. Miembros de org con suscripción activa → acceso libre (sin deducción)
  2. Pool de org tiene créditos suficientes → deduce del pool
  3. Usuario tiene créditos personales suficientes → deduce del usuario
  4. Sin créditos → error 402 Payment Required

Modelo para el calendario de planificación del admin. Permite a los administradores crear eventos, fechas importantes y lanzamientos de cursos.

model CalendarEvent {
id Int @id @default(autoincrement())
title String
description String?
startDate DateTime
endDate DateTime?
type String @default("general")
color String @default("blue")
courseId Int?
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
createdBy String
user User @relation(fields: [createdBy], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([startDate])
@@index([createdBy])
@@index([courseId])
}
CampoTipoDescripción
titleStringTítulo del evento
descriptionString?Descripción opcional
startDateDateTimeFecha de inicio
endDateDateTime?Fecha de fin (opcional)
typeStringgeneral | course_launch | important_date
colorStringblue | green | red | yellow | purple
courseIdInt?Curso relacionado (opcional)
createdByStringID del admin que creó el evento

Permite a los estudiantes guardar momentos específicos de un video con un título y descripción opcional.

model VideoBookmark {
id Int @id @default(autoincrement())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lessonId Int
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
timestamp Int // segundos
title String
description String?
createdAt DateTime @default(now())
@@index([userId, lessonId])
}
CampoTipoDescripción
userIdStringID del usuario que creó el marcador
lessonIdIntID de la lección
timestampIntTiempo en segundos dentro del video
titleStringTítulo descriptivo del marcador
descriptionString?Descripción opcional

Acceso: Solo usuarios inscritos (o lecciones de vista previa).

Notas de texto libre que los estudiantes pueden asociar a una lección, con timestamp de video opcional y auto-guardado.

model LessonNote {
id Int @id @default(autoincrement())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lessonId Int
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
content String @db.Text
timestamp Int? // timestamp de video opcional
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, lessonId])
}
CampoTipoDescripción
userIdStringID del usuario
lessonIdIntID de la lección
contentStringContenido de la nota (hasta 10.000 caracteres)
timestampInt?Segundo del video al que se vincula (opcional)

Características: Auto-guardado con debounce de 1500ms, búsqueda client-side, edición in-place.