Pending course, rest ready for launch

This commit is contained in:
Marcelo
2026-03-15 13:52:11 +00:00
parent be4ca2ed78
commit 62b3cfe467
77 changed files with 6450 additions and 868 deletions

View File

@@ -0,0 +1,107 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/lib/auth/requireUser";
import { db } from "@/lib/prisma";
import { issueCertificateIfEligible } from "@/lib/certificates";
import { refreshStudyRecommendations } from "@/lib/recommendations";
type ToggleLessonCompleteInput = {
courseSlug: string;
lessonId: string;
};
export async function toggleLessonComplete({ courseSlug, lessonId }: ToggleLessonCompleteInput) {
const user = await requireUser();
if (!user?.id) {
return { success: false, error: "Unauthorized" };
}
const lesson = await db.lesson.findUnique({
where: { id: lessonId },
select: {
id: true,
module: {
select: {
courseId: true,
course: {
select: {
slug: true,
},
},
},
},
},
});
if (!lesson || lesson.module.course.slug !== courseSlug) {
return { success: false, error: "Lesson not found" };
}
const enrollment = await db.enrollment.findUnique({
where: {
userId_courseId: {
userId: user.id,
courseId: lesson.module.courseId,
},
},
select: { id: true },
});
if (!enrollment) {
return { success: false, error: "Not enrolled in this course" };
}
const existingProgress = await db.userProgress.findUnique({
where: {
userId_lessonId: {
userId: user.id,
lessonId,
},
},
select: {
id: true,
isCompleted: true,
},
});
const nextCompleted = !existingProgress?.isCompleted;
if (existingProgress) {
await db.userProgress.update({
where: { id: existingProgress.id },
data: {
isCompleted: nextCompleted,
finishedAt: nextCompleted ? new Date() : null,
lastPlayedAt: new Date(),
},
});
} else {
await db.userProgress.create({
data: {
userId: user.id,
lessonId,
isCompleted: true,
finishedAt: new Date(),
},
});
}
const certificateResult = nextCompleted
? await issueCertificateIfEligible(user.id, lesson.module.courseId)
: { certificateId: null, certificateNumber: null, newlyIssued: false };
revalidatePath(`/courses/${courseSlug}/learn`);
revalidatePath("/my-courses");
revalidatePath("/profile");
await refreshStudyRecommendations(user.id);
return {
success: true,
isCompleted: nextCompleted,
certificateId: certificateResult.certificateId,
certificateNumber: certificateResult.certificateNumber,
newlyIssuedCertificate: certificateResult.newlyIssued,
};
}

View File

@@ -0,0 +1,192 @@
import { notFound, redirect } from "next/navigation";
import { db } from "@/lib/prisma";
import { requireUser } from "@/lib/auth/requireUser";
import StudentClassroomClient from "@/components/courses/StudentClassroomClient";
import { parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
type LessonSelect = {
id: string;
title: unknown;
description: unknown;
videoUrl: string | null;
youtubeUrl: string | null;
estimatedDuration: number;
isFreePreview: boolean;
};
type ModuleSelect = { id: string; title: unknown; lessons: LessonSelect[] };
type CourseWithModules = { id: string; slug: string; title: unknown; price: unknown; modules: ModuleSelect[] };
function getText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
const record = value as Record<string, unknown>;
if (typeof record.es === "string") return record.es;
if (typeof record.en === "string") return record.en;
}
return "";
}
type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ lesson?: string }>;
};
export default async function CourseLearnPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const { lesson: requestedLessonId } = await searchParams;
const user = await requireUser();
const courseSelect = {
id: true,
slug: true,
title: true,
price: true,
modules: {
orderBy: { orderIndex: "asc" as const },
select: {
id: true,
title: true,
lessons: {
orderBy: { orderIndex: "asc" as const },
select: {
id: true,
title: true,
description: true,
videoUrl: true,
youtubeUrl: true,
estimatedDuration: true,
isFreePreview: true,
},
},
},
},
};
const course = (await db.course.findUnique({
where: { slug },
select: courseSelect,
})) as CourseWithModules | null;
if (!course) {
notFound();
}
let enrollment: { id: string } | null = null;
let isEnrolled: boolean;
if (!user?.id) {
// Anonymous: no enrollment, preview-only access
const allLessons = course.modules.flatMap((m) =>
m.lessons.map((l) => ({ id: l.id, isFreePreview: l.isFreePreview }))
);
const firstPreviewLesson = allLessons.find((l) => l.isFreePreview);
if (!firstPreviewLesson) {
redirect(`/courses/${slug}`);
}
isEnrolled = false;
} else {
enrollment = await db.enrollment.findUnique({
where: {
userId_courseId: {
userId: user.id,
courseId: course.id,
},
},
select: { id: true },
});
const isFree = Number(course.price) === 0;
if (!enrollment) {
if (isFree) {
enrollment = await db.enrollment.create({
data: {
userId: user.id,
courseId: course.id,
amountPaid: 0,
},
select: { id: true },
});
isEnrolled = true;
} else {
// Paid course, no enrollment: allow only if there are preview lessons
const allLessons = course.modules.flatMap((m) =>
m.lessons.map((l) => ({ id: l.id, isFreePreview: l.isFreePreview }))
);
const firstPreviewLesson = allLessons.find((l) => l.isFreePreview);
if (!firstPreviewLesson) {
redirect(`/courses/${slug}`);
}
isEnrolled = false;
}
} else {
isEnrolled = true;
}
}
const completedProgress =
isEnrolled && user
? await db.userProgress.findMany({
where: {
userId: user.id,
isCompleted: true,
lesson: {
module: { courseId: course.id },
},
},
select: { lessonId: true },
})
: [];
const modules = course.modules.map((module) => ({
id: module.id,
title: getText(module.title) || "Untitled module",
lessons: module.lessons.map((lesson) => {
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
return {
id: lesson.id,
title: getText(lesson.title) || "Untitled lesson",
description: lessonMeta.text,
contentType: lessonMeta.contentType,
materialUrl: lessonMeta.materialUrl,
videoUrl: lesson.videoUrl,
youtubeUrl: lesson.youtubeUrl,
estimatedDuration: lesson.estimatedDuration,
isFreePreview: lesson.isFreePreview,
};
}),
}));
const flattenedLessons = modules.flatMap((module) => module.lessons);
const flattenedLessonIds = flattenedLessons.map((l) => l.id);
let initialSelectedLessonId: string;
if (isEnrolled) {
initialSelectedLessonId =
requestedLessonId && flattenedLessonIds.includes(requestedLessonId)
? requestedLessonId
: flattenedLessonIds[0] ?? "";
} else {
const firstPreview = flattenedLessons.find((l) => l.isFreePreview);
const requestedLesson = requestedLessonId
? flattenedLessons.find((l) => l.id === requestedLessonId)
: null;
if (requestedLesson?.isFreePreview) {
initialSelectedLessonId = requestedLessonId!;
} else {
initialSelectedLessonId = firstPreview?.id ?? "";
}
}
return (
<StudentClassroomClient
courseSlug={course.slug}
courseTitle={getText(course.title) || "Untitled course"}
modules={modules}
initialSelectedLessonId={initialSelectedLessonId}
initialCompletedLessonIds={completedProgress.map((p) => p.lessonId)}
isEnrolled={isEnrolled}
/>
);
}