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:
84
apps/api/src/enrollment/enrollment.controller.ts
Normal file
84
apps/api/src/enrollment/enrollment.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/enrollment/enrollment.module.ts
Normal file
10
apps/api/src/enrollment/enrollment.module.ts
Normal 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 {}
|
||||
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