Files
ACVE/lib/courses/publicCourses.ts
2026-03-15 13:52:11 +00:00

582 lines
16 KiB
TypeScript

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<CourseCatalogViewModel> {
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<string, number>();
for (const entry of completedProgress) {
const courseId = entry.lesson.module.courseId;
completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1);
}
const grouped: Record<AcademicStageId, CatalogCourseCardView[]> = {
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<CourseDetailViewModel | null> {
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),
};
}