your message
This commit is contained in:
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal file
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal 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;
|
||||
}
|
||||
14
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal file
14
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal 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;
|
||||
}
|
||||
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal file
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal 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[];
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user