383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
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');
|
||
|
||
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> {
|
||
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.requireEnrollment(userId, courseId);
|
||
await this.assertLessonUnlocked(userId, courseId, lessonId);
|
||
|
||
const progress = await this.prisma.lessonProgress.findUnique({
|
||
where: { userId_lessonId: { userId, lessonId } },
|
||
});
|
||
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 } },
|
||
create: {
|
||
userId,
|
||
enrollmentId: enrollment.id,
|
||
lessonId,
|
||
quizScore: score,
|
||
quizPassed: finalPassed,
|
||
quizPassedAt: finalPassed ? new Date() : null,
|
||
completedAt: completedAt || null,
|
||
},
|
||
update: {
|
||
quizScore: score,
|
||
quizPassed: finalPassed,
|
||
quizPassedAt: finalPassed ? existing?.quizPassedAt || new Date() : null,
|
||
completedAt: completedAt || null,
|
||
},
|
||
});
|
||
|
||
await this.recalculateProgress(enrollment.id, courseId);
|
||
return {
|
||
score,
|
||
passed,
|
||
passThreshold: QUIZ_PASS_THRESHOLD,
|
||
totalQuestions: questions.length,
|
||
correctAnswers,
|
||
progress,
|
||
};
|
||
}
|
||
|
||
async getHomework(userId: string, courseId: string, lessonId: string): Promise<any> {
|
||
await this.requireEnrollment(userId, courseId);
|
||
await this.assertLessonUnlocked(userId, courseId, lessonId);
|
||
|
||
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: 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(),
|
||
},
|
||
});
|
||
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
|
||
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, isApproved: true },
|
||
update: { rating, title, content, isApproved: true },
|
||
});
|
||
|
||
await this.recalculateAverageRating(courseId);
|
||
return review;
|
||
}
|
||
|
||
async getCourseReviews(courseId: string, page = 1, limit = 20): Promise<any> {
|
||
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: safeLimit,
|
||
}),
|
||
this.prisma.review.count({ where: { courseId, isApproved: true } }),
|
||
]);
|
||
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> {
|
||
const totalLessons = await this.prisma.lesson.count({
|
||
where: { chapter: { courseId } },
|
||
});
|
||
const completedLessons = await this.prisma.lessonProgress.count({
|
||
where: {
|
||
enrollmentId,
|
||
quizPassed: true,
|
||
homeworkSubmitted: true,
|
||
},
|
||
});
|
||
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
|
||
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 };
|
||
}
|
||
}
|