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:
@ -5,6 +5,8 @@ import { join } from 'path';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { CoursesModule } from './courses/courses.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { EnrollmentModule } from './enrollment/enrollment.module';
|
||||
import { GenerationModule } from './generation/generation.module';
|
||||
import { PaymentsModule } from './payments/payments.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
@ -38,6 +40,8 @@ import { PrismaModule } from './common/prisma/prisma.module';
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
CoursesModule,
|
||||
CatalogModule,
|
||||
EnrollmentModule,
|
||||
GenerationModule,
|
||||
PaymentsModule,
|
||||
SearchModule,
|
||||
|
||||
46
apps/api/src/catalog/catalog.controller.ts
Normal file
46
apps/api/src/catalog/catalog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/catalog/catalog.module.ts
Normal file
10
apps/api/src/catalog/catalog.module.ts
Normal 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 {}
|
||||
122
apps/api/src/catalog/catalog.service.ts
Normal file
122
apps/api/src/catalog/catalog.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -64,4 +64,10 @@ export class LessonsController {
|
||||
): Promise<any> {
|
||||
return this.lessonsService.reorder(chapterId, user.id, lessonIds);
|
||||
}
|
||||
|
||||
@Get('lessons/:lessonId/quiz')
|
||||
@ApiOperation({ summary: 'Generate quiz for a lesson' })
|
||||
async generateQuiz(@Param('lessonId') lessonId: string): Promise<any> {
|
||||
return this.lessonsService.generateQuiz(lessonId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,4 +138,36 @@ export class LessonsService {
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async generateQuiz(lessonId: string): Promise<any> {
|
||||
const lesson = await this.prisma.lesson.findUnique({ where: { id: lessonId } });
|
||||
if (!lesson) throw new NotFoundException('Lesson not found');
|
||||
|
||||
// Mock quiz - in production, parse lesson.content and call AI to generate questions
|
||||
return {
|
||||
questions: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'multiple_choice',
|
||||
question: 'Какой из следующих вариантов верен?',
|
||||
options: ['Вариант A', 'Вариант B', 'Вариант C', 'Вариант D'],
|
||||
correctAnswer: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'multiple_choice',
|
||||
question: 'Выберите правильный ответ:',
|
||||
options: ['Ответ 1', 'Ответ 2', 'Ответ 3'],
|
||||
correctAnswer: 1,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'multiple_choice',
|
||||
question: 'Что из перечисленного правильно?',
|
||||
options: ['Первое', 'Второе', 'Третье', 'Четвёртое'],
|
||||
correctAnswer: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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