diff --git a/.env.example b/.env.example index 36ca855..ca6c693 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,7 @@ AI_MODEL_DEFAULT="openai/gpt-4o-mini" STRIPE_SECRET_KEY="sk_test_..." STRIPE_WEBHOOK_SECRET="whsec_..." NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." +PAYMENT_MODE="PROD" # DEV | PROD # Stripe Price IDs STRIPE_PRICE_PREMIUM="price_..." @@ -53,6 +54,15 @@ S3_SECRET_ACCESS_KEY="your-secret-key" S3_BUCKET_NAME="coursecraft" S3_REGION="auto" +# Cooperation form email (optional; if not set requests are still saved in DB) +COOPERATION_EMAIL_TO="exbytestudios@gmail.com" +COOPERATION_EMAIL_FROM="noreply@coursecraft.local" +SMTP_HOST="" +SMTP_PORT="587" +SMTP_USER="" +SMTP_PASS="" +SMTP_SECURE="false" + # App URLs (API на 3125; веб — свой порт, напр. 3000) NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_API_URL="http://localhost:3125" diff --git a/apps/ai-service/src/providers/openrouter.provider.ts b/apps/ai-service/src/providers/openrouter.provider.ts index 2ccec7a..61b7a26 100644 --- a/apps/ai-service/src/providers/openrouter.provider.ts +++ b/apps/ai-service/src/providers/openrouter.provider.ts @@ -137,53 +137,50 @@ export class OpenRouterProvider { model: string ): Promise { log.request('generateClarifyingQuestions', model); - log.info(`User prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`); - - const systemPrompt = `Ты - эксперт по созданию образовательных курсов. -Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы, -чтобы лучше понять его потребности и создать максимально релевантный курс. + log.info(`Using structured onboarding quiz for prompt: "${prompt.substring(0, 120)}${prompt.length > 120 ? '...' : ''}"`); -Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса (важно для длины): -- Короткий (3-4 главы, по 2-4 урока — только введение в тему) -- Средний (5-7 глав, по 4-6 уроков — полноценное покрытие) -- Длинный / полный (7-12 глав, по 5-8 уроков — глубокое погружение, рекомендуемый вариант для серьёзного обучения) + const structured = { + questions: [ + { + id: 'q_audience', + question: 'Для кого курс?', + type: 'single_choice', + options: ['Новички', 'Middle', 'Продвинутые'], + required: true, + }, + { + id: 'q_format', + question: 'Формат курса?', + type: 'single_choice', + options: ['Теория', 'Практика', 'Смешанный'], + required: true, + }, + { + id: 'q_goal', + question: 'Основная цель курса?', + type: 'single_choice', + options: ['Освоить профессию', 'Подготовиться к экзамену', 'Для себя'], + required: true, + }, + { + id: 'q_volume', + question: 'Какой объём курса нужен?', + type: 'single_choice', + options: ['Короткий (3-4 главы)', 'Средний (5-7 глав)', 'Полный (7-12 глав)'], + required: true, + }, + { + id: 'q_notes', + question: 'Есть ли дополнительные пожелания по структуре, заданиям и кейсам?', + type: 'text', + required: false, + }, + ], + }; -Остальные вопросы: целевая аудитория, глубина материала, специфические темы. - -Ответь в формате JSON.`; - - return this.withRetry(async () => { - const response = await this.client.chat.completions.create({ - model, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: `Запрос пользователя: "${prompt}"` }, - ], - response_format: { type: 'json_object' }, - temperature: 0.7, - }); - - log.response('generateClarifyingQuestions', { - prompt: response.usage?.prompt_tokens, - completion: response.usage?.completion_tokens, - }); - - const content = response.choices[0].message.content; - log.debug('Raw AI response:', content); - - if (!content) { - log.error('Empty response from AI'); - throw new Error('Empty response from AI'); - } - - const parsed = JSON.parse(content); - const validated = ClarifyingQuestionsSchema.parse(parsed); - - log.success(`Generated ${validated.questions.length} clarifying questions`); - log.info('Questions:', validated.questions.map(q => q.question)); - - return validated; - }, 'generateClarifyingQuestions'); + const validated = ClarifyingQuestionsSchema.parse(structured); + log.success(`Generated ${validated.questions.length} structured onboarding questions`); + return validated; } async generateCourseOutline( @@ -225,9 +222,22 @@ export class OpenRouterProvider { "tags": ["тег1", "тег2"] }`; + const audience = String(answers.q_audience || '').trim(); + const format = String(answers.q_format || '').trim(); + const goal = String(answers.q_goal || '').trim(); + const volume = String(answers.q_volume || '').trim(); + const notes = String(answers.q_notes || '').trim(); + const userMessage = `Запрос: "${prompt}" -Ответы пользователя на уточняющие вопросы: +Структурированные ответы: +- Аудитория: ${audience || 'не указано'} +- Формат: ${format || 'не указано'} +- Цель: ${goal || 'не указано'} +- Объём: ${volume || 'не указано'} +- Доп. пожелания: ${notes || 'нет'} + +Сырой набор ответов: ${Object.entries(answers) .map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`) .join('\n')}`; diff --git a/apps/api/package.json b/apps/api/package.json index a528e57..dad3df2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -34,8 +34,10 @@ "helmet": "^7.1.0", "ioredis": "^5.3.0", "meilisearch": "^0.37.0", + "nodemailer": "^6.10.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdf-parse": "^1.1.1", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "socket.io": "^4.8.1", @@ -48,7 +50,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^20.11.0", + "@types/nodemailer": "^6.4.17", "@types/passport-jwt": "^4.0.1", + "@types/pdf-parse": "^1.1.5", "jest": "^29.7.0", "source-map-support": "^0.5.21", "ts-jest": "^29.1.2", diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts new file mode 100644 index 0000000..7a6f881 --- /dev/null +++ b/apps/api/src/admin/admin.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { PaymentMode, PaymentProvider, User, UserRole } from '@coursecraft/database'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AdminService } from './admin.service'; + +@ApiTags('admin') +@Controller('admin') +@ApiBearerAuth() +export class AdminController { + constructor(private adminService: AdminService) {} + + @Get('users') + async getUsers( + @CurrentUser() user: User, + @Query('search') search?: string, + @Query('role') role?: UserRole, + @Query('limit') limit?: number, + ) { + return this.adminService.getUsers(user.id, { search, role, limit }); + } + + @Patch('users/:id/role') + async updateUserRole( + @CurrentUser() user: User, + @Param('id') targetUserId: string, + @Body('role') role: UserRole, + ) { + return this.adminService.updateUserRole(user.id, targetUserId, role); + } + + @Get('payments') + async getPayments( + @CurrentUser() user: User, + @Query('mode') mode?: PaymentMode, + @Query('provider') provider?: PaymentProvider, + @Query('status') status?: string, + @Query('search') search?: string, + @Query('limit') limit?: number, + ): Promise { + return this.adminService.getPayments(user.id, { mode, provider, status, search, limit }); + } +} diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts new file mode 100644 index 0000000..9af2986 --- /dev/null +++ b/apps/api/src/admin/admin.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AccessModule } from '../common/access/access.module'; +import { UsersModule } from '../users/users.module'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +@Module({ + imports: [AccessModule, UsersModule], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} + diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts new file mode 100644 index 0000000..dd1de9f --- /dev/null +++ b/apps/api/src/admin/admin.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { PaymentMode, PaymentProvider, UserRole } from '@coursecraft/database'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { AccessService } from '../common/access/access.service'; +import { UsersService } from '../users/users.service'; + +@Injectable() +export class AdminService { + constructor( + private prisma: PrismaService, + private access: AccessService, + private usersService: UsersService, + ) {} + + async getUsers(adminUserId: string, options?: { search?: string; role?: UserRole; limit?: number }) { + await this.access.assertAdmin(adminUserId); + return this.usersService.listUsers(options); + } + + async updateUserRole(adminUserId: string, targetUserId: string, role: UserRole) { + await this.access.assertAdmin(adminUserId); + return this.usersService.updateRole(targetUserId, role); + } + + async getPayments( + adminUserId: string, + filters?: { + mode?: PaymentMode; + provider?: PaymentProvider; + status?: string; + search?: string; + limit?: number; + } + ): Promise { + await this.access.assertAdmin(adminUserId); + const limit = Math.min(300, Math.max(1, filters?.limit || 150)); + const where: any = {}; + if (filters?.mode) where.mode = filters.mode; + if (filters?.provider) where.provider = filters.provider; + if (filters?.status) where.status = filters.status; + if (filters?.search?.trim()) { + const term = filters.search.trim(); + where.OR = [ + { user: { email: { contains: term, mode: 'insensitive' } } }, + { user: { name: { contains: term, mode: 'insensitive' } } }, + { course: { title: { contains: term, mode: 'insensitive' } } }, + ]; + } + + return this.prisma.purchase.findMany({ + where, + include: { + user: { + select: { id: true, email: true, name: true, avatarUrl: true }, + }, + course: { + select: { id: true, title: true, slug: true, authorId: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d4537e2..9af824a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,6 +15,8 @@ import { GenerationModule } from './generation/generation.module'; import { PaymentsModule } from './payments/payments.module'; import { SearchModule } from './search/search.module'; import { PrismaModule } from './common/prisma/prisma.module'; +import { AdminModule } from './admin/admin.module'; +import { CooperationModule } from './cooperation/cooperation.module'; @Module({ imports: [ @@ -53,6 +55,8 @@ import { PrismaModule } from './common/prisma/prisma.module'; GenerationModule, PaymentsModule, SearchModule, + AdminModule, + CooperationModule, ], }) export class AppModule {} diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts index 953b3dd..7a167ba 100644 --- a/apps/api/src/catalog/catalog.service.ts +++ b/apps/api/src/catalog/catalog.service.ts @@ -104,13 +104,26 @@ export class CatalogService { throw new ForbiddenException('Only course author can submit for moderation'); } - return this.prisma.course.update({ + const fromStatus = course.status; + const updated = await this.prisma.course.update({ where: { id: courseId }, data: { - status: CourseStatus.PENDING_REVIEW, + status: CourseStatus.PENDING_MODERATION, isPublished: false, }, }); + + await this.prisma.courseStatusHistory.create({ + data: { + courseId, + fromStatus, + toStatus: CourseStatus.PENDING_MODERATION, + changedById: userId, + note: 'Submitted for moderation', + }, + }); + + return updated; } async createCourseCheckout(courseId: string, userId: string): Promise { diff --git a/apps/api/src/common/access/access.module.ts b/apps/api/src/common/access/access.module.ts new file mode 100644 index 0000000..1193f6a --- /dev/null +++ b/apps/api/src/common/access/access.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AccessService } from './access.service'; + +@Module({ + imports: [PrismaModule], + providers: [AccessService], + exports: [AccessService], +}) +export class AccessModule {} + diff --git a/apps/api/src/common/access/access.service.ts b/apps/api/src/common/access/access.service.ts new file mode 100644 index 0000000..23e97bd --- /dev/null +++ b/apps/api/src/common/access/access.service.ts @@ -0,0 +1,61 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { UserRole } from '@coursecraft/database'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AccessService { + constructor(private prisma: PrismaService) {} + + async getUserRole(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { role: true }, + }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user.role; + } + + async assertStaff(userId: string): Promise { + const role = await this.getUserRole(userId); + if (role !== UserRole.MODERATOR && role !== UserRole.ADMIN) { + throw new ForbiddenException('Staff access only'); + } + } + + async assertAdmin(userId: string): Promise { + const role = await this.getUserRole(userId); + if (role !== UserRole.ADMIN) { + throw new ForbiddenException('Admin access only'); + } + } + + async assertCourseOwner(courseId: string, userId: string): Promise { + const course = await this.prisma.course.findUnique({ + where: { id: courseId }, + select: { id: true, authorId: true }, + }); + if (!course) { + throw new NotFoundException('Course not found'); + } + if (course.authorId !== userId) { + throw new ForbiddenException('Only course author can perform this action'); + } + } + + async assertCourseOwnerOrStaff(courseId: string, userId: string): Promise { + const course = await this.prisma.course.findUnique({ + where: { id: courseId }, + select: { id: true, authorId: true }, + }); + if (!course) { + throw new NotFoundException('Course not found'); + } + if (course.authorId === userId) { + return; + } + await this.assertStaff(userId); + } +} + diff --git a/apps/api/src/common/course-status.ts b/apps/api/src/common/course-status.ts new file mode 100644 index 0000000..2387b5f --- /dev/null +++ b/apps/api/src/common/course-status.ts @@ -0,0 +1,11 @@ +import { CourseStatus } from '@coursecraft/database'; + +export const COURSE_PENDING_STATUSES: CourseStatus[] = [ + CourseStatus.PENDING_MODERATION, + CourseStatus.PENDING_REVIEW, // backward compatibility +]; + +export function isPendingModeration(status: CourseStatus): boolean { + return COURSE_PENDING_STATUSES.includes(status); +} + diff --git a/apps/api/src/cooperation/cooperation.controller.ts b/apps/api/src/cooperation/cooperation.controller.ts new file mode 100644 index 0000000..9f37fa4 --- /dev/null +++ b/apps/api/src/cooperation/cooperation.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Public } from '../auth/decorators/public.decorator'; +import { CooperationService } from './cooperation.service'; +import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto'; + +@ApiTags('cooperation') +@Controller('cooperation') +export class CooperationController { + constructor(private cooperationService: CooperationService) {} + + @Public() + @Post('requests') + @ApiOperation({ summary: 'Create cooperation request from landing page' }) + async createRequest(@Body() dto: CreateCooperationRequestDto) { + return this.cooperationService.createRequest(dto); + } +} + diff --git a/apps/api/src/cooperation/cooperation.module.ts b/apps/api/src/cooperation/cooperation.module.ts new file mode 100644 index 0000000..fcfcfc6 --- /dev/null +++ b/apps/api/src/cooperation/cooperation.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CooperationController } from './cooperation.controller'; +import { CooperationService } from './cooperation.service'; + +@Module({ + controllers: [CooperationController], + providers: [CooperationService], +}) +export class CooperationModule {} + diff --git a/apps/api/src/cooperation/cooperation.service.ts b/apps/api/src/cooperation/cooperation.service.ts new file mode 100644 index 0000000..4b992fe --- /dev/null +++ b/apps/api/src/cooperation/cooperation.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto'; + +@Injectable() +export class CooperationService { + constructor( + private prisma: PrismaService, + private config: ConfigService, + ) {} + + async createRequest(dto: CreateCooperationRequestDto) { + const created = await this.prisma.cooperationRequest.create({ + data: { + organization: dto.organization, + contactName: dto.contactName, + email: dto.email, + phone: dto.phone, + role: dto.role, + organizationType: dto.organizationType, + message: dto.message, + source: 'landing', + status: 'new', + }, + }); + + const emailError = await this.trySendEmail(created).catch((error) => error as Error); + if (emailError) { + await this.prisma.cooperationRequest.update({ + where: { id: created.id }, + data: { + status: 'stored_email_failed', + }, + }); + } + + return { + id: created.id, + status: emailError ? 'stored_email_failed' : 'stored_and_sent', + }; + } + + private async trySendEmail(request: { + id: string; + organization: string; + contactName: string; + email: string; + phone: string | null; + role: string | null; + organizationType: string | null; + message: string; + createdAt: Date; + }) { + const host = this.config.get('SMTP_HOST'); + const portRaw = this.config.get('SMTP_PORT'); + const user = this.config.get('SMTP_USER'); + const pass = this.config.get('SMTP_PASS'); + const secureRaw = this.config.get('SMTP_SECURE'); + const to = this.config.get('COOPERATION_EMAIL_TO') || 'exbytestudios@gmail.com'; + const from = this.config.get('COOPERATION_EMAIL_FROM') || user; + + if (!host || !portRaw || !user || !pass || !from) { + throw new Error('SMTP is not configured'); + } + + const nodemailer = await import('nodemailer'); + const transporter = nodemailer.createTransport({ + host, + port: Number(portRaw), + secure: String(secureRaw || '').toLowerCase() === 'true', + auth: { user, pass }, + }); + + const text = [ + 'Новая заявка на сотрудничество (CourseCraft)', + `ID: ${request.id}`, + `Организация: ${request.organization}`, + `Контакт: ${request.contactName}`, + `Email: ${request.email}`, + `Телефон: ${request.phone || '—'}`, + `Роль: ${request.role || '—'}`, + `Тип организации: ${request.organizationType || '—'}`, + `Создано: ${request.createdAt.toISOString()}`, + '', + 'Сообщение:', + request.message, + ].join('\n'); + + await transporter.sendMail({ + from, + to, + subject: `[CourseCraft] Cooperation request: ${request.organization}`, + text, + }); + } +} + diff --git a/apps/api/src/cooperation/dto/create-cooperation-request.dto.ts b/apps/api/src/cooperation/dto/create-cooperation-request.dto.ts new file mode 100644 index 0000000..c3d260a --- /dev/null +++ b/apps/api/src/cooperation/dto/create-cooperation-request.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateCooperationRequestDto { + @ApiProperty({ example: 'Tech College #17' }) + @IsString() + @MinLength(2) + @MaxLength(160) + organization: string; + + @ApiProperty({ example: 'Иван Петров' }) + @IsString() + @MinLength(2) + @MaxLength(120) + contactName: string; + + @ApiProperty({ example: 'partner@example.edu' }) + @IsEmail() + email: string; + + @ApiPropertyOptional({ example: '+7 900 000-00-00' }) + @IsOptional() + @IsString() + @MaxLength(80) + phone?: string; + + @ApiPropertyOptional({ example: 'Руководитель цифрового обучения' }) + @IsOptional() + @IsString() + @MaxLength(120) + role?: string; + + @ApiPropertyOptional({ example: 'college' }) + @IsOptional() + @IsString() + @MaxLength(80) + organizationType?: string; + + @ApiProperty({ example: 'Хотим пилот на 300 студентов и интеграцию с LMS.' }) + @IsString() + @MinLength(10) + @MaxLength(5000) + message: string; +} + diff --git a/apps/api/src/courses/course-sources.controller.ts b/apps/api/src/courses/course-sources.controller.ts new file mode 100644 index 0000000..b14b5c0 --- /dev/null +++ b/apps/api/src/courses/course-sources.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; +import { CourseSourcesService } from './course-sources.service'; + +@ApiTags('course-sources') +@Controller('courses/:courseId/sources') +@ApiBearerAuth() +export class CourseSourcesController { + constructor(private sourcesService: CourseSourcesService) {} + + @Post('upload') + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 25 * 1024 * 1024 }, + }), + ) + async upload( + @Param('courseId') courseId: string, + @UploadedFile() file: any, + @CurrentUser() user: User, + ): Promise { + return this.sourcesService.uploadSource(courseId, user.id, file); + } + + @Get() + async list(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + return this.sourcesService.getSources(courseId, user.id); + } + + @Get('outline-hints') + async getOutlineHints(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + return this.sourcesService.buildOutlineHints(courseId, user.id); + } +} diff --git a/apps/api/src/courses/course-sources.service.ts b/apps/api/src/courses/course-sources.service.ts new file mode 100644 index 0000000..40091d9 --- /dev/null +++ b/apps/api/src/courses/course-sources.service.ts @@ -0,0 +1,158 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CourseSourceParseStatus, CourseSourceType } from '@coursecraft/database'; +import { basename, extname, join } from 'path'; +import { promises as fs } from 'fs'; +import pdfParse from 'pdf-parse'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { AccessService } from '../common/access/access.service'; + +@Injectable() +export class CourseSourcesService { + constructor( + private prisma: PrismaService, + private access: AccessService, + ) {} + + async uploadSource(courseId: string, userId: string, file: any): Promise { + if (!file) { + throw new NotFoundException('File is required'); + } + + await this.access.assertCourseOwner(courseId, userId); + + const sourceType = this.resolveSourceType(file.originalname, file.mimetype); + const storageDir = join('/tmp', 'coursecraft_uploads', courseId); + await fs.mkdir(storageDir, { recursive: true }); + const timestamp = Date.now(); + const safeName = `${timestamp}-${basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_')}`; + const storagePath = join(storageDir, safeName); + + await fs.writeFile(storagePath, file.buffer); + + let parseStatus: CourseSourceParseStatus = CourseSourceParseStatus.SKIPPED; + let extractedText: string | null = null; + let extractedMeta: any = null; + + try { + if (sourceType === CourseSourceType.TXT) { + extractedText = file.buffer.toString('utf8'); + parseStatus = CourseSourceParseStatus.PARSED; + extractedMeta = { method: 'utf8', chars: extractedText?.length || 0 }; + } else if (sourceType === CourseSourceType.PDF) { + const parsed = await pdfParse(file.buffer); + extractedText = (parsed.text || '').trim(); + parseStatus = CourseSourceParseStatus.PARSED; + extractedMeta = { + method: 'pdf-parse', + pages: parsed.numpages || null, + chars: extractedText?.length || 0, + }; + } + } catch (error: any) { + parseStatus = CourseSourceParseStatus.FAILED; + extractedMeta = { + method: sourceType === CourseSourceType.PDF ? 'pdf-parse' : 'unknown', + error: error?.message || 'Parse failed', + }; + } + + const created = await this.prisma.courseSourceFile.create({ + data: { + courseId, + uploadedById: userId, + fileName: file.originalname, + mimeType: file.mimetype || 'application/octet-stream', + fileSize: file.size || file.buffer.length, + sourceType, + storagePath, + parseStatus, + extractedText, + extractedMeta, + }, + select: { + id: true, + fileName: true, + mimeType: true, + fileSize: true, + sourceType: true, + parseStatus: true, + extractedMeta: true, + createdAt: true, + }, + }); + + return { + ...created, + extractedPreview: + parseStatus === CourseSourceParseStatus.PARSED && extractedText + ? extractedText.slice(0, 500) + : null, + }; + } + + async getSources(courseId: string, userId: string): Promise { + await this.access.assertCourseOwnerOrStaff(courseId, userId); + return this.prisma.courseSourceFile.findMany({ + where: { courseId }, + select: { + id: true, + fileName: true, + mimeType: true, + fileSize: true, + sourceType: true, + parseStatus: true, + extractedMeta: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async buildOutlineHints(courseId: string, userId: string): Promise { + await this.access.assertCourseOwnerOrStaff(courseId, userId); + + const files = await this.prisma.courseSourceFile.findMany({ + where: { courseId, parseStatus: CourseSourceParseStatus.PARSED }, + select: { id: true, fileName: true, sourceType: true, extractedText: true }, + orderBy: { createdAt: 'asc' }, + take: 10, + }); + + const mergedText = files.map((f) => f.extractedText || '').join('\n\n').trim(); + if (!mergedText) { + return { filesCount: files.length, hints: [], summary: null }; + } + + const lines = mergedText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 8); + const headingCandidates = lines + .filter((line) => /^(\\d+[.)]\\s+|[A-ZА-ЯЁ][^.!?]{8,80}$)/.test(line)) + .slice(0, 24); + + const hints = headingCandidates.slice(0, 8).map((line, idx) => ({ + id: `hint_${idx + 1}`, + title: line.replace(/^\\d+[.)]\\s+/, ''), + })); + + return { + filesCount: files.length, + summary: `Найдено ${files.length} источников с текстом. Сформированы рекомендации по структуре.`, + hints, + }; + } + + private resolveSourceType(fileName: string, mimeType: string): CourseSourceType { + const ext = extname(fileName).toLowerCase(); + if (mimeType.startsWith('image/') || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'].includes(ext)) { + return CourseSourceType.IMAGE; + } + if (ext === '.pdf' || mimeType.includes('pdf')) return CourseSourceType.PDF; + if (ext === '.docx') return CourseSourceType.DOCX; + if (ext === '.txt' || mimeType.includes('text/plain')) return CourseSourceType.TXT; + if (ext === '.pptx') return CourseSourceType.PPTX; + if (ext === '.zip' || mimeType.includes('zip')) return CourseSourceType.ZIP; + return CourseSourceType.OTHER; + } +} diff --git a/apps/api/src/courses/courses.module.ts b/apps/api/src/courses/courses.module.ts index 6d3cfc2..3a87aa9 100644 --- a/apps/api/src/courses/courses.module.ts +++ b/apps/api/src/courses/courses.module.ts @@ -5,10 +5,14 @@ import { ChaptersController } from './chapters.controller'; import { ChaptersService } from './chapters.service'; import { LessonsController } from './lessons.controller'; import { LessonsService } from './lessons.service'; +import { CourseSourcesController } from './course-sources.controller'; +import { CourseSourcesService } from './course-sources.service'; +import { AccessModule } from '../common/access/access.module'; @Module({ - controllers: [CoursesController, ChaptersController, LessonsController], - providers: [CoursesService, ChaptersService, LessonsService], - exports: [CoursesService, ChaptersService, LessonsService], + imports: [AccessModule], + controllers: [CoursesController, ChaptersController, LessonsController, CourseSourcesController], + providers: [CoursesService, ChaptersService, LessonsService, CourseSourcesService], + exports: [CoursesService, ChaptersService, LessonsService, CourseSourcesService], }) export class CoursesModule {} diff --git a/apps/api/src/courses/courses.service.ts b/apps/api/src/courses/courses.service.ts index ee98a37..e683aa4 100644 --- a/apps/api/src/courses/courses.service.ts +++ b/apps/api/src/courses/courses.service.ts @@ -12,7 +12,7 @@ export class CoursesService { async create(authorId: string, dto: CreateCourseDto): Promise { const slug = generateUniqueSlug(dto.title); - return this.prisma.course.create({ + const created = await this.prisma.course.create({ data: { authorId, title: dto.title, @@ -36,6 +36,18 @@ export class CoursesService { }, }, }); + + await this.prisma.courseStatusHistory.create({ + data: { + courseId: created.id, + fromStatus: null, + toStatus: CourseStatus.DRAFT, + changedById: authorId, + note: 'Course created', + }, + }); + + return created; } async findAllByAuthor( @@ -222,16 +234,28 @@ export class CoursesService { throw new ForbiddenException('You can only edit your own courses'); } - if (status === CourseStatus.PUBLISHED) { + if (status === CourseStatus.PUBLISHED || status === CourseStatus.APPROVED) { throw new ForbiddenException('Course can be published only by moderation'); } - const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status }; + const nextStatus = status === CourseStatus.PENDING_REVIEW ? CourseStatus.PENDING_MODERATION : status; + const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status: nextStatus }; - return this.prisma.course.update({ + const updated = await this.prisma.course.update({ where: { id }, data: updateData, }); + + await this.prisma.courseStatusHistory.create({ + data: { + courseId: id, + fromStatus: course.status, + toStatus: nextStatus, + changedById: userId, + }, + }); + + return updated; } async checkOwnership(courseId: string, userId: string): Promise { diff --git a/apps/api/src/courses/lessons.controller.ts b/apps/api/src/courses/lessons.controller.ts index bb21c87..56350b0 100644 --- a/apps/api/src/courses/lessons.controller.ts +++ b/apps/api/src/courses/lessons.controller.ts @@ -70,4 +70,15 @@ export class LessonsController { async generateQuiz(@Param('lessonId') lessonId: string): Promise { return this.lessonsService.generateQuiz(lessonId); } + + @Post('lessons/:lessonId/homework/generate') + @ApiOperation({ summary: 'Generate homework task for lesson (author only)' }) + async generateHomework( + @Param('courseId') courseId: string, + @Param('lessonId') lessonId: string, + @CurrentUser() user: User, + @Body('type') type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB' + ): Promise { + return this.lessonsService.generateHomework(courseId, lessonId, user.id, type); + } } diff --git a/apps/api/src/courses/lessons.service.ts b/apps/api/src/courses/lessons.service.ts index ca2881a..b23e413 100644 --- a/apps/api/src/courses/lessons.service.ts +++ b/apps/api/src/courses/lessons.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.service'; -import { Lesson } from '@coursecraft/database'; +import { HomeworkType, Lesson } from '@coursecraft/database'; import { CoursesService } from './courses.service'; import { ChaptersService } from './chapters.service'; import { CreateLessonDto } from './dto/create-lesson.dto'; @@ -268,4 +268,92 @@ ${textContent.slice(0, 3000)} }, ]; } + + async generateHomework( + courseId: string, + lessonId: string, + userId: string, + type: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB' = 'TEXT' + ): Promise { + const lesson = await this.prisma.lesson.findUnique({ + where: { id: lessonId }, + include: { + chapter: { + select: { + id: true, + title: true, + courseId: true, + }, + }, + }, + }); + if (!lesson || lesson.chapter.courseId !== courseId) { + throw new NotFoundException('Lesson not found'); + } + const isOwner = await this.coursesService.checkOwnership(courseId, userId); + if (!isOwner) { + throw new ForbiddenException('Only course author can generate homework'); + } + + const homeworkType = HomeworkType[type] ? (type as HomeworkType) : HomeworkType.TEXT; + const template = this.buildHomeworkTemplate(lesson.title, homeworkType); + + return this.prisma.homework.upsert({ + where: { lessonId }, + create: { + lessonId, + title: template.title, + description: template.description, + type: homeworkType, + config: template.config as any, + }, + update: { + title: template.title, + description: template.description, + type: homeworkType, + config: template.config as any, + }, + }); + } + + private buildHomeworkTemplate(lessonTitle: string, type: HomeworkType): { + title: string; + description: string; + config: Record; + } { + if (type === HomeworkType.FILE) { + return { + title: `Практическая работа (файл): ${lessonTitle}`, + description: 'Подготовьте файл с выполненным заданием и приложите ссылку/файл в ответе.', + config: { acceptedFormats: ['pdf', 'docx', 'txt', 'zip'] }, + }; + } + if (type === HomeworkType.PROJECT) { + return { + title: `Мини-проект: ${lessonTitle}`, + description: 'Сделайте небольшой проект по материалу урока. Опишите архитектуру и результат.', + config: { rubric: ['Понимание темы', 'Практичность', 'Качество решения'] }, + }; + } + if (type === HomeworkType.GITHUB) { + return { + title: `GitHub задача: ${lessonTitle}`, + description: 'Выполните задачу и приложите ссылку на публичный GitHub-репозиторий.', + config: { requireGithubUrl: true }, + }; + } + if (type === HomeworkType.QUIZ) { + return { + title: `Тест-кейс: ${lessonTitle}`, + description: 'Ответьте на контрольные вопросы и приложите обоснование решений.', + config: { requireExplanation: true }, + }; + } + return { + title: `Письменное домашнее задание: ${lessonTitle}`, + description: + 'Опишите, как вы примените изученную тему на практике. Приведите примеры, обоснования и собственные выводы.', + config: { minLength: 200 }, + }; + } } diff --git a/apps/api/src/enrollment/dto/submit-homework.dto.ts b/apps/api/src/enrollment/dto/submit-homework.dto.ts index db8689d..82099f4 100644 --- a/apps/api/src/enrollment/dto/submit-homework.dto.ts +++ b/apps/api/src/enrollment/dto/submit-homework.dto.ts @@ -1,14 +1,33 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MaxLength, MinLength } from 'class-validator'; +import { HomeworkType } from '@coursecraft/database'; +import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; export class SubmitHomeworkDto { @ApiProperty({ description: 'Written homework answer', - minLength: 50, + minLength: 1, maxLength: 20000, }) + @IsOptional() @IsString() - @MinLength(50) + @MinLength(1) @MaxLength(20000) - content: string; + content?: string; + + @ApiProperty({ enum: HomeworkType, default: HomeworkType.TEXT }) + @IsOptional() + @IsEnum(HomeworkType) + type?: HomeworkType; + + @ApiProperty({ required: false, description: 'File URL for FILE/PROJECT submission' }) + @IsOptional() + @IsString() + @MaxLength(1024) + attachmentUrl?: string; + + @ApiProperty({ required: false, description: 'GitHub repository URL' }) + @IsOptional() + @IsString() + @MaxLength(1024) + githubUrl?: string; } diff --git a/apps/api/src/enrollment/enrollment.controller.ts b/apps/api/src/enrollment/enrollment.controller.ts index 758e915..ca2abfd 100644 --- a/apps/api/src/enrollment/enrollment.controller.ts +++ b/apps/api/src/enrollment/enrollment.controller.ts @@ -83,7 +83,7 @@ export class EnrollmentController { @Body() dto: SubmitHomeworkDto, @CurrentUser() user: User, ): Promise { - return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto.content); + return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto); } @Post(':courseId/review') diff --git a/apps/api/src/enrollment/enrollment.service.ts b/apps/api/src/enrollment/enrollment.service.ts index 26c9adc..bc436a6 100644 --- a/apps/api/src/enrollment/enrollment.service.ts +++ b/apps/api/src/enrollment/enrollment.service.ts @@ -6,7 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.service'; -import { HomeworkReviewStatus } from '@coursecraft/database'; +import { HomeworkReviewStatus, HomeworkType } from '@coursecraft/database'; const QUIZ_PASS_THRESHOLD = 70; @@ -172,7 +172,12 @@ export class EnrollmentService { return { homework, submission }; } - async submitHomework(userId: string, courseId: string, lessonId: string, content: string): Promise { + async submitHomework( + userId: string, + courseId: string, + lessonId: string, + dto: { content?: string; type?: HomeworkType; attachmentUrl?: string; githubUrl?: string } + ): Promise { const enrollment = await this.requireEnrollment(userId, courseId); await this.assertLessonUnlocked(userId, courseId, lessonId); @@ -184,20 +189,36 @@ export class EnrollmentService { } const { homework } = await this.getHomework(userId, courseId, lessonId); - const aiResult = this.gradeHomeworkWithAI(content); + const submissionType = dto.type || homework.type || HomeworkType.TEXT; + const normalizedContent = (dto.content || '').trim(); + if (!normalizedContent && !dto.attachmentUrl && !dto.githubUrl) { + throw new BadRequestException('Provide content, attachment URL, or GitHub URL'); + } + const fallbackContent = + normalizedContent || + dto.githubUrl || + dto.attachmentUrl || + `Submission type: ${submissionType}`; + const aiResult = this.gradeHomeworkWithAI(fallbackContent); const submission = await this.prisma.homeworkSubmission.upsert({ where: { homeworkId_userId: { homeworkId: homework.id, userId } }, create: { homeworkId: homework.id, userId, - content, + content: fallbackContent, + answerType: submissionType, + attachmentUrl: dto.attachmentUrl || null, + githubUrl: dto.githubUrl || null, aiScore: aiResult.score, aiFeedback: aiResult.feedback, reviewStatus: HomeworkReviewStatus.AI_REVIEWED, }, update: { - content, + content: fallbackContent, + answerType: submissionType, + attachmentUrl: dto.attachmentUrl || null, + githubUrl: dto.githubUrl || null, aiScore: aiResult.score, aiFeedback: aiResult.feedback, reviewStatus: HomeworkReviewStatus.AI_REVIEWED, diff --git a/apps/api/src/groups/groups.controller.ts b/apps/api/src/groups/groups.controller.ts index 0ca3611..60cd099 100644 --- a/apps/api/src/groups/groups.controller.ts +++ b/apps/api/src/groups/groups.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Param, Body } from '@nestjs/common'; +import { Controller, Post, Get, Param, Body, Query } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { GroupsService } from './groups.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -31,13 +31,21 @@ export class GroupsController { } @Get(':groupId/messages') - async getMessages(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise { - return this.groupsService.getGroupMessages(groupId, user.id); + async getMessages( + @Param('groupId') groupId: string, + @Query('lessonId') lessonId: string | undefined, + @CurrentUser() user: User + ): Promise { + return this.groupsService.getGroupMessages(groupId, user.id, lessonId); } @Post(':groupId/messages') - async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise { - return this.groupsService.sendMessage(groupId, user.id, content); + async sendMessage( + @Param('groupId') groupId: string, + @Body() body: { content: string; lessonId?: string }, + @CurrentUser() user: User + ): Promise { + return this.groupsService.sendMessage(groupId, user.id, body.content, body.lessonId); } @Post(':groupId/invite-link') diff --git a/apps/api/src/groups/groups.gateway.ts b/apps/api/src/groups/groups.gateway.ts index d7929ee..8e80e0b 100644 --- a/apps/api/src/groups/groups.gateway.ts +++ b/apps/api/src/groups/groups.gateway.ts @@ -65,12 +65,12 @@ export class GroupsGateway implements OnGatewayConnection { @SubscribeMessage('groups:send') async sendMessage( @ConnectedSocket() client: Socket, - @MessageBody() body: { groupId: string; content: string } + @MessageBody() body: { groupId: string; content: string; lessonId?: string } ) { const user = client.data.user; if (!user || !body?.groupId || !body?.content?.trim()) return { ok: false }; - const message = await this.groupsService.sendMessage(body.groupId, user.id, body.content.trim()); + const message = await this.groupsService.sendMessage(body.groupId, user.id, body.content.trim(), body.lessonId); this.server.to(this.room(body.groupId)).emit('groups:new-message', message); return { ok: true, message }; } diff --git a/apps/api/src/groups/groups.module.ts b/apps/api/src/groups/groups.module.ts index 8170f76..dadfc12 100644 --- a/apps/api/src/groups/groups.module.ts +++ b/apps/api/src/groups/groups.module.ts @@ -3,9 +3,10 @@ import { GroupsController } from './groups.controller'; import { GroupsService } from './groups.service'; import { GroupsGateway } from './groups.gateway'; import { UsersModule } from '../users/users.module'; +import { AccessModule } from '../common/access/access.module'; @Module({ - imports: [UsersModule], + imports: [UsersModule, AccessModule], controllers: [GroupsController], providers: [GroupsService, GroupsGateway], exports: [GroupsService], diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index 33dcb8f..50bdca6 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -1,13 +1,16 @@ import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.service'; +import { AccessService } from '../common/access/access.service'; @Injectable() export class GroupsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private access: AccessService, + ) {} async createGroup(courseId: string, userId: string, name: string, description?: string): Promise { - const course = await this.prisma.course.findFirst({ where: { id: courseId, authorId: userId } }); - if (!course) throw new ForbiddenException('Only course author can create groups'); + await this.access.assertCourseOwner(courseId, userId); return this.prisma.courseGroup.create({ data: { courseId, name, description }, @@ -71,22 +74,25 @@ export class GroupsService { }); } - async getGroupMessages(groupId: string, userId: string): Promise { + async getGroupMessages(groupId: string, userId: string, lessonId?: string): Promise { await this.assertCanReadGroup(groupId, userId); return this.prisma.groupMessage.findMany({ - where: { groupId }, + where: { + groupId, + ...(lessonId ? { lessonId } : {}), + }, include: { user: { select: { id: true, name: true, avatarUrl: true } } }, orderBy: { createdAt: 'asc' }, take: 200, }); } - async sendMessage(groupId: string, userId: string, content: string): Promise { + async sendMessage(groupId: string, userId: string, content: string, lessonId?: string): Promise { await this.assertCanReadGroup(groupId, userId); return this.prisma.groupMessage.create({ - data: { groupId, userId, content }, + data: { groupId, userId, content, lessonId: lessonId || null }, include: { user: { select: { id: true, name: true, avatarUrl: true } } }, }); } @@ -97,9 +103,7 @@ export class GroupsService { include: { course: { select: { authorId: true } } }, }); if (!group) throw new NotFoundException('Group not found'); - if (group.course.authorId !== userId) { - throw new ForbiddenException('Only course author can create invite links'); - } + if (group.course.authorId !== userId) throw new ForbiddenException('Only course author can create invite links'); const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3080'; return { diff --git a/apps/api/src/moderation/moderation.controller.ts b/apps/api/src/moderation/moderation.controller.ts index ded8dde..1feaa7d 100644 --- a/apps/api/src/moderation/moderation.controller.ts +++ b/apps/api/src/moderation/moderation.controller.ts @@ -49,4 +49,18 @@ export class ModerationController { async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { await this.moderationService.deleteCourse(user.id, courseId); } + + @Get(':courseId/preview') + async previewCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + return this.moderationService.previewCourse(user.id, courseId); + } + + @Post(':courseId/quiz-preview') + async previewQuiz( + @Param('courseId') courseId: string, + @Body() body: { lessonId: string; answers?: number[] }, + @CurrentUser() user: User, + ): Promise { + return this.moderationService.previewQuiz(user.id, courseId, body); + } } diff --git a/apps/api/src/moderation/moderation.module.ts b/apps/api/src/moderation/moderation.module.ts index 5d5362a..5c57e72 100644 --- a/apps/api/src/moderation/moderation.module.ts +++ b/apps/api/src/moderation/moderation.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ModerationController } from './moderation.controller'; import { ModerationService } from './moderation.service'; +import { AccessModule } from '../common/access/access.module'; @Module({ + imports: [AccessModule], controllers: [ModerationController], providers: [ModerationService], exports: [ModerationService], diff --git a/apps/api/src/moderation/moderation.service.ts b/apps/api/src/moderation/moderation.service.ts index 022dd03..1b02d36 100644 --- a/apps/api/src/moderation/moderation.service.ts +++ b/apps/api/src/moderation/moderation.service.ts @@ -1,19 +1,21 @@ import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { CourseStatus } from '@coursecraft/database'; import { PrismaService } from '../common/prisma/prisma.service'; -import { CourseStatus, UserRole } from '@coursecraft/database'; +import { AccessService } from '../common/access/access.service'; +import { COURSE_PENDING_STATUSES, isPendingModeration } from '../common/course-status'; @Injectable() export class ModerationService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private access: AccessService, + ) {} async getPendingCourses(userId: string): Promise { - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { - throw new ForbiddenException('Moderators only'); - } + await this.access.assertStaff(userId); return this.prisma.course.findMany({ - where: { status: CourseStatus.PENDING_REVIEW }, + where: { status: { in: COURSE_PENDING_STATUSES } }, include: { author: { select: { id: true, name: true, email: true } }, _count: { select: { chapters: true } }, @@ -29,21 +31,27 @@ export class ModerationService { search?: string; } ): Promise { - await this.assertStaff(userId); + await this.access.assertStaff(userId); const allowedStatuses = Object.values(CourseStatus); const where: any = {}; if (options?.status && allowedStatuses.includes(options.status as CourseStatus)) { - where.status = options.status as CourseStatus; + const inputStatus = options.status as CourseStatus; + if (inputStatus === CourseStatus.PENDING_MODERATION) { + where.status = { in: COURSE_PENDING_STATUSES }; + } else { + where.status = inputStatus; + } } if (options?.search?.trim()) { + const term = options.search.trim(); where.OR = [ - { title: { contains: options.search.trim(), mode: 'insensitive' } }, - { description: { contains: options.search.trim(), mode: 'insensitive' } }, - { author: { name: { contains: options.search.trim(), mode: 'insensitive' } } }, - { author: { email: { contains: options.search.trim(), mode: 'insensitive' } } }, + { title: { contains: term, mode: 'insensitive' } }, + { description: { contains: term, mode: 'insensitive' } }, + { author: { name: { contains: term, mode: 'insensitive' } } }, + { author: { email: { contains: term, mode: 'insensitive' } } }, ]; } @@ -65,52 +73,69 @@ export class ModerationService { } async approveCourse(userId: string, courseId: string, note?: string): Promise { - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { - throw new ForbiddenException('Moderators only'); - } + await this.access.assertStaff(userId); const course = await this.prisma.course.findUnique({ where: { id: courseId }, - select: { status: true }, + select: { id: true, status: true }, }); if (!course) { - throw new ForbiddenException('Course not found'); + throw new NotFoundException('Course not found'); } - if (course.status !== CourseStatus.PENDING_REVIEW) { - throw new ForbiddenException('Only courses pending review can be approved'); + if (!isPendingModeration(course.status)) { + throw new ForbiddenException('Only courses pending moderation can be approved'); } - return this.prisma.course.update({ + const now = new Date(); + + const updated = await this.prisma.course.update({ where: { id: courseId }, data: { status: CourseStatus.PUBLISHED, isPublished: true, - publishedAt: new Date(), + publishedAt: now, moderationNote: note, - moderatedAt: new Date(), + moderatedAt: now, }, }); + + await this.prisma.courseStatusHistory.createMany({ + data: [ + { + courseId, + fromStatus: course.status, + toStatus: CourseStatus.APPROVED, + note: note || 'Approved by moderation', + changedById: userId, + }, + { + courseId, + fromStatus: CourseStatus.APPROVED, + toStatus: CourseStatus.PUBLISHED, + note: 'Auto publish after approve', + changedById: userId, + }, + ], + }); + + return updated; } async rejectCourse(userId: string, courseId: string, reason: string): Promise { - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { - throw new ForbiddenException('Moderators only'); - } + await this.access.assertStaff(userId); const course = await this.prisma.course.findUnique({ where: { id: courseId }, - select: { status: true }, + select: { id: true, status: true }, }); if (!course) { - throw new ForbiddenException('Course not found'); + throw new NotFoundException('Course not found'); } - if (course.status !== CourseStatus.PENDING_REVIEW) { - throw new ForbiddenException('Only courses pending review can be rejected'); + if (!isPendingModeration(course.status)) { + throw new ForbiddenException('Only courses pending moderation can be rejected'); } - return this.prisma.course.update({ + const updated = await this.prisma.course.update({ where: { id: courseId }, data: { status: CourseStatus.REJECTED, @@ -118,10 +143,97 @@ export class ModerationService { moderatedAt: new Date(), }, }); + + await this.prisma.courseStatusHistory.create({ + data: { + courseId, + fromStatus: course.status, + toStatus: CourseStatus.REJECTED, + note: reason, + changedById: userId, + }, + }); + + return updated; + } + + async previewCourse(userId: string, courseId: string): Promise { + await this.access.assertStaff(userId); + + const course = await this.prisma.course.findUnique({ + where: { id: courseId }, + include: { + author: { select: { id: true, name: true, email: true } }, + chapters: { + include: { + lessons: { + include: { + quiz: true, + homework: true, + }, + orderBy: { order: 'asc' }, + }, + }, + orderBy: { order: 'asc' }, + }, + }, + }); + + if (!course) { + throw new NotFoundException('Course not found'); + } + + if (!isPendingModeration(course.status) && course.status !== CourseStatus.REJECTED) { + throw new ForbiddenException('Preview is available only for moderation flow courses'); + } + + return course; + } + + async previewQuiz( + userId: string, + courseId: string, + dto: { lessonId: string; answers?: number[] } + ): Promise { + await this.access.assertStaff(userId); + + const lesson = await this.prisma.lesson.findFirst({ + where: { id: dto.lessonId, chapter: { courseId } }, + include: { quiz: true }, + }); + if (!lesson) { + throw new NotFoundException('Lesson not found in this course'); + } + if (!lesson.quiz) { + return { questions: [], score: null, passed: null, preview: true }; + } + + const questions = Array.isArray(lesson.quiz.questions) ? (lesson.quiz.questions as any[]) : []; + if (!dto.answers) { + return { questions, score: null, passed: null, preview: true }; + } + + const correct = questions.reduce((acc, question, idx) => { + const expected = Number(question.correctAnswer); + const actual = Number(dto.answers?.[idx]); + return acc + (expected === actual ? 1 : 0); + }, 0); + + const total = questions.length || 1; + const score = Math.round((correct / total) * 100); + + return { + questions, + score, + passed: score >= 70, + totalQuestions: questions.length, + correctAnswers: correct, + preview: true, + }; } async hideReview(userId: string, reviewId: string): Promise { - await this.assertStaff(userId); + await this.access.assertStaff(userId); const review = await this.prisma.review.update({ where: { id: reviewId }, data: { isApproved: false }, @@ -131,7 +243,7 @@ export class ModerationService { } async unhideReview(userId: string, reviewId: string): Promise { - await this.assertStaff(userId); + await this.access.assertStaff(userId); const review = await this.prisma.review.update({ where: { id: reviewId }, data: { isApproved: true }, @@ -141,7 +253,7 @@ export class ModerationService { } async deleteCourse(userId: string, courseId: string): Promise { - await this.assertStaff(userId); + await this.access.assertStaff(userId); const existing = await this.prisma.course.findUnique({ where: { id: courseId }, select: { id: true }, @@ -153,13 +265,6 @@ export class ModerationService { await this.prisma.course.delete({ where: { id: courseId } }); } - private async assertStaff(userId: string): Promise { - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { - throw new ForbiddenException('Moderators only'); - } - } - private async recalculateAverageRating(courseId: string): Promise { const result = await this.prisma.review.aggregate({ where: { courseId, isApproved: true }, diff --git a/apps/api/src/payments/dev-payments.controller.ts b/apps/api/src/payments/dev-payments.controller.ts new file mode 100644 index 0000000..663b338 --- /dev/null +++ b/apps/api/src/payments/dev-payments.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; +import { PaymentsService } from './payments.service'; + +@ApiTags('payments') +@Controller('payments') +@ApiBearerAuth() +export class DevPaymentsController { + constructor(private paymentsService: PaymentsService) {} + + @Post('dev/yoomoney/complete') + @ApiOperation({ summary: 'Complete DEV YooMoney payment (mock flow)' }) + async completeDevYoomoneyPayment( + @CurrentUser() user: User, + @Body('courseId') courseId: string, + ) { + return this.paymentsService.completeDevYoomoneyPayment(user.id, courseId); + } +} + diff --git a/apps/api/src/payments/payments.module.ts b/apps/api/src/payments/payments.module.ts index 75f5416..6b2a3fc 100644 --- a/apps/api/src/payments/payments.module.ts +++ b/apps/api/src/payments/payments.module.ts @@ -3,9 +3,10 @@ import { PaymentsController } from './payments.controller'; import { PaymentsService } from './payments.service'; import { StripeService } from './stripe.service'; import { WebhooksController } from './webhooks.controller'; +import { DevPaymentsController } from './dev-payments.controller'; @Module({ - controllers: [PaymentsController, WebhooksController], + controllers: [PaymentsController, WebhooksController, DevPaymentsController], providers: [PaymentsService, StripeService], exports: [PaymentsService, StripeService], }) diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts index bbe3779..3d3a903 100644 --- a/apps/api/src/payments/payments.service.ts +++ b/apps/api/src/payments/payments.service.ts @@ -1,8 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../common/prisma/prisma.service'; import { StripeService } from './stripe.service'; -import { SubscriptionTier } from '@coursecraft/database'; +import { PaymentMode, PaymentProvider, SubscriptionTier } from '@coursecraft/database'; import { SUBSCRIPTION_PLANS } from '@coursecraft/shared'; @Injectable() @@ -78,6 +78,9 @@ export class PaymentsService { if (!course) { throw new NotFoundException('Course not found'); } + if (!course.isPublished) { + throw new ForbiddenException('Course is not available for purchase'); + } if (!course.price) { throw new Error('Course is free, checkout is not required'); } @@ -85,8 +88,26 @@ export class PaymentsService { throw new Error('Course is already purchased'); } - const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId); const appUrl = this.configService.get('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000'; + const paymentMode = this.getPaymentMode(); + + if (paymentMode === PaymentMode.DEV) { + await this.handleCoursePurchaseCompleted({ + userId, + courseId, + provider: PaymentProvider.YOOMONEY, + mode: PaymentMode.DEV, + eventCode: 'DEV_PAYMENT_SUCCESS', + }); + console.log('DEV_PAYMENT_SUCCESS', { userId, courseId, provider: 'YOOMONEY' }); + return { + url: `${appUrl}/courses/${courseId}?purchase=success&devPayment=1`, + mode: 'DEV', + provider: 'YOOMONEY', + }; + } + + const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId); const unitAmount = Math.round(Number(course.price) * 100); const session = await this.stripeService.createOneTimeCheckoutSession({ @@ -104,7 +125,23 @@ export class PaymentsService { }, }); - return { url: session.url }; + return { url: session.url, mode: 'PROD', provider: 'STRIPE' }; + } + + async completeDevYoomoneyPayment(userId: string, courseId: string) { + await this.handleCoursePurchaseCompleted({ + userId, + courseId, + provider: PaymentProvider.YOOMONEY, + mode: PaymentMode.DEV, + eventCode: 'DEV_PAYMENT_SUCCESS', + }); + return { + success: true, + eventCode: 'DEV_PAYMENT_SUCCESS', + provider: 'YOOMONEY', + mode: 'DEV', + }; } async createPortalSession(userId: string) { @@ -169,6 +206,9 @@ export class PaymentsService { await this.handleCoursePurchaseCompleted({ userId: metadata.userId, courseId: metadata.courseId || '', + provider: PaymentProvider.STRIPE, + mode: PaymentMode.PROD, + eventCode: 'STRIPE_PAYMENT_SUCCESS', }); return; } @@ -201,7 +241,13 @@ export class PaymentsService { }); } - private async handleCoursePurchaseCompleted(params: { userId: string; courseId: string }) { + private async handleCoursePurchaseCompleted(params: { + userId: string; + courseId: string; + provider?: PaymentProvider; + mode?: PaymentMode; + eventCode?: string; + }) { const { userId, courseId } = params; if (!courseId) return; @@ -219,11 +265,27 @@ export class PaymentsService { amount: course.price, currency: course.currency, status: 'completed', + provider: params.provider || PaymentProvider.STRIPE, + mode: params.mode || PaymentMode.PROD, + eventCode: params.eventCode || null, + metadata: { + eventCode: params.eventCode || null, + provider: params.provider || PaymentProvider.STRIPE, + mode: params.mode || PaymentMode.PROD, + }, }, update: { status: 'completed', amount: course.price, currency: course.currency, + provider: params.provider || PaymentProvider.STRIPE, + mode: params.mode || PaymentMode.PROD, + eventCode: params.eventCode || null, + metadata: { + eventCode: params.eventCode || null, + provider: params.provider || PaymentProvider.STRIPE, + mode: params.mode || PaymentMode.PROD, + }, }, }); @@ -241,6 +303,11 @@ export class PaymentsService { }); } + private getPaymentMode(): PaymentMode { + const raw = (this.configService.get('PAYMENT_MODE') || 'PROD').toUpperCase(); + return raw === PaymentMode.DEV ? PaymentMode.DEV : PaymentMode.PROD; + } + private async getOrCreateStripeCustomer(userId: string): Promise<{ stripeCustomerId: string; email: string; name: string | null }> { const user = await this.prisma.user.findUnique({ where: { id: userId }, diff --git a/apps/api/src/support/support.module.ts b/apps/api/src/support/support.module.ts index e084338..52fcc66 100644 --- a/apps/api/src/support/support.module.ts +++ b/apps/api/src/support/support.module.ts @@ -3,9 +3,10 @@ import { SupportController } from './support.controller'; import { SupportService } from './support.service'; import { SupportGateway } from './support.gateway'; import { UsersModule } from '../users/users.module'; +import { AccessModule } from '../common/access/access.module'; @Module({ - imports: [UsersModule], + imports: [UsersModule, AccessModule], controllers: [SupportController], providers: [SupportService, SupportGateway], exports: [SupportService], diff --git a/apps/api/src/support/support.service.ts b/apps/api/src/support/support.service.ts index 5914fa0..b9935de 100644 --- a/apps/api/src/support/support.service.ts +++ b/apps/api/src/support/support.service.ts @@ -1,10 +1,14 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { UserRole } from '@coursecraft/database'; import { PrismaService } from '../common/prisma/prisma.service'; +import { AccessService } from '../common/access/access.service'; @Injectable() export class SupportService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private access: AccessService, + ) {} async createTicket( userId: string, @@ -150,12 +154,6 @@ export class SupportService { } private async assertStaff(userId: string): Promise { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { role: true }, - }); - if (!user || (user.role !== UserRole.ADMIN && user.role !== UserRole.MODERATOR)) { - throw new ForbiddenException('Staff access only'); - } + await this.access.assertStaff(userId); } } diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 6d0ee7a..801531f 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.service'; -import { User, UserSettings, SubscriptionTier } from '@coursecraft/database'; +import { User, UserRole, UserSettings, SubscriptionTier } from '@coursecraft/database'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateSettingsDto } from './dto/update-settings.dto'; @@ -147,4 +147,42 @@ export class UsersService { }, }); } + + async listUsers(options?: { search?: string; role?: UserRole; limit?: number }) { + const limit = Math.min(200, Math.max(1, options?.limit || 100)); + const where: any = {}; + if (options?.role) { + where.role = options.role; + } + if (options?.search?.trim()) { + const term = options.search.trim(); + where.OR = [ + { email: { contains: term, mode: 'insensitive' } }, + { name: { contains: term, mode: 'insensitive' } }, + ]; + } + return this.prisma.user.findMany({ + where, + select: { + id: true, + email: true, + name: true, + avatarUrl: true, + role: true, + subscriptionTier: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async updateRole(userId: string, role: UserRole): Promise { + return this.prisma.user.update({ + where: { id: userId }, + data: { role }, + include: { settings: true, subscription: true }, + }); + } } diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx index fceec4d..cc3ab7e 100644 --- a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx @@ -4,15 +4,20 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { + CheckSquare, ChevronLeft, ChevronRight, Eye, + FileArchive, FileText, + FolderOpen, + ListChecks, Layers3, Lock, Save, Settings2, Shield, + Sparkles, Upload, Wallet, } from 'lucide-react'; @@ -35,7 +40,7 @@ type CourseData = { coverImage?: string | null; price?: number | null; currency?: string; - status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string; + status: 'DRAFT' | 'PENDING_MODERATION' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string; moderationNote?: string | null; difficulty?: string | null; estimatedHours?: number | null; @@ -43,12 +48,15 @@ type CourseData = { chapters: Chapter[]; }; -type EditTab = 'general' | 'content' | 'pricing' | 'settings' | 'access'; +type EditTab = 'general' | 'content' | 'quiz' | 'homework' | 'materials' | 'pricing' | 'settings' | 'access'; const emptyDoc = { type: 'doc', content: [] }; const tabs: { key: EditTab; label: string; icon: any }[] = [ { key: 'general', label: 'Общая информация', icon: FileText }, { key: 'content', label: 'Контент', icon: Layers3 }, + { key: 'quiz', label: 'Тест', icon: CheckSquare }, + { key: 'homework', label: 'Домашнее задание', icon: ListChecks }, + { key: 'materials', label: 'Доп. материалы', icon: FolderOpen }, { key: 'pricing', label: 'Цены', icon: Wallet }, { key: 'settings', label: 'Настройки', icon: Settings2 }, { key: 'access', label: 'Доступ', icon: Lock }, @@ -84,6 +92,13 @@ export default function CourseEditPage() { const [courseDifficulty, setCourseDifficulty] = useState(''); const [courseEstimatedHours, setCourseEstimatedHours] = useState(''); const [courseTags, setCourseTags] = useState(''); + const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT'); + const [quizGenerated, setQuizGenerated] = useState(false); + const [materials, setMaterials] = useState([]); + const [outlineHints, setOutlineHints] = useState([]); + const [uploadingSource, setUploadingSource] = useState(false); + const [generatingHomework, setGeneratingHomework] = useState(false); + const [generatingQuiz, setGeneratingQuiz] = useState(false); useEffect(() => { if (!courseId || authLoading) return; @@ -156,6 +171,11 @@ export default function CourseEditPage() { }; }, [courseId, activeLesson?.lessonId]); + useEffect(() => { + if (activeTab !== 'materials') return; + loadMaterials(); + }, [activeTab, courseId]); + const handleSelectLesson = (lessonId: string) => { if (!course) return; for (const chapter of course.chapters) { @@ -233,6 +253,65 @@ export default function CourseEditPage() { } }; + const handleGenerateQuiz = async () => { + if (!courseId || !activeLesson) return; + setGeneratingQuiz(true); + try { + await api.getLessonQuiz(courseId, activeLesson.lessonId); + setQuizGenerated(true); + toast({ title: 'Готово', description: 'Тест для урока сгенерирован или обновлён' }); + } catch (e: any) { + toast({ title: 'Ошибка', description: e.message || 'Не удалось сгенерировать тест', variant: 'destructive' }); + } finally { + setGeneratingQuiz(false); + } + }; + + const handleGenerateHomework = async () => { + if (!courseId || !activeLesson) return; + setGeneratingHomework(true); + try { + await api.generateLessonHomework(courseId, activeLesson.lessonId, homeworkType); + toast({ title: 'Готово', description: 'Домашнее задание обновлено' }); + } catch (e: any) { + toast({ title: 'Ошибка', description: e.message || 'Не удалось сгенерировать ДЗ', variant: 'destructive' }); + } finally { + setGeneratingHomework(false); + } + }; + + const loadMaterials = async () => { + if (!courseId) return; + try { + const [files, hints] = await Promise.all([ + api.getCourseSources(courseId).catch(() => []), + api.getCourseSourceOutlineHints(courseId).catch(() => ({ hints: [] })), + ]); + setMaterials(files || []); + setOutlineHints(hints?.hints || []); + } catch { + setMaterials([]); + setOutlineHints([]); + } + }; + + const handleUploadSource = async (event: any) => { + if (!courseId) return; + const file = event.target.files?.[0]; + if (!file) return; + setUploadingSource(true); + try { + await api.uploadCourseSource(courseId, file); + toast({ title: 'Файл загружен', description: 'Источник добавлен в анализ курса' }); + await loadMaterials(); + } catch (e: any) { + toast({ title: 'Ошибка', description: e.message || 'Не удалось загрузить файл', variant: 'destructive' }); + } finally { + setUploadingSource(false); + event.target.value = ''; + } + }; + if (authLoading || loading) { return (
@@ -376,6 +455,123 @@ export default function CourseEditPage() { )} + {activeTab === 'quiz' && ( + + + Тест урока + + +

+ Выберите урок во вкладке «Контент», затем сгенерируйте тест в один клик. +

+
+

Текущий урок:

+

{activeLessonMeta?.title || 'Урок не выбран'}

+
+ + {quizGenerated ? ( +

Тест доступен студентам в режиме обучения.

+ ) : null} +
+
+ )} + + {activeTab === 'homework' && ( + + + Домашнее задание + + +

+ Типы: Текстовый ответ, Файл, Проект, Тест, GitHub ссылка. +

+
+ + +
+

+ ДЗ создаётся для выбранного урока: {activeLessonMeta?.title || 'урок не выбран'}. +

+
+
+ )} + + {activeTab === 'materials' && ( + + + Дополнительные материалы и источники + + +
+

Поддержка форматов

+

PDF, DOCX, TXT, PPTX, изображения, ZIP

+

+ В фазе 1: PDF/TXT анализируются для структуры, остальные сохраняются как вложения. +

+
+ + + +
+

Загруженные файлы

+ {materials.length === 0 ? ( +

Пока нет загруженных материалов.

+ ) : ( +
+ {materials.map((file) => ( +
+

{file.fileName}

+

+ {file.sourceType} • {file.parseStatus} +

+
+ ))} +
+ )} +
+ +
+

Предложенная структура из источников

+ {outlineHints.length === 0 ? ( +

Пока нет рекомендаций. Добавьте PDF/TXT.

+ ) : ( +
    + {outlineHints.map((hint: any) => ( +
  • {hint.title}
  • + ))} +
+ )} +
+
+
+ )} + {activeTab === 'pricing' && ( diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx index 0b02eba..7e50550 100644 --- a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx @@ -12,6 +12,7 @@ import { ChevronRight, Clock, Edit, + FilePlus2, GraduationCap, Lock, Play, @@ -32,6 +33,7 @@ import { } from '@/components/ui/alert-dialog'; import { LessonContentViewer } from '@/components/editor/lesson-content-viewer'; import { LessonQuiz } from '@/components/dashboard/lesson-quiz'; +import { LessonChatPanel } from '@/components/dashboard/lesson-chat-panel'; import { api } from '@/lib/api'; import { useAuth } from '@/contexts/auth-context'; import { cn } from '@/lib/utils'; @@ -53,7 +55,7 @@ type LessonProgressRow = { }; type HomeworkState = { - homework: { id: string; title: string; description: string } | null; + homework: { id: string; title: string; description: string; type?: string; config?: any } | null; submission: { id: string; content: string; aiScore?: number | null; aiFeedback?: string | null } | null; }; @@ -80,6 +82,9 @@ export default function CoursePage() { const [homeworkLoading, setHomeworkLoading] = useState(false); const [homeworkSubmitting, setHomeworkSubmitting] = useState(false); const [homeworkContent, setHomeworkContent] = useState(''); + const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT'); + const [groupId, setGroupId] = useState(null); + const [activeLessonPanel, setActiveLessonPanel] = useState<'content' | 'quiz' | 'homework' | 'materials'>('content'); const flatLessons = useMemo(() => { if (!course) return []; @@ -156,7 +161,13 @@ export default function CoursePage() { setCourse(courseData); setExpandedChapters((courseData.chapters || []).map((ch: Chapter) => ch.id)); - const map = await refreshProgress(id); + const [map, groupData] = await Promise.all([ + refreshProgress(id), + api.getDefaultCourseGroup(id).catch(() => null), + ]); + if (groupData?.group?.id) { + setGroupId(groupData.group.id); + } const ordered = (courseData.chapters || []) .sort((a: Chapter, b: Chapter) => a.order - b.order) @@ -195,6 +206,7 @@ export default function CoursePage() { setLessonContentLoading(true); setShowQuiz(false); setQuizQuestions([]); + setActiveLessonPanel('content'); (async () => { try { @@ -277,7 +289,10 @@ export default function CoursePage() { if (!id || !selectedLessonId || !homeworkContent.trim() || homeworkSubmitting) return; setHomeworkSubmitting(true); try { - const submission = await api.submitLessonHomework(id, selectedLessonId, homeworkContent.trim()); + const submission = await api.submitLessonHomework(id, selectedLessonId, { + content: homeworkContent.trim(), + type: homeworkType, + }); setHomework((prev) => ({ ...prev, submission })); await refreshProgress(id); } finally { @@ -285,6 +300,12 @@ export default function CoursePage() { } }; + const handleGenerateHomework = async () => { + if (!id || !selectedLessonId) return; + await api.generateLessonHomework(id, selectedLessonId, homeworkType).catch(() => null); + await loadHomework(id, selectedLessonId); + }; + const goToPrevLesson = () => { if (currentLessonIndex <= 0) return; setSelectedLessonId(flatLessons[currentLessonIndex - 1].id); @@ -514,33 +535,96 @@ export default function CoursePage() {
) : selectedLessonId ? ( <> - +
+ + + + +
- {!activeProgress?.quizPassed && ( -
-

Шаг 1 из 2: тест

-

- Для открытия следующего урока нужно пройти тест и отправить письменное ДЗ. -

- + {activeLessonPanel === 'content' ? ( + + ) : null} + + {activeLessonPanel === 'quiz' ? ( +
+ {!activeProgress?.quizPassed ? ( +
+

Шаг 1 из 2: тест

+

+ Для открытия следующего урока пройдите тест. +

+ +
+ ) : ( +
+ Тест уже пройден. Можно переходить к домашнему заданию. +
+ )} + {showQuiz ? ( + + ) : null}
- )} + ) : null} - {showQuiz && ( - - )} - - {activeProgress?.quizPassed && ( -
-

Шаг 2 из 2: письменное домашнее задание

- {homeworkLoading ? ( + {activeLessonPanel === 'homework' ? ( +
+
+

Домашнее задание

+ {isAuthor ? ( +
+ + +
+ ) : null} +
+ {!activeProgress?.quizPassed ? ( +

Сначала пройдите тест этого урока.

+ ) : homeworkLoading ? (

Подготовка задания...

) : ( <> @@ -559,7 +643,7 @@ export default function CoursePage() { disabled={Boolean(activeProgress?.homeworkSubmitted)} />
-

Минимум 50 символов

+

Рекомендуется подробный ответ и примеры

- )} + ) : null} + + {activeLessonPanel === 'materials' ? ( +
+ Дополнительные материалы для урока можно добавить в редакторе курса во вкладке + {' '}«Доп. материалы». +
+ ) : null} ) : (
Выберите урок
@@ -613,6 +704,7 @@ export default function CoursePage() {
+
); } diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx index 95704d0..858f597 100644 --- a/apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils'; import { api } from '@/lib/api'; import { useToast } from '@/components/ui/use-toast'; -type Step = 'prompt' | 'questions' | 'generating' | 'complete' | 'error'; +type Step = 'prompt' | 'questions' | 'recommendations' | 'generating' | 'complete' | 'error'; interface ClarifyingQuestion { id: string; @@ -34,6 +34,12 @@ export default function NewCoursePage() { const [errorMessage, setErrorMessage] = useState(''); const [courseId, setCourseId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [aiRecommendation, setAiRecommendation] = useState<{ + modules: number; + lessonFormat: string; + assignmentTypes: string[]; + suggestedStructure: string[]; + } | null>(null); // Poll for generation status const pollStatus = useCallback(async () => { @@ -74,7 +80,7 @@ export default function NewCoursePage() { break; default: // Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT) - if (step !== 'questions') { + if (step !== 'questions' && step !== 'recommendations') { setStep('generating'); } } @@ -85,7 +91,7 @@ export default function NewCoursePage() { // Start polling when we have a generation ID useEffect(() => { - if (!generationId || step === 'complete' || step === 'error' || step === 'questions') { + if (!generationId || step === 'complete' || step === 'error' || step === 'questions' || step === 'recommendations') { return; } @@ -132,13 +138,44 @@ export default function NewCoursePage() { const handleSubmitAnswers = async () => { if (!generationId || isSubmitting) return; + const audience = String(answers.q_audience || ''); + const format = String(answers.q_format || ''); + const goal = String(answers.q_goal || ''); + const volume = String(answers.q_volume || ''); + + const modules = + volume.includes('Короткий') ? 4 : volume.includes('Полный') ? 9 : 6; + const lessonFormat = + format === 'Практика' ? '70% практика / 30% теория' : format === 'Теория' ? '80% теория / 20% практика' : 'Смешанный 50/50'; + const assignmentTypes = + goal === 'Подготовиться к экзамену' + ? ['Тесты', 'Контрольные кейсы', 'Проверка на время'] + : goal === 'Освоить профессию' + ? ['Практика', 'Мини-проекты', 'Портфолио задания'] + : ['Практика', 'Тест', 'Домашнее задание']; + const suggestedStructure = [ + `Введение для уровня: ${audience || 'не указан'}`, + 'Базовые принципы и инструменты', + 'Практические модули по задачам', + 'Финальный блок с закреплением', + ]; + + setAiRecommendation({ + modules, + lessonFormat, + assignmentTypes, + suggestedStructure, + }); + setStep('recommendations'); + }; + + const handleConfirmGeneration = async () => { + if (!generationId || isSubmitting) return; setIsSubmitting(true); try { await api.answerQuestions(generationId, answers); setStep('generating'); - - // Resume polling setTimeout(pollStatus, 1000); } catch (error: any) { toast({ @@ -171,6 +208,7 @@ export default function NewCoursePage() { setCurrentStepText(''); setErrorMessage(''); setCourseId(null); + setAiRecommendation(null); }; const allRequiredAnswered = questions @@ -392,6 +430,68 @@ export default function NewCoursePage() { )} + {/* Step 3: Generating */} + {step === 'recommendations' && ( + + + +
+ +

AI-рекомендации перед генерацией

+
+ {aiRecommendation ? ( +
+
+

Рекомендуемое число модулей: {aiRecommendation.modules}

+

Формат уроков: {aiRecommendation.lessonFormat}

+
+
+

Типы заданий:

+
    + {aiRecommendation.assignmentTypes.map((item) => ( +
  • {item}
  • + ))} +
+
+
+

Рекомендованная структура:

+
    + {aiRecommendation.suggestedStructure.map((item) => ( +
  • {item}
  • + ))} +
+
+
+ ) : null} + +
+ + +
+
+
+
+ )} + {/* Step 3: Generating */} {step === 'generating' && ( { const drafts = courses.filter((course) => course.status === 'DRAFT' || course.status === 'REJECTED'); const published = courses.filter((course) => course.status === 'PUBLISHED'); - const pending = courses.filter((course) => course.status === 'PENDING_REVIEW'); + const pending = courses.filter( + (course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW' + ); return { drafts, published, diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 82d18cb..0bf5735 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -2,7 +2,17 @@ import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; -import { CheckCircle2, Loader2, MessageCircle, Search, Trash2, XCircle } from 'lucide-react'; +import { + CheckCircle2, + CreditCard, + ExternalLink, + Loader2, + MessageCircle, + Search, + Trash2, + Users, + XCircle, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { api } from '@/lib/api'; @@ -19,9 +29,30 @@ type ModerationCourse = { _count?: { chapters?: number; enrollments?: number; reviews?: number }; }; +type AdminUser = { + id: string; + email: string; + name?: string | null; + role: 'USER' | 'MODERATOR' | 'ADMIN'; + subscriptionTier?: string; +}; + +type AdminPayment = { + id: string; + amount: string | number; + currency: string; + status: string; + mode: 'DEV' | 'PROD'; + provider: 'STRIPE' | 'YOOMONEY'; + eventCode?: string | null; + createdAt: string; + user?: { name?: string | null; email?: string }; + course?: { id: string; title: string }; +}; + const statusFilters = [ { value: '', label: 'Все статусы' }, - { value: 'PENDING_REVIEW', label: 'На проверке' }, + { value: 'PENDING_MODERATION', label: 'На проверке' }, { value: 'PUBLISHED', label: 'Опубликованные' }, { value: 'REJECTED', label: 'Отклонённые' }, { value: 'DRAFT', label: 'Черновики' }, @@ -29,6 +60,7 @@ const statusFilters = [ const badgeMap: Record = { PENDING_REVIEW: 'bg-amber-100 text-amber-900', + PENDING_MODERATION: 'bg-amber-100 text-amber-900', PUBLISHED: 'bg-green-100 text-green-900', REJECTED: 'bg-rose-100 text-rose-900', DRAFT: 'bg-slate-100 text-slate-900', @@ -38,6 +70,8 @@ const badgeMap: Record = { export default function AdminPage() { const { toast } = useToast(); + const [activeTab, setActiveTab] = useState<'courses' | 'users' | 'payments'>('courses'); + const [courses, setCourses] = useState([]); const [search, setSearch] = useState(''); const [status, setStatus] = useState(''); @@ -45,10 +79,20 @@ export default function AdminPage() { const [loading, setLoading] = useState(true); const [actingId, setActingId] = useState(null); + const [adminUsers, setAdminUsers] = useState([]); + const [usersLoading, setUsersLoading] = useState(false); + const [usersSearch, setUsersSearch] = useState(''); + + const [payments, setPayments] = useState([]); + const [paymentsLoading, setPaymentsLoading] = useState(false); + const [paymentSearch, setPaymentSearch] = useState(''); + const [paymentMode, setPaymentMode] = useState(''); + const loadCourses = async () => { setLoading(true); try { - const data = await api.getModerationCourses({ status: status || undefined, search: search || undefined }); + const requestedStatus = status === 'PENDING_REVIEW' ? 'PENDING_MODERATION' : status; + const data = await api.getModerationCourses({ status: requestedStatus || undefined, search: search || undefined }); setCourses(data || []); } catch (error: any) { toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить курсы', variant: 'destructive' }); @@ -58,14 +102,52 @@ export default function AdminPage() { } }; + const loadUsers = async () => { + setUsersLoading(true); + try { + const data = await api.getAdminUsers({ search: usersSearch || undefined }); + setAdminUsers(data || []); + } catch (error: any) { + toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить пользователей', variant: 'destructive' }); + setAdminUsers([]); + } finally { + setUsersLoading(false); + } + }; + + const loadPayments = async () => { + setPaymentsLoading(true); + try { + const data = await api.getAdminPayments({ + search: paymentSearch || undefined, + mode: (paymentMode || undefined) as any, + }); + setPayments(data || []); + } catch (error: any) { + toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить платежи', variant: 'destructive' }); + setPayments([]); + } finally { + setPaymentsLoading(false); + } + }; + useEffect(() => { loadCourses(); }, [status]); + useEffect(() => { + if (activeTab === 'users') { + loadUsers(); + } + if (activeTab === 'payments') { + loadPayments(); + } + }, [activeTab]); + const stats = useMemo(() => { return { total: courses.length, - pending: courses.filter((course) => course.status === 'PENDING_REVIEW').length, + pending: courses.filter((course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW').length, published: courses.filter((course) => course.status === 'PUBLISHED').length, }; }, [courses]); @@ -109,14 +191,24 @@ export default function AdminPage() { } }; + const updateUserRole = async (userId: string, role: 'USER' | 'MODERATOR' | 'ADMIN') => { + try { + await api.updateAdminUserRole(userId, role); + toast({ title: 'Роль обновлена', description: `Новая роль: ${role}` }); + await loadUsers(); + } catch (error: any) { + toast({ title: 'Ошибка', description: error.message || 'Не удалось изменить роль', variant: 'destructive' }); + } + }; + return (
-

Модерация курсов

+

Админ Панель

- Проверка курсов, публикация, отклонение и удаление. + Модерация курсов, поддержка, управление пользователями и платежами.

-
- - - Всего в выдаче - - {stats.total} - - - - Ожидают модерации - - {stats.pending} - - - - Опубликовано - - {stats.published} - -
- -
- - - - - + +
-
- {courses.map((course) => ( - - -
-
- {course.title} - - {course.author?.name || 'Без имени'} ({course.author?.email || '—'}) - -
- - {course.status} - -
-
- - -
-

Глав: {course._count?.chapters || 0}

-

Студентов: {course._count?.enrollments || 0}

-

Отзывов: {course._count?.reviews || 0}

-
+ {activeTab === 'courses' ? ( + <> +
+ + + Всего в выдаче + + {stats.total} + + + + Ожидают модерации + + {stats.pending} + + + + Опубликовано + + {stats.published} + +
+
+ -
- {course.status === 'PENDING_REVIEW' ? ( - <> - +
+ +
+ {courses.map((course) => ( + + +
+
+ {course.title} + + {course.author?.name || 'Без имени'} ({course.author?.email || '—'}) + +
+ + {course.status} + +
+
+ + +
+

Глав: {course._count?.chapters || 0}

+

Студентов: {course._count?.enrollments || 0}

+

Отзывов: {course._count?.reviews || 0}

+
+ +
+ setNoteDraft((prev) => ({ ...prev, [course.id]: e.target.value }))} + placeholder="Комментарий модерации" + className="h-10 w-full rounded-lg border bg-background px-3 text-sm" + /> + +
+ +
+ {course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW' ? ( + <> + + + + ) : null} + - +
+
+
+ ))} + + {!loading && courses.length === 0 ? ( + + + Курсы по заданным фильтрам не найдены. + + + ) : null} +
+ + ) : null} + + {activeTab === 'users' ? ( +
+
+ setUsersSearch(e.target.value)} + className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm" + placeholder="Поиск пользователя" + /> + +
+ + {adminUsers.map((user) => ( + + +
+

{user.name || user.email}

+

{user.email} • {user.subscriptionTier || 'FREE'}

+
+
+ +
+
+
+ ))} + + {!usersLoading && adminUsers.length === 0 ? ( + + Пользователи не найдены. + + ) : null} +
+ ) : null} + + {activeTab === 'payments' ? ( +
+
+ setPaymentSearch(e.target.value)} + className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm" + placeholder="Поиск по курсу / пользователю" + /> + + +
+ + {payments.map((payment) => ( + + +
+

{payment.course?.title || 'Курс удалён'}

+ + {payment.mode} • {payment.provider} + +
+

+ {payment.user?.name || payment.user?.email} • {payment.amount} {payment.currency} • {payment.status} +

+ {payment.eventCode ? ( +

Событие: {payment.eventCode}

) : null} +
+
+ ))} - -
- - - ))} - - {!loading && courses.length === 0 ? ( - - - Курсы по заданным фильтрам не найдены. - - - ) : null} - + {!paymentsLoading && payments.length === 0 ? ( + + Платежи не найдены. + + ) : null} + + ) : null} ); } diff --git a/apps/web/src/app/cooperation/page.tsx b/apps/web/src/app/cooperation/page.tsx new file mode 100644 index 0000000..03b2943 --- /dev/null +++ b/apps/web/src/app/cooperation/page.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState } from 'react'; +import { Header } from '@/components/landing/header'; +import { Footer } from '@/components/landing/footer'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { api } from '@/lib/api'; + +export default function CooperationPage() { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(null); + const [form, setForm] = useState({ + organization: '', + contactName: '', + email: '', + phone: '', + role: '', + organizationType: '', + message: '', + }); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); + setSuccess(null); + try { + const result = await api.submitCooperationRequest({ + organization: form.organization, + contactName: form.contactName, + email: form.email, + phone: form.phone || undefined, + role: form.role || undefined, + organizationType: form.organizationType || undefined, + message: form.message, + }); + setSuccess(result?.status === 'stored_and_sent' ? 'Заявка отправлена. Мы свяжемся с вами.' : 'Заявка сохранена. Мы свяжемся с вами по почте.'); + setForm({ + organization: '', + contactName: '', + email: '', + phone: '', + role: '', + organizationType: '', + message: '', + }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+

Сотрудничество

+

+ Предоставляем платформу для вузов, школ, колледжей и компаний по договорённости: + запуск внутренних академий, каталогов курсов, трекинг прогресса и поддержка авторов. +

+
+ +
+ + + Что можем предоставить + + +

1. White-label платформу с вашей айдентикой.

+

2. Инструменты для авторов и методистов.

+

3. Проверку контента, модерацию и аналитику обучения.

+

4. Корпоративные группы, чаты и домашние задания.

+

5. Интеграцию с процессами вашей организации.

+
+
+ + + + Оставить заявку + + +
+ setForm((prev) => ({ ...prev, organization: e.target.value }))} + className="h-10 w-full rounded-lg border bg-background px-3 text-sm" + placeholder="Организация" + required + /> + setForm((prev) => ({ ...prev, contactName: e.target.value }))} + className="h-10 w-full rounded-lg border bg-background px-3 text-sm" + placeholder="Контактное лицо" + required + /> + setForm((prev) => ({ ...prev, email: e.target.value }))} + className="h-10 w-full rounded-lg border bg-background px-3 text-sm" + placeholder="Email" + required + /> + setForm((prev) => ({ ...prev, phone: e.target.value }))} + className="h-10 w-full rounded-lg border bg-background px-3 text-sm" + placeholder="Телефон (необязательно)" + /> +