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,46 @@
import { Controller, Get, Post, Patch, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { CatalogService } from './catalog.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { User } from '@coursecraft/database';
@ApiTags('catalog')
@Controller('catalog')
export class CatalogController {
constructor(private catalogService: CatalogService) {}
@Public()
@Get()
@ApiOperation({ summary: 'Browse published courses (public)' })
async browseCourses(
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('search') search?: string,
@Query('difficulty') difficulty?: string,
): Promise<any> {
return this.catalogService.getPublishedCourses({ page, limit, search, difficulty });
}
@Public()
@Get(':id')
@ApiOperation({ summary: 'Get public course details' })
async getCourse(@Param('id') id: string): Promise<any> {
return this.catalogService.getPublicCourse(id);
}
@Post(':id/submit')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Submit course for review / publish' })
async submitForReview(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.catalogService.publishCourse(id, user.id);
}
@Patch(':id/verify')
@ApiBearerAuth()
@ApiOperation({ summary: 'Toggle author verification badge' })
async toggleVerify(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.catalogService.toggleVerification(id, user.id);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}

View File

@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { CourseStatus } from '@coursecraft/database';
@Injectable()
export class CatalogService {
constructor(private prisma: PrismaService) {}
async getPublishedCourses(options?: {
page?: number;
limit?: number;
category?: string;
difficulty?: string;
search?: string;
}): Promise<any> {
const page = options?.page || 1;
const limit = options?.limit || 20;
const skip = (page - 1) * limit;
const where: any = {
status: CourseStatus.PUBLISHED,
isPublished: true,
};
if (options?.category) where.categoryId = options.category;
if (options?.difficulty) where.difficulty = options.difficulty;
if (options?.search) {
where.OR = [
{ title: { contains: options.search, mode: 'insensitive' } },
{ description: { contains: options.search, mode: 'insensitive' } },
];
}
const [courses, total] = await Promise.all([
this.prisma.course.findMany({
where,
include: {
author: { select: { id: true, name: true, avatarUrl: true } },
_count: { select: { chapters: true, reviews: true, enrollments: true } },
chapters: { include: { _count: { select: { lessons: true } } } },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.course.count({ where }),
]);
const data = courses.map((c) => ({
id: c.id,
title: c.title,
description: c.description,
slug: c.slug,
coverImage: c.coverImage,
price: c.price,
currency: c.currency,
difficulty: c.difficulty,
estimatedHours: c.estimatedHours,
tags: c.tags,
isVerified: c.isVerified,
averageRating: c.averageRating,
enrollmentCount: c._count.enrollments,
reviewCount: c._count.reviews,
chaptersCount: c._count.chapters,
lessonsCount: c.chapters.reduce((acc, ch) => acc + ch._count.lessons, 0),
author: c.author,
publishedAt: c.publishedAt,
}));
return { data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } };
}
async getPublicCourse(courseId: string): Promise<any> {
return this.prisma.course.findFirst({
where: { id: courseId, status: CourseStatus.PUBLISHED, isPublished: true },
include: {
author: { select: { id: true, name: true, avatarUrl: true } },
chapters: {
include: {
lessons: { select: { id: true, title: true, order: true, durationMinutes: true }, orderBy: { order: 'asc' } },
},
orderBy: { order: 'asc' },
},
reviews: {
where: { isApproved: true },
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'desc' },
take: 20,
},
_count: { select: { reviews: true, enrollments: true } },
},
});
}
async submitForReview(courseId: string, userId: string): Promise<any> {
return this.prisma.course.update({
where: { id: courseId, authorId: userId },
data: { status: CourseStatus.PENDING_REVIEW },
});
}
async publishCourse(courseId: string, userId: string): Promise<any> {
return this.prisma.course.update({
where: { id: courseId, authorId: userId },
data: {
status: CourseStatus.PUBLISHED,
isPublished: true,
publishedAt: new Date(),
},
});
}
async toggleVerification(courseId: string, userId: string): Promise<any> {
const course = await this.prisma.course.findFirst({
where: { id: courseId, authorId: userId },
});
if (!course) return null;
return this.prisma.course.update({
where: { id: courseId },
data: { isVerified: !course.isVerified },
});
}
}