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:
root
2026-02-06 10:44:05 +00:00
parent dab726e8d1
commit 2ed65f5678
23 changed files with 1796 additions and 78 deletions

View File

@ -0,0 +1,84 @@
import {
Controller,
Post,
Get,
Param,
Body,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { EnrollmentService } from './enrollment.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
@ApiTags('enrollment')
@Controller('enrollment')
@ApiBearerAuth()
export class EnrollmentController {
constructor(private enrollmentService: EnrollmentService) {}
@Post(':courseId/enroll')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Enroll in a course' })
async enroll(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.enrollmentService.enroll(user.id, courseId);
}
@Get()
@ApiOperation({ summary: 'Get my enrolled courses' })
async myEnrollments(@CurrentUser() user: User): Promise<any> {
return this.enrollmentService.getUserEnrollments(user.id);
}
@Get(':courseId/progress')
@ApiOperation({ summary: 'Get my progress for a course' })
async getProgress(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.enrollmentService.getProgress(user.id, courseId);
}
@Post(':courseId/lessons/:lessonId/complete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Mark a lesson as completed' })
async completeLesson(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.completeLesson(user.id, courseId, lessonId);
}
@Post(':courseId/lessons/:lessonId/quiz')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Submit quiz score' })
async submitQuiz(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@Body('score') score: number,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.saveQuizScore(user.id, courseId, lessonId, score);
}
@Post(':courseId/review')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Leave a review' })
async createReview(
@Param('courseId') courseId: string,
@Body() body: { rating: number; title?: string; content?: string },
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content);
}
@Get(':courseId/reviews')
@ApiOperation({ summary: 'Get course reviews' })
async getReviews(
@Param('courseId') courseId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<any> {
return this.enrollmentService.getCourseReviews(courseId, page, limit);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EnrollmentController } from './enrollment.controller';
import { EnrollmentService } from './enrollment.service';
@Module({
controllers: [EnrollmentController],
providers: [EnrollmentService],
exports: [EnrollmentService],
})
export class EnrollmentModule {}

View 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,
},
});
}
}