feat: add course catalog, enrollment, progress tracking, quizzes, and reviews
Backend changes: - Add Enrollment and LessonProgress models to track user progress - Add UserRole enum (USER, MODERATOR, ADMIN) - Add course verification and moderation fields - New CatalogModule: public course browsing, publishing, verification - New EnrollmentModule: enroll, progress tracking, quiz submission, reviews - Add quiz generation endpoint to LessonsController Frontend changes: - Redesign course viewer: proper course UI with lesson navigation, progress bar - Add beautiful typography styles for course content (prose-course) - Fix first-login bug with token exchange retry logic - New pages: /catalog (public courses), /catalog/[id] (course details), /learning (enrollments) - Add LessonQuiz component with scoring and results - Update sidebar navigation: add Catalog and My Learning links - Add publish/verify buttons in course editor - Integrate enrollment progress tracking with backend All courses now support: sequential progression, quiz tests, reviews, ratings, author verification badges, and full marketplace publishing workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
140
apps/api/src/enrollment/enrollment.service.ts
Normal file
140
apps/api/src/enrollment/enrollment.service.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async enroll(userId: string, courseId: string): Promise<any> {
|
||||
const existing = await this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
if (existing) throw new ConflictException('Already enrolled');
|
||||
|
||||
return this.prisma.enrollment.create({
|
||||
data: { userId, courseId },
|
||||
include: { course: { select: { id: true, title: true, slug: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEnrollments(userId: string): Promise<any> {
|
||||
return this.prisma.enrollment.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
course: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, avatarUrl: true } },
|
||||
_count: { select: { chapters: true } },
|
||||
chapters: { include: { _count: { select: { lessons: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async completeLesson(userId: string, courseId: string, lessonId: string): Promise<any> {
|
||||
const enrollment = await this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
if (!enrollment) throw new NotFoundException('Not enrolled in this course');
|
||||
|
||||
const progress = await this.prisma.lessonProgress.upsert({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
create: {
|
||||
userId,
|
||||
enrollmentId: enrollment.id,
|
||||
lessonId,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.recalculateProgress(enrollment.id, courseId);
|
||||
return progress;
|
||||
}
|
||||
|
||||
async saveQuizScore(userId: string, courseId: string, lessonId: string, score: number): Promise<any> {
|
||||
const enrollment = await this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
if (!enrollment) throw new NotFoundException('Not enrolled in this course');
|
||||
|
||||
return this.prisma.lessonProgress.upsert({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
create: {
|
||||
userId,
|
||||
enrollmentId: enrollment.id,
|
||||
lessonId,
|
||||
quizScore: score,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
update: { quizScore: score },
|
||||
});
|
||||
}
|
||||
|
||||
async getProgress(userId: string, courseId: string): Promise<any> {
|
||||
return this.prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
include: { lessons: true },
|
||||
});
|
||||
}
|
||||
|
||||
async createReview(userId: string, courseId: string, rating: number, title?: string, content?: string): Promise<any> {
|
||||
const review = await this.prisma.review.upsert({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
create: { userId, courseId, rating, title, content },
|
||||
update: { rating, title, content },
|
||||
});
|
||||
|
||||
// Recalculate avg rating
|
||||
const result = await this.prisma.review.aggregate({
|
||||
where: { courseId, isApproved: true },
|
||||
_avg: { rating: true },
|
||||
});
|
||||
if (result._avg.rating !== null) {
|
||||
await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: { averageRating: result._avg.rating },
|
||||
});
|
||||
}
|
||||
|
||||
return review;
|
||||
}
|
||||
|
||||
async getCourseReviews(courseId: string, page = 1, limit = 20): Promise<any> {
|
||||
const skip = (page - 1) * limit;
|
||||
const [reviews, total] = await Promise.all([
|
||||
this.prisma.review.findMany({
|
||||
where: { courseId, isApproved: true },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.review.count({ where: { courseId, isApproved: true } }),
|
||||
]);
|
||||
return { data: reviews, meta: { page, limit, total } };
|
||||
}
|
||||
|
||||
private async recalculateProgress(enrollmentId: string, courseId: string): Promise<void> {
|
||||
const totalLessons = await this.prisma.lesson.count({
|
||||
where: { chapter: { courseId } },
|
||||
});
|
||||
const completedLessons = await this.prisma.lessonProgress.count({
|
||||
where: { enrollmentId, completedAt: { not: null } },
|
||||
});
|
||||
|
||||
const progress = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
|
||||
|
||||
await this.prisma.enrollment.update({
|
||||
where: { id: enrollmentId },
|
||||
data: {
|
||||
progress,
|
||||
completedAt: progress >= 100 ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user