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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }; } }