import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.service'; import { Lesson } from '@coursecraft/database'; import { CoursesService } from './courses.service'; import { ChaptersService } from './chapters.service'; import { CreateLessonDto } from './dto/create-lesson.dto'; import { UpdateLessonDto } from './dto/update-lesson.dto'; @Injectable() export class LessonsService { constructor( private prisma: PrismaService, private coursesService: CoursesService, private chaptersService: ChaptersService ) {} async create(chapterId: string, userId: string, dto: CreateLessonDto): Promise { const chapter = await this.chaptersService.findById(chapterId); if (!chapter) { throw new NotFoundException('Chapter not found'); } const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId); if (!isOwner) { throw new ForbiddenException('You can only edit your own courses'); } // Get max order const maxOrder = await this.prisma.lesson.aggregate({ where: { chapterId }, _max: { order: true }, }); const order = (maxOrder._max.order ?? -1) + 1; return this.prisma.lesson.create({ data: { chapterId, title: dto.title, content: dto.content as any, order, durationMinutes: dto.durationMinutes, }, }); } async findById(id: string): Promise { return this.prisma.lesson.findUnique({ where: { id }, include: { chapter: { select: { id: true, title: true, courseId: true, }, }, }, }); } async update(lessonId: string, userId: string, dto: UpdateLessonDto): Promise { const lesson = await this.prisma.lesson.findUnique({ where: { id: lessonId }, include: { chapter: { select: { courseId: true }, }, }, }); if (!lesson) { throw new NotFoundException('Lesson not found'); } const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId); if (!isOwner) { throw new ForbiddenException('You can only edit your own courses'); } return this.prisma.lesson.update({ where: { id: lessonId }, data: { title: dto.title, content: dto.content as any, durationMinutes: dto.durationMinutes, videoUrl: dto.videoUrl, }, }); } async delete(lessonId: string, userId: string): Promise { const lesson = await this.prisma.lesson.findUnique({ where: { id: lessonId }, include: { chapter: { select: { courseId: true }, }, }, }); if (!lesson) { throw new NotFoundException('Lesson not found'); } const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId); if (!isOwner) { throw new ForbiddenException('You can only edit your own courses'); } await this.prisma.lesson.delete({ where: { id: lessonId }, }); } async reorder(chapterId: string, userId: string, lessonIds: string[]): Promise { const chapter = await this.chaptersService.findById(chapterId); if (!chapter) { throw new NotFoundException('Chapter not found'); } const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId); if (!isOwner) { throw new ForbiddenException('You can only edit your own courses'); } await Promise.all( lessonIds.map((id, index) => this.prisma.lesson.update({ where: { id }, data: { order: index }, }) ) ); return this.prisma.lesson.findMany({ where: { chapterId }, orderBy: { order: 'asc' }, }); } async generateQuiz(lessonId: string): Promise { const lesson = await this.prisma.lesson.findUnique({ where: { id: lessonId }, include: { quiz: true }, }); if (!lesson) throw new NotFoundException('Lesson not found'); // Return cached quiz if exists if (lesson.quiz) { return { questions: lesson.quiz.questions }; } // Generate quiz using AI const questions = await this.generateQuizWithAI(lesson.content, lesson.title); // Save to database await this.prisma.quiz.create({ data: { lessonId, questions: questions as any }, }); return { questions }; } private async generateQuizWithAI(content: any, lessonTitle: string): Promise { try { // Extract text from TipTap JSON content const textContent = this.extractTextFromContent(content); if (!textContent || textContent.length < 50) { // Not enough content, return simple quiz return this.getDefaultQuiz(); } // Call OpenRouter to generate quiz const apiKey = process.env.OPENROUTER_API_KEY; const baseUrl = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1'; const model = process.env.AI_MODEL_FREE || 'openai/gpt-4o-mini'; if (!apiKey) { return this.getDefaultQuiz(); } const prompt = `На основе следующего содержания урока "${lessonTitle}" сгенерируй 3-5 вопросов с вариантами ответов для теста. Содержание урока: ${textContent.slice(0, 3000)} Верни JSON массив вопросов в формате: [ { "id": "1", "question": "Вопрос?", "options": ["Вариант А", "Вариант Б", "Вариант В", "Вариант Г"], "correctAnswer": 0 } ] Только JSON, без markdown.`; const response = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.7, }), }); if (!response.ok) { return this.getDefaultQuiz(); } const data: any = await response.json(); const aiResponse = data.choices?.[0]?.message?.content || ''; // Parse JSON from response const jsonMatch = aiResponse.match(/\[[\s\S]*\]/); if (jsonMatch) { const questions = JSON.parse(jsonMatch[0]); return questions; } return this.getDefaultQuiz(); } catch { return this.getDefaultQuiz(); } } private extractTextFromContent(content: any): string { if (!content || typeof content !== 'object') return ''; let text = ''; const traverse = (node: any) => { if (node.type === 'text' && node.text) { text += node.text + ' '; } if (node.content && Array.isArray(node.content)) { node.content.forEach(traverse); } }; traverse(content); return text.trim(); } private getDefaultQuiz(): any[] { return [ { id: '1', question: 'Вы изучили материал урока?', options: ['Да, всё понятно', 'Частично', 'Нужно повторить', 'Нет'], correctAnswer: 0, }, { id: '2', question: 'Какие основные темы были рассмотрены?', options: ['Теория и практика', 'Только теория', 'Только примеры', 'Не помню'], correctAnswer: 0, }, { id: '3', question: 'Готовы ли вы применить полученные знания?', options: ['Да, готов', 'Нужна практика', 'Нужно повторить', 'Нет'], correctAnswer: 0, }, ]; } }