582 lines
16 KiB
TypeScript
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),
|
|
};
|
|
}
|