import type { Prisma } from "@prisma/client"; import { db } from "@/lib/prisma"; import { ACADEMIC_STAGE_CONFIG, ACADEMIC_STAGE_ORDER, PUBLIC_UPCOMING_TAGS, type AcademicStageConfig, type AcademicStageId, formatDuration, formatMinutes, getCourseDescriptions, getLocalizedText, getProficiencyLabel, resolveAcademicStage, resolveAvailability, resolveThumbnailUrl, truncateText, } from "@/lib/courses/presentation"; import { isFinalExam, parseLessonDescriptionMeta } from "@/lib/courses/lessonContent"; type CatalogCourseRow = Prisma.CourseGetPayload<{ include: { author: { select: { fullName: true; }; }; modules: { orderBy: { orderIndex: "asc"; }; select: { lessons: { orderBy: { orderIndex: "asc"; }; select: { id: true; estimatedDuration: true; isFreePreview: true; youtubeUrl: true; videoUrl: true; }; }; }; }; _count: { select: { enrollments: true; }; }; }; }>; type DetailCourseRow = Prisma.CourseGetPayload<{ include: { author: { select: { fullName: true; }; }; modules: { orderBy: { orderIndex: "asc"; }; select: { id: true; title: true; orderIndex: true; lessons: { orderBy: { orderIndex: "asc"; }; select: { id: true; title: true; description: true; orderIndex: true; estimatedDuration: true; isFreePreview: true; youtubeUrl: true; videoUrl: true; _count: { select: { resources: true; exercises: true; }; }; }; }; }; }; _count: { select: { enrollments: true; }; }; }; }>; export type CatalogCourseCardView = { id: string; slug: string; title: string; shortDescription: string; longDescription: string; thumbnailUrl: string | null; stageId: AcademicStageId; stageLabel: string; proficiencyLabel: string; durationLabel: string; durationMinutes: number; lessonCount: number; instructor: string; availabilityLabel: string; availabilityState: "published" | "upcoming" | "draft"; progressPercent: number; completedLessons: number; totalLessons: number; studentsCount: number; isEnrolled: boolean; hasPreview: boolean; }; export type CatalogSectionView = { id: AcademicStageId; anchorId: string; tabLabel: string; sectionTitle: string; sectionDescription: string; courses: CatalogCourseCardView[]; }; export type CourseCatalogViewModel = { sections: CatalogSectionView[]; totals: { totalCourses: number; totalLessons: number; instructorCount: number; }; }; type ProgramBadge = "Video" | "Lectura" | "Actividad" | "Evaluación"; export type CourseProgramItemView = { id: string; order: number; title: string; subtitle: string; durationLabel: string | null; badges: ProgramBadge[]; isPreview: boolean; isFinalExam: boolean; isLocked: boolean; isCompleted: boolean; isUpcoming: boolean; }; export type CourseProgramModuleView = { id: string; title: string; order: number; items: CourseProgramItemView[]; }; export type CourseDetailViewModel = { id: string; slug: string; title: string; shortDescription: string; longDescription: string; thumbnailUrl: string | null; stage: AcademicStageConfig; proficiencyLabel: string; instructor: string; availabilityLabel: string; availabilityState: "published" | "upcoming" | "draft"; studentsCount: number; lessonCount: number; durationLabel: string; durationMinutes: number; moduleCount: number; modules: CourseProgramModuleView[]; progressPercent: number; completedLessons: number; totalLessons: number; isEnrolled: boolean; firstPreviewLessonId: string | null; price: number; }; function toMinutes(totalSeconds: number): number { if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return 0; return Math.max(1, Math.ceil(totalSeconds / 60)); } function getPrimaryMedia(lessons: Array<{ youtubeUrl: string | null; videoUrl: string | null }>) { const withYoutube = lessons.find((lesson) => Boolean(lesson.youtubeUrl?.trim())); if (withYoutube) return { youtubeUrl: withYoutube.youtubeUrl, videoUrl: withYoutube.videoUrl }; const withVideo = lessons.find((lesson) => Boolean(lesson.videoUrl?.trim())); if (withVideo) return { youtubeUrl: withVideo.youtubeUrl, videoUrl: withVideo.videoUrl }; return { youtubeUrl: null, videoUrl: null }; } function getProgramBadges(lesson: DetailCourseRow["modules"][number]["lessons"][number]): ProgramBadge[] { const lessonMeta = parseLessonDescriptionMeta(lesson.description); const badges: ProgramBadge[] = []; if (lessonMeta.contentType === "VIDEO") badges.push("Video"); if (lessonMeta.contentType === "LECTURE") badges.push("Lectura"); if (lessonMeta.contentType === "ACTIVITY") badges.push("Actividad"); if (lessonMeta.contentType === "QUIZ" || lessonMeta.contentType === "FINAL_EXAM") badges.push("Evaluación"); if (lesson.youtubeUrl || lesson.videoUrl) { if (!badges.includes("Video")) badges.push("Video"); } if (lesson._count.resources > 0 && !badges.includes("Lectura")) badges.push("Lectura"); if (lesson._count.exercises > 0 && !badges.includes("Evaluación")) badges.push("Evaluación"); if (badges.length === 0) badges.push("Actividad"); return badges; } function getPublicCatalogWhere() { return { OR: [ { status: "PUBLISHED" as const }, { status: "DRAFT" as const, tags: { hasSome: PUBLIC_UPCOMING_TAGS, }, }, ], }; } export async function getCourseCatalogViewModel(userId: string | null): Promise { const courses = await db.course .findMany({ where: getPublicCatalogWhere(), include: { author: { select: { fullName: true, }, }, modules: { orderBy: { orderIndex: "asc", }, select: { lessons: { orderBy: { orderIndex: "asc", }, select: { id: true, estimatedDuration: true, isFreePreview: true, youtubeUrl: true, videoUrl: true, }, }, }, }, _count: { select: { enrollments: true, }, }, }, orderBy: [ { updatedAt: "desc", }, ], }) .catch((error) => { console.error("Failed to load public course catalog.", error); return [] as CatalogCourseRow[]; }); const courseIds = courses.map((course) => course.id); const [enrollments, completedProgress] = userId && courseIds.length > 0 ? await Promise.all([ db.enrollment .findMany({ where: { userId, courseId: { in: courseIds, }, }, select: { courseId: true, }, }) .catch(() => []), db.userProgress .findMany({ where: { userId, isCompleted: true, lesson: { module: { courseId: { in: courseIds, }, }, }, }, select: { lesson: { select: { module: { select: { courseId: true, }, }, }, }, }, }) .catch(() => []), ]) : [[], []]; const enrollmentSet = new Set(enrollments.map((entry) => entry.courseId)); const completedByCourse = new Map(); for (const entry of completedProgress) { const courseId = entry.lesson.module.courseId; completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1); } const grouped: Record = { base: [], consolidacion: [], especializacion: [], }; for (const course of courses) { const allLessons = course.modules.flatMap((module) => module.lessons); const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0); const totalLessons = allLessons.length; const completedLessons = completedByCourse.get(course.id) ?? 0; const isEnrolled = enrollmentSet.has(course.id); const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; const stage = resolveAcademicStage(course.level, course.tags); const availability = resolveAvailability(course.status, course.tags); const descriptions = getCourseDescriptions(course.description); const media = getPrimaryMedia(allLessons); const thumbnailUrl = resolveThumbnailUrl({ description: course.description, tags: course.tags, youtubeUrl: media.youtubeUrl, videoUrl: media.videoUrl, }) || null; grouped[stage.id].push({ id: course.id, slug: course.slug, title: getLocalizedText(course.title) || "Programa académico ACVE", shortDescription: descriptions.short, longDescription: descriptions.long, thumbnailUrl, stageId: stage.id, stageLabel: stage.levelLabel, proficiencyLabel: getProficiencyLabel(course.level), durationLabel: formatDuration(totalSeconds), durationMinutes: toMinutes(totalSeconds), lessonCount: totalLessons, instructor: course.author.fullName || "Equipo académico ACVE", availabilityLabel: availability.label, availabilityState: availability.state, progressPercent, completedLessons, totalLessons, studentsCount: course._count.enrollments, isEnrolled, hasPreview: allLessons.some((lesson) => lesson.isFreePreview), }); } const sections: CatalogSectionView[] = ACADEMIC_STAGE_ORDER.map((stageId) => { const config = ACADEMIC_STAGE_CONFIG[stageId]; return { id: stageId, anchorId: `programas-${stageId}`, tabLabel: config.tabLabel, sectionTitle: config.sectionTitle, sectionDescription: config.sectionDescription, courses: grouped[stageId], }; }); const totalLessons = sections.reduce( (sum, section) => sum + section.courses.reduce((sectionSum, course) => sectionSum + course.lessonCount, 0), 0, ); const instructorCount = new Set(sections.flatMap((section) => section.courses.map((course) => course.instructor))).size; const totalCourses = sections.reduce((sum, section) => sum + section.courses.length, 0); return { sections, totals: { totalCourses, totalLessons, instructorCount, }, }; } function toNumber(value: Prisma.Decimal): number { const asNumber = Number(value); return Number.isFinite(asNumber) ? asNumber : 0; } export async function getCourseDetailViewModel( slug: string, userId: string | null, ): Promise { const course = await db.course.findFirst({ where: { slug, ...getPublicCatalogWhere(), }, include: { author: { select: { fullName: true, }, }, modules: { orderBy: { orderIndex: "asc", }, select: { id: true, title: true, orderIndex: true, lessons: { orderBy: { orderIndex: "asc", }, select: { id: true, title: true, description: true, orderIndex: true, estimatedDuration: true, isFreePreview: true, youtubeUrl: true, videoUrl: true, _count: { select: { resources: true, exercises: true, }, }, }, }, }, }, _count: { select: { enrollments: true, }, }, }, }); if (!course) return null; const allLessons = course.modules.flatMap((module) => module.lessons); const lessonIds = allLessons.map((lesson) => lesson.id); const [enrollment, completedProgress] = userId ? await Promise.all([ db.enrollment.findUnique({ where: { userId_courseId: { userId, courseId: course.id, }, }, select: { id: true, }, }), lessonIds.length > 0 ? db.userProgress.findMany({ where: { userId, isCompleted: true, lessonId: { in: lessonIds, }, }, select: { lessonId: true, }, }) : Promise.resolve([] as { lessonId: string }[]), ]) : [null, [] as { lessonId: string }[]]; const completedSet = new Set(completedProgress.map((entry) => entry.lessonId)); const isEnrolled = Boolean(enrollment); const totalLessons = allLessons.length; const completedLessons = completedSet.size; const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0); const stage = resolveAcademicStage(course.level, course.tags); const availability = resolveAvailability(course.status, course.tags); const descriptions = getCourseDescriptions(course.description); const media = getPrimaryMedia(allLessons); const thumbnailUrl = resolveThumbnailUrl({ description: course.description, tags: course.tags, youtubeUrl: media.youtubeUrl, videoUrl: media.videoUrl, }) || null; let runningOrder = 0; const modules: CourseProgramModuleView[] = course.modules.map((module) => ({ id: module.id, title: getLocalizedText(module.title) || `Módulo ${module.orderIndex + 1}`, order: module.orderIndex + 1, items: module.lessons.map((lesson) => { runningOrder += 1; const lessonMeta = parseLessonDescriptionMeta(lesson.description); const subtitleRaw = lessonMeta.text || getLocalizedText(lesson.description); const subtitle = subtitleRaw ? truncateText(subtitleRaw, 130) : ""; const isUpcoming = availability.state !== "published"; const isLocked = !isUpcoming && !isEnrolled && !lesson.isFreePreview; return { id: lesson.id, order: runningOrder, title: getLocalizedText(lesson.title) || `Lección ${runningOrder}`, subtitle, durationLabel: lesson.estimatedDuration > 0 ? formatMinutes(toMinutes(lesson.estimatedDuration)) : null, badges: getProgramBadges(lesson), isPreview: lesson.isFreePreview, isFinalExam: isFinalExam(lessonMeta.contentType), isLocked, isCompleted: completedSet.has(lesson.id), isUpcoming, }; }), })); return { id: course.id, slug: course.slug, title: getLocalizedText(course.title) || "Programa académico ACVE", shortDescription: descriptions.short, longDescription: descriptions.long, thumbnailUrl, stage, proficiencyLabel: getProficiencyLabel(course.level), instructor: course.author.fullName || "Equipo académico ACVE", availabilityLabel: availability.label, availabilityState: availability.state, studentsCount: course._count.enrollments, lessonCount: totalLessons, durationLabel: formatDuration(totalSeconds), durationMinutes: toMinutes(totalSeconds), moduleCount: modules.length, modules, progressPercent, completedLessons, totalLessons, isEnrolled, firstPreviewLessonId: allLessons.find((lesson) => lesson.isFreePreview)?.id ?? null, price: toNumber(course.price), }; }