your message

This commit is contained in:
root
2026-02-06 14:53:52 +00:00
parent c809d049fe
commit 3d488f22b7
47 changed files with 3127 additions and 425 deletions

View File

@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateReviewDto {
@ApiProperty({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
@IsInt()
@Min(1)
@Max(5)
rating: number;
@ApiPropertyOptional({ description: 'Review title' })
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@ApiPropertyOptional({ description: 'Review content' })
@IsOptional()
@IsString()
@MaxLength(4000)
content?: string;
}

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MaxLength, MinLength } from 'class-validator';
export class SubmitHomeworkDto {
@ApiProperty({
description: 'Written homework answer',
minLength: 50,
maxLength: 20000,
})
@IsString()
@MinLength(50)
@MaxLength(20000)
content: string;
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsInt, Min } from 'class-validator';
export class SubmitQuizDto {
@ApiProperty({
description: 'Selected option index for each quiz question',
type: [Number],
example: [0, 2, 1],
})
@IsArray()
@ArrayMinSize(1)
@IsInt({ each: true })
@Min(0, { each: true })
answers: number[];
}

View File

@ -12,6 +12,9 @@ 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';
import { SubmitQuizDto } from './dto/submit-quiz.dto';
import { CreateReviewDto } from './dto/create-review.dto';
import { SubmitHomeworkDto } from './dto/submit-homework.dto';
@ApiTags('enrollment')
@Controller('enrollment')
@ -55,10 +58,32 @@ export class EnrollmentController {
async submitQuiz(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@Body('score') score: number,
@Body() dto: SubmitQuizDto,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.saveQuizScore(user.id, courseId, lessonId, score);
return this.enrollmentService.submitQuiz(user.id, courseId, lessonId, dto.answers);
}
@Get(':courseId/lessons/:lessonId/homework')
@ApiOperation({ summary: 'Get (or lazy-create) homework for lesson' })
async getHomework(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.getHomework(user.id, courseId, lessonId);
}
@Post(':courseId/lessons/:lessonId/homework')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Submit written homework for lesson' })
async submitHomework(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@Body() dto: SubmitHomeworkDto,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto.content);
}
@Post(':courseId/review')
@ -66,7 +91,7 @@ export class EnrollmentController {
@ApiOperation({ summary: 'Leave a review' })
async createReview(
@Param('courseId') courseId: string,
@Body() body: { rating: number; title?: string; content?: string },
@Body() body: CreateReviewDto,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content);

View File

@ -1,20 +1,47 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { HomeworkReviewStatus } from '@coursecraft/database';
const QUIZ_PASS_THRESHOLD = 70;
@Injectable()
export class EnrollmentService {
constructor(private prisma: PrismaService) {}
async enroll(userId: string, courseId: string): Promise<any> {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { id: true, price: true, title: true, slug: true },
});
if (!course) throw new NotFoundException('Course not found');
if (course.price) {
const purchase = await this.prisma.purchase.findUnique({
where: { userId_courseId: { userId, courseId } },
});
if (!purchase || purchase.status !== 'completed') {
throw new ForbiddenException('Purchase is required for paid course');
}
}
const existing = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
});
if (existing) throw new ConflictException('Already enrolled');
return this.prisma.enrollment.create({
const enrollment = await this.prisma.enrollment.create({
data: { userId, courseId },
include: { course: { select: { id: true, title: true, slug: true } } },
});
await this.addUserToDefaultCourseGroup(courseId, userId);
return enrollment;
}
async getUserEnrollments(userId: string): Promise<any> {
@ -34,10 +61,56 @@ export class EnrollmentService {
}
async completeLesson(userId: string, courseId: string, lessonId: string): Promise<any> {
const enrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
const enrollment = await this.requireEnrollment(userId, courseId);
await this.assertLessonUnlocked(userId, courseId, lessonId);
const progress = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId } },
});
if (!enrollment) throw new NotFoundException('Not enrolled in this course');
if (!progress?.quizPassed || !progress?.homeworkSubmitted) {
throw new BadRequestException('Lesson can be completed only after quiz and homework');
}
const updated = await this.prisma.lessonProgress.update({
where: { userId_lessonId: { userId, lessonId } },
data: { completedAt: new Date() },
});
await this.recalculateProgress(enrollment.id, courseId);
return updated;
}
async submitQuiz(userId: string, courseId: string, lessonId: string, answers: number[]): Promise<any> {
const enrollment = await this.requireEnrollment(userId, courseId);
await this.assertLessonUnlocked(userId, courseId, lessonId);
const quiz = await this.prisma.quiz.findUnique({
where: { lessonId },
});
if (!quiz) {
throw new NotFoundException('Quiz not found for this lesson');
}
const questions = Array.isArray(quiz.questions) ? (quiz.questions as any[]) : [];
if (questions.length === 0) {
throw new BadRequestException('Quiz has no questions');
}
if (!Array.isArray(answers) || answers.length !== questions.length) {
throw new BadRequestException('Answers count must match questions count');
}
const correctAnswers = questions.reduce((acc, question, index) => {
const expected = Number(question.correctAnswer);
const actual = Number(answers[index]);
return acc + (expected === actual ? 1 : 0);
}, 0);
const score = Math.round((correctAnswers / questions.length) * 100);
const passed = score >= QUIZ_PASS_THRESHOLD;
const existing = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId } },
});
const finalPassed = Boolean(existing?.quizPassed || passed);
const completedAt = finalPassed && existing?.homeworkSubmitted ? new Date() : existing?.completedAt;
const progress = await this.prisma.lessonProgress.upsert({
where: { userId_lessonId: { userId, lessonId } },
@ -45,78 +118,219 @@ export class EnrollmentService {
userId,
enrollmentId: enrollment.id,
lessonId,
completedAt: new Date(),
quizScore: score,
quizPassed: finalPassed,
quizPassedAt: finalPassed ? new Date() : null,
completedAt: completedAt || null,
},
update: {
completedAt: new Date(),
quizScore: score,
quizPassed: finalPassed,
quizPassedAt: finalPassed ? existing?.quizPassedAt || new Date() : null,
completedAt: completedAt || null,
},
});
await this.recalculateProgress(enrollment.id, courseId);
return progress;
return {
score,
passed,
passThreshold: QUIZ_PASS_THRESHOLD,
totalQuestions: questions.length,
correctAnswers,
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');
async getHomework(userId: string, courseId: string, lessonId: string): Promise<any> {
await this.requireEnrollment(userId, courseId);
await this.assertLessonUnlocked(userId, courseId, lessonId);
return this.prisma.lessonProgress.upsert({
const lesson = await this.prisma.lesson.findFirst({
where: { id: lessonId, chapter: { courseId } },
select: { id: true, title: true },
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const homework = await this.prisma.homework.upsert({
where: { lessonId: lesson.id },
create: {
lessonId: lesson.id,
title: `Письменное домашнее задание: ${lesson.title}`,
description:
'Опишите, как вы примените изученную тему на практике. Приведите примеры, обоснования и собственные выводы.',
},
update: {},
});
const submission = await this.prisma.homeworkSubmission.findUnique({
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
});
return { homework, submission };
}
async submitHomework(userId: string, courseId: string, lessonId: string, content: string): Promise<any> {
const enrollment = await this.requireEnrollment(userId, courseId);
await this.assertLessonUnlocked(userId, courseId, lessonId);
const lessonProgress = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId } },
});
if (!lessonProgress?.quizPassed) {
throw new BadRequestException('Pass the quiz before submitting homework');
}
const { homework } = await this.getHomework(userId, courseId, lessonId);
const aiResult = this.gradeHomeworkWithAI(content);
const submission = await this.prisma.homeworkSubmission.upsert({
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
create: {
homeworkId: homework.id,
userId,
content,
aiScore: aiResult.score,
aiFeedback: aiResult.feedback,
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
},
update: {
content,
aiScore: aiResult.score,
aiFeedback: aiResult.feedback,
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
submittedAt: new Date(),
},
});
await this.prisma.lessonProgress.upsert({
where: { userId_lessonId: { userId, lessonId } },
create: {
userId,
enrollmentId: enrollment.id,
lessonId,
quizScore: score,
quizScore: lessonProgress.quizScore,
quizPassed: true,
quizPassedAt: lessonProgress.quizPassedAt || new Date(),
homeworkSubmitted: true,
homeworkSubmittedAt: new Date(),
completedAt: new Date(),
},
update: {
homeworkSubmitted: true,
homeworkSubmittedAt: new Date(),
completedAt: new Date(),
},
update: { quizScore: score },
});
await this.recalculateProgress(enrollment.id, courseId);
return submission;
}
async getProgress(userId: string, courseId: string): Promise<any> {
return this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
include: { lessons: true },
include: {
lessons: true,
},
});
}
async createReview(userId: string, courseId: string, rating: number, title?: string, content?: string): Promise<any> {
await this.requireEnrollment(userId, courseId);
const review = await this.prisma.review.upsert({
where: { userId_courseId: { userId, courseId } },
create: { userId, courseId, rating, title, content },
update: { rating, title, content },
create: { userId, courseId, rating, title, content, isApproved: true },
update: { rating, title, content, isApproved: true },
});
// 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 },
});
}
await this.recalculateAverageRating(courseId);
return review;
}
async getCourseReviews(courseId: string, page = 1, limit = 20): Promise<any> {
const skip = (page - 1) * limit;
const safePage = Math.max(1, Number(page) || 1);
const safeLimit = Math.min(100, Math.max(1, Number(limit) || 20));
const skip = (safePage - 1) * safeLimit;
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,
take: safeLimit,
}),
this.prisma.review.count({ where: { courseId, isApproved: true } }),
]);
return { data: reviews, meta: { page, limit, total } };
return { data: reviews, meta: { page: safePage, limit: safeLimit, total } };
}
async recalculateAverageRating(courseId: string): Promise<void> {
const result = await this.prisma.review.aggregate({
where: { courseId, isApproved: true },
_avg: { rating: true },
});
await this.prisma.course.update({
where: { id: courseId },
data: { averageRating: result._avg.rating ?? null },
});
}
private async requireEnrollment(userId: string, courseId: string) {
const enrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
});
if (!enrollment) {
throw new NotFoundException('Not enrolled in this course');
}
return enrollment;
}
private async assertLessonUnlocked(userId: string, courseId: string, lessonId: string): Promise<void> {
const orderedLessons = await this.prisma.lesson.findMany({
where: { chapter: { courseId } },
orderBy: [{ chapter: { order: 'asc' } }, { order: 'asc' }],
select: { id: true },
});
const targetIndex = orderedLessons.findIndex((lesson) => lesson.id === lessonId);
if (targetIndex === -1) {
throw new NotFoundException('Lesson not found in this course');
}
if (targetIndex === 0) return;
const prevLessonId = orderedLessons[targetIndex - 1].id;
const prevProgress = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId: prevLessonId } },
select: { quizPassed: true, homeworkSubmitted: true },
});
if (!prevProgress?.quizPassed || !prevProgress?.homeworkSubmitted) {
throw new ForbiddenException('Complete previous lesson quiz and homework first');
}
}
private async addUserToDefaultCourseGroup(courseId: string, userId: string): Promise<void> {
let group = await this.prisma.courseGroup.findFirst({
where: { courseId, isDefault: true },
});
if (!group) {
group = await this.prisma.courseGroup.create({
data: {
courseId,
name: 'Основная группа',
description: 'Обсуждение курса и вопросы преподавателю',
isDefault: true,
},
});
}
await this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId: group.id, userId } },
create: { groupId: group.id, userId, role: 'student' },
update: {},
});
}
private async recalculateProgress(enrollmentId: string, courseId: string): Promise<void> {
@ -124,7 +338,11 @@ export class EnrollmentService {
where: { chapter: { courseId } },
});
const completedLessons = await this.prisma.lessonProgress.count({
where: { enrollmentId, completedAt: { not: null } },
where: {
enrollmentId,
quizPassed: true,
homeworkSubmitted: true,
},
});
const progress = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
@ -137,4 +355,28 @@ export class EnrollmentService {
},
});
}
private gradeHomeworkWithAI(content: string): { score: number; feedback: string } {
const normalized = content.trim();
const words = normalized.split(/\s+/).filter(Boolean).length;
const hasStructure = /(^|\n)\s*[-*•]|\d+\./.test(normalized);
const hasExamples = /например|пример|кейc|case/i.test(normalized);
let score = 3;
if (words >= 250) score = 4;
if (words >= 450 && hasStructure && hasExamples) score = 5;
if (words < 120) score = 2;
if (words < 60) score = 1;
let feedback = 'Хорошая работа. Раскройте больше практических деталей и аргументов.';
if (score === 5) {
feedback = 'Отличная работа: есть структура, примеры и практическая применимость.';
} else if (score === 4) {
feedback = 'Сильная работа. Добавьте ещё один практический кейс для максимальной оценки.';
} else if (score <= 2) {
feedback = 'Ответ слишком краткий. Раскройте тему глубже и добавьте практические примеры.';
}
return { score, feedback };
}
}