Base de Datos
Sección titulada «Base de Datos»TalentBricksAI usa PostgreSQL con Prisma ORM. Los modelos se definen en app/schema.prisma.
Diagrama de Relaciones
Sección titulada «Diagrama de Relaciones»┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ User │────▶│ Enrollment │◀────│ Course │└─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ │ ▼ ▼ ▼┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ Certificate │ │LessonProgress│◀───│ Lesson │└─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ ▼ ▼┌─────────────┐ ┌─────────────┐│ Review │◀────────────────────────│ Course │└─────────────┘ └─────────────┘ │ │ ▼ ┌─────────────┐ │ Quiz │◀───┐ └─────────────┘ │ │ │ │ │ ▼ │ ┌──────────────┐ │ ┌─────────────────▶│QuizQuestion │ │ │ └──────────────┘ │ │ │ │ │ │ │┌─────────────┐ │ ▼ ││ User │──────┼──────────────────┌──────────────┐ │└─────────────┘ │ │ QuizAnswer │ │ │ └──────────────┘ │ │ │ └─────────────────────────────────────┘ QuizAttemptModelos de Cursos
Sección titulada «Modelos de Cursos»Instructor
Sección titulada «Instructor»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}| Campo | Tipo | descripción |
|---|---|---|
id | String | UUID único |
name | String | Nombre completo del instructor |
slug | String | URL-friendly identifier, único |
title | String? | Título profesional (ej. “Senior Engineer”) |
bio | String? | Biografía del instructor |
avatarUrl | String? | URL de la foto de perfil |
isPublished | Boolean | Solo 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
Course (Curso)
Sección titulada «Course (Curso)»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[]}| Campo | Tipo | descripción |
|---|---|---|
id | Int | ID autoincremental |
slug | String | URL-friendly identifier, único |
price | Int | Precio en centavos (2900 = $29.00) |
difficulty | Enum | BEGINNER, INTERMEDIATE, ADVANCED |
isPublished | Boolean | Solo cursos publicados son visibles |
instructorName | String | Nombre del instructor (legacy) |
instructorId | String? | Referencia al instructor |
instructor | Instructor? | Relación con el modelo Instructor |
creditCostPerLesson | Int | Créditos por lección (default 50 = $0.50) |
Lesson (Leccion)
Sección titulada «Lesson (Leccion)»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[]}| Campo | Tipo | descripción |
|---|---|---|
order | Int | Orden de la leccion en el curso |
duration | Int | Duracion en segundos |
storageVideoKey | String? | Clave en el proveedor de almacenamiento (S3/Azure). En dev puede ser una URL directa de video MP4. |
isPreview | Boolean | Lecciones preview son accesibles sin enrollment |
isPublished | Boolean | false = borrador, invisible para estudiantes |
scheduledPublishAt | DateTime? | Fecha en que el job publica la lección automáticamente |
Enrollment (Inscripcion)
Sección titulada «Enrollment (Inscripcion)»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.
LessonProgress (Progreso de Leccion)
Sección titulada «LessonProgress (Progreso de Leccion)»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])}| Campo | Tipo | descripción |
|---|---|---|
watchedSeconds | Int | Segundos vistos del video |
isCompleted | Boolean | Leccion marcada como completada |
Certificate (Certificado)
Sección titulada «Certificate (Certificado)»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])}| Campo | Tipo | descripción |
|---|---|---|
verificationCode | String | UUID único para verificación pública |
studentName | String | Nombre del estudiante (snapshot) |
courseTitle | String | Título del curso (snapshot) |
instructorName | String | Nombre del instructor (snapshot) |
totalDurationMinutes | Int | Duracion total en minutos (snapshot) |
Review (Resena)
Sección titulada «Review (Resena)»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}| Campo | Tipo | descripción |
|---|---|---|
rating | Int | Calificacion de 1 a 5 estrellas |
title | String | Título corto de la resena |
comment | String | Comentario detallado |
userId | String | Usuario que escribio la resena |
courseId | Int | Curso al que pertenece la resena |
Modelos de Quizzes y Evaluaciones
Sección titulada «Modelos de Quizzes y Evaluaciones»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])}| Campo | Tipo | Descripción |
|---|---|---|
courseId | Int | Curso al que pertenece el quiz |
lessonId | Int? | Lección asociada (null = examen final) |
passingScore | Int | Puntuación mínima para aprobar (%) |
timeLimit | Int? | Límite de tiempo en segundos (null = sin límite) |
isRequired | Boolean | Si el quiz es obligatorio para avanzar |
isFinalExam | Boolean | Marca si es un examen final del curso |
QuizQuestion (Pregunta de Quiz)
Sección titulada «QuizQuestion (Pregunta de Quiz)»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])}| Campo | Tipo | Descripción |
|---|---|---|
question | String | Texto de la pregunta |
order | Int | Orden de la pregunta en el quiz |
explanation | String | Explicación opcional mostrada tras responder |
QuizAnswer (Respuesta de Pregunta)
Sección titulada «QuizAnswer (Respuesta de Pregunta)»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])}| Campo | Tipo | Descripción |
|---|---|---|
answer | String | Texto de la respuesta |
isCorrect | Boolean | Si es la respuesta correcta |
order | Int | Orden de visualización |
QuizAttempt (Intento de Quiz)
Sección titulada «QuizAttempt (Intento de Quiz)»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])}| Campo | Tipo | Descripción |
|---|---|---|
score | Int | Puntuación obtenida (0-100) |
passed | Boolean | Si aprobó el quiz |
timeSpent | Int? | Tiempo empleado en segundos |
answers | String | JSON con las respuestas del usuario |
Modelos de Referidos y Promociones
Sección titulada «Modelos de Referidos y Promociones»ReferralCode (código de Referido)
Sección titulada «ReferralCode (código de Referido)»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])}| Campo | Tipo | descripción |
|---|---|---|
code | String | código único de 8 caracteres (ej: “abc12def”) |
clicks | Int | número de clicks en el link de referido |
signups | Int | número de usuarios que se registraron |
conversions | Int | número de usuarios que compraron |
ReferralSignup (Registro de Referido)
Sección titulada «ReferralSignup (Registro de Referido)»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])}| Campo | Tipo | descripción |
|---|---|---|
referredUserId | String | ID del usuario que se registró (único) |
convertedAt | DateTime? | Fecha de la primera compra |
rewardClaimed | Boolean | Si el referidor ya reclamo su recompensa |
source | String? | Fuente de la referencia (utm_source) |
PromoCode (código Promocional)
Sección titulada «PromoCode (código Promocional)»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)}| Campo | Tipo | descripción |
|---|---|---|
code | String | código promocional (ej: “VERANO20”) |
discountType | Enum | PERCENTAGE o FIXED_AMOUNT |
discountValue | Int | Valor del descuento |
maxUses | Int? | Limite de usos (null = ilimitado) |
currentUses | Int | Usos actuales |
courseIds | String[] | IDs de cursos validos (vacio = todos) |
newUsersOnly | Boolean | Solo para usuarios nuevos |
minPurchase | Int? | Compra minima requerida |
UserReward (Recompensa de Usuario)
Sección titulada «UserReward (Recompensa de Usuario)»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}| Campo | Tipo | descripción |
|---|---|---|
rewardType | String | Tipo de recompensa |
value | Int | Valor (porcentaje o centavos) |
description | String? | descripción de como se obtuvo |
expiresAt | DateTime | Fecha de expiracion |
isRedeemed | Boolean | Si ya fue usada |
Modelo User (Actualizado)
Sección titulada «Modelo User (Actualizado)»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[]}Campos de configuración de Perfil
Sección titulada «Campos de configuración de Perfil»| Campo | Tipo | descripción |
|---|---|---|
showProfilePublic | Boolean | Si true, el perfil es público y visible para otros usuarios |
showInLeaderboards | Boolean | Si true, el usuario aparece en rankings y tablas de clasificacion |
displayName | String? | Nombre para mostrar en el perfil (opcional) |
bio | String? | Biografia del usuario (max 500 caracteres) |
avatarUrl | String? | URL de la imagen de perfil almacenada en S3 |
timezone | String? | Zona horaria del usuario (default: “UTC”) |
linkedinUrl | String? | URL del perfil de LinkedIn |
githubUrl | String? | URL del perfil de GitHub |
twitterUrl | String? | URL del perfil de Twitter |
Campos de Preferencias
Sección titulada «Campos de Preferencias»| Campo | Tipo | descripción |
|---|---|---|
emailNotifications | Boolean | Si true, el usuario recibe notificaciones por email |
defaultPlaybackSpeed | Float | Velocidad de reproduccion predeterminada (0.5 - 2.0) |
autoplayNextLesson | Boolean | Si true, reproduce automaticamente la siguiente leccion |
Campos de Seguridad
Sección titulada «Campos de Seguridad»| Campo | Tipo | descripción |
|---|---|---|
mustChangePassword | Boolean | Si 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 |
twoFactorEnabled | Boolean | Si true, el usuario tiene 2FA activado con una app autenticadora (TOTP) |
twoFactorSecret | String? | Secreto TOTP cifrado con AES-256-GCM. Solo almacenado en el servidor |
twoFactorVerifiedAt | DateTime? | Marca de tiempo de la última verificación 2FA exitosa. La sesión expira a las 24 horas |
twoFactorBackupCodes | String[] | Array de códigos de respaldo hasheados (SHA-256). Hasta 8 códigos de un solo uso |
Comandos de Base de Datos
Sección titulada «Comandos de Base de Datos»# Iniciar PostgreSQL (dejar corriendo)wasp db start
# Crear migración después de cambiar schema.prismawasp db migrate-dev
# Abrir Prisma Studio (GUI para ver datos)wasp db studio
# Ejecutar seed scriptwasp db seedConsultas Comunes
Sección titulada «Consultas Comunes»Obtener cursos publicados con lecciones
Sección titulada «Obtener cursos publicados con lecciones»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, },});Calcular progreso de un curso
Sección titulada «Calcular progreso de un curso»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);Índices de Base de Datos
Sección titulada «Índices de Base de Datos»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.
Índices por Modelo
Sección titulada «Índices por Modelo»@@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 cursosPropó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)
Enrollment
Sección titulada «Enrollment»@@unique([userId, courseId])@@index([userId])@@index([courseId])@@index([createdAt])// Índices compuestos para rendimiento@@index([userId, completedAt]) // Dashboard de cursos completadosPropósito:
[userId, completedAt]- Dashboard del usuario para mostrar cursos completados y en progreso
LessonProgress
Sección titulada «LessonProgress»@@unique([userId, lessonId])// Índices compuestos para rendimiento@@index([userId, isCompleted]) // Seguimiento de progreso del usuario@@index([enrollmentId, isCompleted]) // Cálculo de finalización de cursoPropósito:
[userId, isCompleted]- Tracking de progreso por usuario (10x más rápido)[enrollmentId, isCompleted]- Calcular porcentaje de finalización de curso
Certificate
Sección titulada «Certificate»@@unique([userId, courseId])@@index([verificationCode])// Índices compuestos para rendimiento@@index([userId, createdAt]) // Lista de certificados del usuarioPropósito:
[userId, createdAt]- Mostrar certificados del usuario ordenados por fecha
Comment
Sección titulada «Comment»@@index([lessonId, createdAt])@@index([userId])@@index([parentId])@@index([isDeleted])// Índices compuestos para rendimiento@@index([userId, createdAt]) // Historial de actividad del usuarioPropó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 cursoPropósito:
[courseId, createdAt]- Mostrar reseñas de un curso ordenadas por fecha
CommentVote
Sección titulada «CommentVote»@@unique([userId, commentId])@@index([commentId])Propósito:
- Un usuario solo puede votar una vez por comentario
- Lookup rápido de votos por comentario
Mejoras de Rendimiento
Sección titulada «Mejoras de Rendimiento»Después de agregar los índices compuestos (migración 20260315143904_add_performance_indexes):
| Query | Antes | Después | Mejora |
|---|---|---|---|
| Course listing por categoría | ~500ms | ~50ms | 10x |
| Dashboard de usuario | ~1s | ~100ms | 10x |
| Cálculo de progreso | ~200ms | ~20ms | 10x |
| Lista de certificados | ~150ms | ~15ms | 10x |
Modelos de Comentarios y Q&A
Sección titulada «Modelos de Comentarios y Q&A»Comment
Sección titulada «Comment»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])}| Campo | Tipo | Descripción |
|---|---|---|
id | Int | ID único autoincremental |
userId | String | ID del usuario que creó el comentario |
lessonId | Int | ID de la lección a la que pertenece |
content | String | Contenido del comentario (10-2000 caracteres) |
videoTimestamp | Int? | Timestamp del video en segundos (opcional) |
parentId | Int? | ID del comentario padre (para respuestas anidadas) |
isInstructorResponse | Boolean | Marca si es respuesta oficial del instructor |
markedAsSolution | Boolean | Marca si fue marcado como solución a la pregunta |
isEdited | Boolean | Indica si el comentario fue editado |
isDeleted | Boolean | Soft delete - preserva estructura de threads |
deletedAt | DateTime? | 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
CommentVote
Sección titulada «CommentVote»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])}| Campo | Tipo | Descripción |
|---|---|---|
id | Int | ID único autoincremental |
userId | String | ID del usuario que votó |
commentId | Int | ID del comentario votado |
value | Int | Valor 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
Modelo de Recursos Descargables
Sección titulada «Modelo de Recursos Descargables»LessonResource
Sección titulada «LessonResource»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])}| Campo | Tipo | Descripción |
|---|---|---|
id | Int | ID único autoincremental |
lessonId | Int | ID de la lección asociada |
title | String | Título del recurso |
description | String? | Descripción opcional del recurso |
fileName | String | Nombre original del archivo |
fileType | String | MIME type (application/pdf, etc.) |
fileSize | Int | Tamaño del archivo en bytes |
s3Key | String | Ruta en S3: lessons/{lessonId}/resources/{uuid} |
order | Int | Orden 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[] // ...}WishlistItem
Sección titulada «WishlistItem»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])}| Campo | Tipo | Descripción |
|---|---|---|
userId | String | Referencia al usuario dueño del ítem |
courseId | Int | Referencia al curso guardado |
note | String? | Nota personal (máx. 500 caracteres) |
Restricción @@unique([userId, courseId]) garantiza un único ítem por combinación usuario-curso.
Relación User-Instructor
Sección titulada «Relación User-Instructor»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? // ...}Modelos de Gestión de Precios
Sección titulada «Modelos de Gestión de Precios»PricingPlan
Sección titulada «PricingPlan»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}| Campo | Tipo | Descripción |
|---|---|---|
planId | String unique | Identificador textual: monthly-subscription, team-50, etc. |
type | PlanType | Clasifica el plan para filtrar en /pricing vs /teams |
status | PlanStatus | Controla visibilidad y disponibilidad |
priceInCents | Int | Precio en centavos USD (1900 = $19.00) |
billingPeriod | BillingPeriod | Determina si es recurrente (MONTHLY/ANNUAL) o único (ONETIME) |
creditAmount | Int? | Solo para PERSONAL_CREDITS — cuántos créditos otorga la compra |
maxSeats | Int? | Solo para TEAM_SUBSCRIPTION — null significa ilimitado (Enterprise) |
stripePriceId | String? | Price ID de Stripe (price_xxx). Se setea desde el Admin Panel |
stripeProductId | String? | Product ID de Stripe. Se rellena automáticamente al hacer Sync |
isBestDeal | Boolean | Muestra badge “Mejor oferta” en la página de precios |
displayOrder | Int | Orden de aparición en la página de precios |
Planes activos actuales:
| planId | type | billingPeriod | status |
|---|---|---|---|
monthly-subscription | PERSONAL_SUBSCRIPTION | MONTHLY | ACTIVE |
annual-subscription | PERSONAL_SUBSCRIPTION | ANNUAL | ACTIVE |
credits-10 | PERSONAL_CREDITS | ONETIME | ACTIVE |
team-10 | TEAM_SUBSCRIPTION | MONTHLY | ACTIVE |
team-50 | TEAM_SUBSCRIPTION | MONTHLY | ACTIVE |
team-100 | TEAM_SUBSCRIPTION | MONTHLY | ACTIVE |
enterprise | TEAM_SUBSCRIPTION | MONTHLY | ACTIVE |
team-10-annual | TEAM_SUBSCRIPTION | ANNUAL | DRAFT |
team-50-annual | TEAM_SUBSCRIPTION | ANNUAL | DRAFT |
team-100-annual | TEAM_SUBSCRIPTION | ANNUAL | DRAFT |
hobby | PERSONAL_SUBSCRIPTION | MONTHLY | ARCHIVED |
pro | PERSONAL_SUBSCRIPTION | MONTHLY | ARCHIVED |
PricingPlanHistory
Sección titulada «PricingPlanHistory»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])}| Campo | Tipo | Descripción |
|---|---|---|
priceInCents | Int | Snapshot del precio al momento del cambio |
status | Enum | Snapshot del estado al momento del cambio |
changedBy | String | Email del admin que realizó el cambio |
changeReason | String? | Razón opcional del cambio (visible en Admin Panel) |
Modelo de Solicitudes de Demo (B2B)
Sección titulada «Modelo de Solicitudes de Demo (B2B)»DemoRequest
Sección titulada «DemoRequest»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?}| Campo | Tipo | Descripción |
|---|---|---|
id | String | UUID único |
createdAt | DateTime | Timestamp de creación de la solicitud |
companyName | String | Nombre de la empresa |
contactName | String | Nombre de la persona de contacto |
email | String | Email corporativo del contacto |
phone | String? | Teléfono opcional |
teamSize | String | Tamaño del equipo (opciones: 1-10, 11-50, 51-100, 101-500, 500+) |
message | String? | Mensaje opcional con detalles sobre necesidades de capacitación |
isContacted | Boolean | Flag para marcar si ya se contactó (default: false) |
contactedAt | DateTime? | Timestamp de cuando se contactó al lead |
notes | String? | 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
Modelos B2B — Organizaciones y Créditos
Sección titulada «Modelos B2B — Organizaciones y Créditos»Organization (Organización)
Sección titulada «Organization (Organización)»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?}| Campo | Tipo | Descripción |
|---|---|---|
credits | Int | Balance actual del pool de créditos del equipo |
monthlyCredits | Int | Créditos que se añaden cada mes según el plan |
creditsCap | Int | Máximo acumulable = monthlyCredits × 3 |
Estructura de créditos por plan (tasa $1 = 100 cr):
| Plan | Precio | cr/mes | Cap (3×) |
|---|---|---|---|
| Team 10 | $99/mo | 9,900 | 29,700 |
| Team 50 | $399/mo | 39,900 | 119,700 |
| Team 100 | $699/mo | 69,900 | 209,700 |
Lógica de deducción:
- Miembros de org con suscripción activa → acceso libre (sin deducción)
- Pool de org tiene créditos suficientes → deduce del pool
- Usuario tiene créditos personales suficientes → deduce del usuario
- Sin créditos → error 402 Payment Required
CalendarEvent (Evento de Calendario)
Sección titulada «CalendarEvent (Evento de Calendario)»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])}| Campo | Tipo | Descripción |
|---|---|---|
title | String | Título del evento |
description | String? | Descripción opcional |
startDate | DateTime | Fecha de inicio |
endDate | DateTime? | Fecha de fin (opcional) |
type | String | general | course_launch | important_date |
color | String | blue | green | red | yellow | purple |
courseId | Int? | Curso relacionado (opcional) |
createdBy | String | ID del admin que creó el evento |
Marcadores y Notas de Lección
Sección titulada «Marcadores y Notas de Lección»VideoBookmark
Sección titulada «VideoBookmark»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])}| Campo | Tipo | Descripción |
|---|---|---|
userId | String | ID del usuario que creó el marcador |
lessonId | Int | ID de la lección |
timestamp | Int | Tiempo en segundos dentro del video |
title | String | Título descriptivo del marcador |
description | String? | Descripción opcional |
Acceso: Solo usuarios inscritos (o lecciones de vista previa).
LessonNote
Sección titulada «LessonNote»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])}| Campo | Tipo | Descripción |
|---|---|---|
userId | String | ID del usuario |
lessonId | Int | ID de la lección |
content | String | Contenido de la nota (hasta 10.000 caracteres) |
timestamp | Int? | Segundo del video al que se vincula (opcional) |
Características: Auto-guardado con debounce de 1500ms, búsqueda client-side, edición in-place.