Compare commits

...

1 Commits

54 changed files with 2687 additions and 318 deletions

View File

@ -37,6 +37,7 @@ AI_MODEL_DEFAULT="openai/gpt-4o-mini"
STRIPE_SECRET_KEY="sk_test_..." STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..." STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
PAYMENT_MODE="PROD" # DEV | PROD
# Stripe Price IDs # Stripe Price IDs
STRIPE_PRICE_PREMIUM="price_..." STRIPE_PRICE_PREMIUM="price_..."
@ -53,6 +54,15 @@ S3_SECRET_ACCESS_KEY="your-secret-key"
S3_BUCKET_NAME="coursecraft" S3_BUCKET_NAME="coursecraft"
S3_REGION="auto" 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) # App URLs (API на 3125; веб — свой порт, напр. 3000)
NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="http://localhost:3125" NEXT_PUBLIC_API_URL="http://localhost:3125"

View File

@ -137,53 +137,50 @@ export class OpenRouterProvider {
model: string model: string
): Promise<ClarifyingQuestions> { ): Promise<ClarifyingQuestions> {
log.request('generateClarifyingQuestions', model); log.request('generateClarifyingQuestions', model);
log.info(`User prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`); log.info(`Using structured onboarding quiz for prompt: "${prompt.substring(0, 120)}${prompt.length > 120 ? '...' : ''}"`);
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
чтобы лучше понять его потребности и создать максимально релевантный курс.
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса (важно для длины): const structured = {
- Короткий (3-4 главы, по 2-4 урока — только введение в тему) questions: [
- Средний (5-7 глав, по 4-6 уроков — полноценное покрытие) {
- Длинный / полный (7-12 глав, по 5-8 уроков — глубокое погружение, рекомендуемый вариант для серьёзного обучения) 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,
},
],
};
Остальные вопросы: целевая аудитория, глубина материала, специфические темы. const validated = ClarifyingQuestionsSchema.parse(structured);
log.success(`Generated ${validated.questions.length} structured onboarding questions`);
Ответь в формате JSON.`; return validated;
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');
} }
async generateCourseOutline( async generateCourseOutline(
@ -225,9 +222,22 @@ export class OpenRouterProvider {
"tags": ["тег1", "тег2"] "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}" const userMessage = `Запрос: "${prompt}"
Ответы пользователя на уточняющие вопросы: Структурированные ответы:
- Аудитория: ${audience || 'не указано'}
- Формат: ${format || 'не указано'}
- Цель: ${goal || 'не указано'}
- Объём: ${volume || 'не указано'}
- Доп. пожелания: ${notes || 'нет'}
Сырой набор ответов:
${Object.entries(answers) ${Object.entries(answers)
.map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`) .map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
.join('\n')}`; .join('\n')}`;

View File

@ -34,8 +34,10 @@
"helmet": "^7.1.0", "helmet": "^7.1.0",
"ioredis": "^5.3.0", "ioredis": "^5.3.0",
"meilisearch": "^0.37.0", "meilisearch": "^0.37.0",
"nodemailer": "^6.10.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-parse": "^1.1.1",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
@ -48,7 +50,9 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdf-parse": "^1.1.5",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",

View File

@ -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<any> {
return this.adminService.getPayments(user.id, { mode, provider, status, search, limit });
}
}

View File

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

View File

@ -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<any> {
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,
});
}
}

View File

@ -15,6 +15,8 @@ import { GenerationModule } from './generation/generation.module';
import { PaymentsModule } from './payments/payments.module'; import { PaymentsModule } from './payments/payments.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { PrismaModule } from './common/prisma/prisma.module'; import { PrismaModule } from './common/prisma/prisma.module';
import { AdminModule } from './admin/admin.module';
import { CooperationModule } from './cooperation/cooperation.module';
@Module({ @Module({
imports: [ imports: [
@ -53,6 +55,8 @@ import { PrismaModule } from './common/prisma/prisma.module';
GenerationModule, GenerationModule,
PaymentsModule, PaymentsModule,
SearchModule, SearchModule,
AdminModule,
CooperationModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -104,13 +104,26 @@ export class CatalogService {
throw new ForbiddenException('Only course author can submit for moderation'); 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 }, where: { id: courseId },
data: { data: {
status: CourseStatus.PENDING_REVIEW, status: CourseStatus.PENDING_MODERATION,
isPublished: false, 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<any> { async createCourseCheckout(courseId: string, userId: string): Promise<any> {

View File

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

View File

@ -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<UserRole> {
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<void> {
const role = await this.getUserRole(userId);
if (role !== UserRole.MODERATOR && role !== UserRole.ADMIN) {
throw new ForbiddenException('Staff access only');
}
}
async assertAdmin(userId: string): Promise<void> {
const role = await this.getUserRole(userId);
if (role !== UserRole.ADMIN) {
throw new ForbiddenException('Admin access only');
}
}
async assertCourseOwner(courseId: string, userId: string): Promise<void> {
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<void> {
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);
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

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

View File

@ -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<string>('SMTP_HOST');
const portRaw = this.config.get<string>('SMTP_PORT');
const user = this.config.get<string>('SMTP_USER');
const pass = this.config.get<string>('SMTP_PASS');
const secureRaw = this.config.get<string>('SMTP_SECURE');
const to = this.config.get<string>('COOPERATION_EMAIL_TO') || 'exbytestudios@gmail.com';
const from = this.config.get<string>('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,
});
}
}

View File

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

View File

@ -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<any> {
return this.sourcesService.uploadSource(courseId, user.id, file);
}
@Get()
async list(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.sourcesService.getSources(courseId, user.id);
}
@Get('outline-hints')
async getOutlineHints(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.sourcesService.buildOutlineHints(courseId, user.id);
}
}

View File

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

View File

@ -5,10 +5,14 @@ import { ChaptersController } from './chapters.controller';
import { ChaptersService } from './chapters.service'; import { ChaptersService } from './chapters.service';
import { LessonsController } from './lessons.controller'; import { LessonsController } from './lessons.controller';
import { LessonsService } from './lessons.service'; 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({ @Module({
controllers: [CoursesController, ChaptersController, LessonsController], imports: [AccessModule],
providers: [CoursesService, ChaptersService, LessonsService], controllers: [CoursesController, ChaptersController, LessonsController, CourseSourcesController],
exports: [CoursesService, ChaptersService, LessonsService], providers: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
exports: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
}) })
export class CoursesModule {} export class CoursesModule {}

View File

@ -12,7 +12,7 @@ export class CoursesService {
async create(authorId: string, dto: CreateCourseDto): Promise<Course> { async create(authorId: string, dto: CreateCourseDto): Promise<Course> {
const slug = generateUniqueSlug(dto.title); const slug = generateUniqueSlug(dto.title);
return this.prisma.course.create({ const created = await this.prisma.course.create({
data: { data: {
authorId, authorId,
title: dto.title, 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( async findAllByAuthor(
@ -222,16 +234,28 @@ export class CoursesService {
throw new ForbiddenException('You can only edit your own courses'); 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'); 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 }, where: { id },
data: updateData, 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<boolean> { async checkOwnership(courseId: string, userId: string): Promise<boolean> {

View File

@ -70,4 +70,15 @@ export class LessonsController {
async generateQuiz(@Param('lessonId') lessonId: string): Promise<any> { async generateQuiz(@Param('lessonId') lessonId: string): Promise<any> {
return this.lessonsService.generateQuiz(lessonId); 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<any> {
return this.lessonsService.generateHomework(courseId, lessonId, user.id, type);
}
} }

View File

@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { Lesson } from '@coursecraft/database'; import { HomeworkType, Lesson } from '@coursecraft/database';
import { CoursesService } from './courses.service'; import { CoursesService } from './courses.service';
import { ChaptersService } from './chapters.service'; import { ChaptersService } from './chapters.service';
import { CreateLessonDto } from './dto/create-lesson.dto'; 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<any> {
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<string, unknown>;
} {
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 },
};
}
} }

View File

@ -1,14 +1,33 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { export class SubmitHomeworkDto {
@ApiProperty({ @ApiProperty({
description: 'Written homework answer', description: 'Written homework answer',
minLength: 50, minLength: 1,
maxLength: 20000, maxLength: 20000,
}) })
@IsOptional()
@IsString() @IsString()
@MinLength(50) @MinLength(1)
@MaxLength(20000) @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;
} }

View File

@ -83,7 +83,7 @@ export class EnrollmentController {
@Body() dto: SubmitHomeworkDto, @Body() dto: SubmitHomeworkDto,
@CurrentUser() user: User, @CurrentUser() user: User,
): Promise<any> { ): Promise<any> {
return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto.content); return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto);
} }
@Post(':courseId/review') @Post(':courseId/review')

View File

@ -6,7 +6,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { HomeworkReviewStatus } from '@coursecraft/database'; import { HomeworkReviewStatus, HomeworkType } from '@coursecraft/database';
const QUIZ_PASS_THRESHOLD = 70; const QUIZ_PASS_THRESHOLD = 70;
@ -172,7 +172,12 @@ export class EnrollmentService {
return { homework, submission }; return { homework, submission };
} }
async submitHomework(userId: string, courseId: string, lessonId: string, content: string): Promise<any> { async submitHomework(
userId: string,
courseId: string,
lessonId: string,
dto: { content?: string; type?: HomeworkType; attachmentUrl?: string; githubUrl?: string }
): Promise<any> {
const enrollment = await this.requireEnrollment(userId, courseId); const enrollment = await this.requireEnrollment(userId, courseId);
await this.assertLessonUnlocked(userId, courseId, lessonId); await this.assertLessonUnlocked(userId, courseId, lessonId);
@ -184,20 +189,36 @@ export class EnrollmentService {
} }
const { homework } = await this.getHomework(userId, courseId, lessonId); 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({ const submission = await this.prisma.homeworkSubmission.upsert({
where: { homeworkId_userId: { homeworkId: homework.id, userId } }, where: { homeworkId_userId: { homeworkId: homework.id, userId } },
create: { create: {
homeworkId: homework.id, homeworkId: homework.id,
userId, userId,
content, content: fallbackContent,
answerType: submissionType,
attachmentUrl: dto.attachmentUrl || null,
githubUrl: dto.githubUrl || null,
aiScore: aiResult.score, aiScore: aiResult.score,
aiFeedback: aiResult.feedback, aiFeedback: aiResult.feedback,
reviewStatus: HomeworkReviewStatus.AI_REVIEWED, reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
}, },
update: { update: {
content, content: fallbackContent,
answerType: submissionType,
attachmentUrl: dto.attachmentUrl || null,
githubUrl: dto.githubUrl || null,
aiScore: aiResult.score, aiScore: aiResult.score,
aiFeedback: aiResult.feedback, aiFeedback: aiResult.feedback,
reviewStatus: HomeworkReviewStatus.AI_REVIEWED, reviewStatus: HomeworkReviewStatus.AI_REVIEWED,

View File

@ -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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
@ -31,13 +31,21 @@ export class GroupsController {
} }
@Get(':groupId/messages') @Get(':groupId/messages')
async getMessages(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> { async getMessages(
return this.groupsService.getGroupMessages(groupId, user.id); @Param('groupId') groupId: string,
@Query('lessonId') lessonId: string | undefined,
@CurrentUser() user: User
): Promise<any> {
return this.groupsService.getGroupMessages(groupId, user.id, lessonId);
} }
@Post(':groupId/messages') @Post(':groupId/messages')
async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> { async sendMessage(
return this.groupsService.sendMessage(groupId, user.id, content); @Param('groupId') groupId: string,
@Body() body: { content: string; lessonId?: string },
@CurrentUser() user: User
): Promise<any> {
return this.groupsService.sendMessage(groupId, user.id, body.content, body.lessonId);
} }
@Post(':groupId/invite-link') @Post(':groupId/invite-link')

View File

@ -65,12 +65,12 @@ export class GroupsGateway implements OnGatewayConnection {
@SubscribeMessage('groups:send') @SubscribeMessage('groups:send')
async sendMessage( async sendMessage(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() body: { groupId: string; content: string } @MessageBody() body: { groupId: string; content: string; lessonId?: string }
) { ) {
const user = client.data.user; const user = client.data.user;
if (!user || !body?.groupId || !body?.content?.trim()) return { ok: false }; 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); this.server.to(this.room(body.groupId)).emit('groups:new-message', message);
return { ok: true, message }; return { ok: true, message };
} }

View File

@ -3,9 +3,10 @@ import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { GroupsGateway } from './groups.gateway'; import { GroupsGateway } from './groups.gateway';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { AccessModule } from '../common/access/access.module';
@Module({ @Module({
imports: [UsersModule], imports: [UsersModule, AccessModule],
controllers: [GroupsController], controllers: [GroupsController],
providers: [GroupsService, GroupsGateway], providers: [GroupsService, GroupsGateway],
exports: [GroupsService], exports: [GroupsService],

View File

@ -1,13 +1,16 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { AccessService } from '../common/access/access.service';
@Injectable() @Injectable()
export class GroupsService { 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<any> { async createGroup(courseId: string, userId: string, name: string, description?: string): Promise<any> {
const course = await this.prisma.course.findFirst({ where: { id: courseId, authorId: userId } }); await this.access.assertCourseOwner(courseId, userId);
if (!course) throw new ForbiddenException('Only course author can create groups');
return this.prisma.courseGroup.create({ return this.prisma.courseGroup.create({
data: { courseId, name, description }, data: { courseId, name, description },
@ -71,22 +74,25 @@ export class GroupsService {
}); });
} }
async getGroupMessages(groupId: string, userId: string): Promise<any> { async getGroupMessages(groupId: string, userId: string, lessonId?: string): Promise<any> {
await this.assertCanReadGroup(groupId, userId); await this.assertCanReadGroup(groupId, userId);
return this.prisma.groupMessage.findMany({ return this.prisma.groupMessage.findMany({
where: { groupId }, where: {
groupId,
...(lessonId ? { lessonId } : {}),
},
include: { user: { select: { id: true, name: true, avatarUrl: true } } }, include: { user: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
take: 200, take: 200,
}); });
} }
async sendMessage(groupId: string, userId: string, content: string): Promise<any> { async sendMessage(groupId: string, userId: string, content: string, lessonId?: string): Promise<any> {
await this.assertCanReadGroup(groupId, userId); await this.assertCanReadGroup(groupId, userId);
return this.prisma.groupMessage.create({ 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 } } }, include: { user: { select: { id: true, name: true, avatarUrl: true } } },
}); });
} }
@ -97,9 +103,7 @@ export class GroupsService {
include: { course: { select: { authorId: true } } }, include: { course: { select: { authorId: true } } },
}); });
if (!group) throw new NotFoundException('Group not found'); if (!group) throw new NotFoundException('Group not found');
if (group.course.authorId !== userId) { if (group.course.authorId !== userId) throw new ForbiddenException('Only course author can create invite links');
throw new ForbiddenException('Only course author can create invite links');
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3080'; const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3080';
return { return {

View File

@ -49,4 +49,18 @@ export class ModerationController {
async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<void> { async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<void> {
await this.moderationService.deleteCourse(user.id, courseId); await this.moderationService.deleteCourse(user.id, courseId);
} }
@Get(':courseId/preview')
async previewCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
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<any> {
return this.moderationService.previewQuiz(user.id, courseId, body);
}
} }

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ModerationController } from './moderation.controller'; import { ModerationController } from './moderation.controller';
import { ModerationService } from './moderation.service'; import { ModerationService } from './moderation.service';
import { AccessModule } from '../common/access/access.module';
@Module({ @Module({
imports: [AccessModule],
controllers: [ModerationController], controllers: [ModerationController],
providers: [ModerationService], providers: [ModerationService],
exports: [ModerationService], exports: [ModerationService],

View File

@ -1,19 +1,21 @@
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { CourseStatus } from '@coursecraft/database';
import { PrismaService } from '../common/prisma/prisma.service'; 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() @Injectable()
export class ModerationService { export class ModerationService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private access: AccessService,
) {}
async getPendingCourses(userId: string): Promise<any> { async getPendingCourses(userId: string): Promise<any> {
const user = await this.prisma.user.findUnique({ where: { id: userId } }); await this.access.assertStaff(userId);
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
throw new ForbiddenException('Moderators only');
}
return this.prisma.course.findMany({ return this.prisma.course.findMany({
where: { status: CourseStatus.PENDING_REVIEW }, where: { status: { in: COURSE_PENDING_STATUSES } },
include: { include: {
author: { select: { id: true, name: true, email: true } }, author: { select: { id: true, name: true, email: true } },
_count: { select: { chapters: true } }, _count: { select: { chapters: true } },
@ -29,21 +31,27 @@ export class ModerationService {
search?: string; search?: string;
} }
): Promise<any[]> { ): Promise<any[]> {
await this.assertStaff(userId); await this.access.assertStaff(userId);
const allowedStatuses = Object.values(CourseStatus); const allowedStatuses = Object.values(CourseStatus);
const where: any = {}; const where: any = {};
if (options?.status && allowedStatuses.includes(options.status as CourseStatus)) { 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()) { if (options?.search?.trim()) {
const term = options.search.trim();
where.OR = [ where.OR = [
{ title: { contains: options.search.trim(), mode: 'insensitive' } }, { title: { contains: term, mode: 'insensitive' } },
{ description: { contains: options.search.trim(), mode: 'insensitive' } }, { description: { contains: term, mode: 'insensitive' } },
{ author: { name: { contains: options.search.trim(), mode: 'insensitive' } } }, { author: { name: { contains: term, mode: 'insensitive' } } },
{ author: { email: { contains: options.search.trim(), mode: 'insensitive' } } }, { author: { email: { contains: term, mode: 'insensitive' } } },
]; ];
} }
@ -65,52 +73,69 @@ export class ModerationService {
} }
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> { async approveCourse(userId: string, courseId: string, note?: string): Promise<any> {
const user = await this.prisma.user.findUnique({ where: { id: userId } }); await this.access.assertStaff(userId);
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
throw new ForbiddenException('Moderators only');
}
const course = await this.prisma.course.findUnique({ const course = await this.prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
select: { status: true }, select: { id: true, status: true },
}); });
if (!course) { if (!course) {
throw new ForbiddenException('Course not found'); throw new NotFoundException('Course not found');
} }
if (course.status !== CourseStatus.PENDING_REVIEW) { if (!isPendingModeration(course.status)) {
throw new ForbiddenException('Only courses pending review can be approved'); 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 }, where: { id: courseId },
data: { data: {
status: CourseStatus.PUBLISHED, status: CourseStatus.PUBLISHED,
isPublished: true, isPublished: true,
publishedAt: new Date(), publishedAt: now,
moderationNote: note, 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<any> { async rejectCourse(userId: string, courseId: string, reason: string): Promise<any> {
const user = await this.prisma.user.findUnique({ where: { id: userId } }); await this.access.assertStaff(userId);
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
throw new ForbiddenException('Moderators only');
}
const course = await this.prisma.course.findUnique({ const course = await this.prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
select: { status: true }, select: { id: true, status: true },
}); });
if (!course) { if (!course) {
throw new ForbiddenException('Course not found'); throw new NotFoundException('Course not found');
} }
if (course.status !== CourseStatus.PENDING_REVIEW) { if (!isPendingModeration(course.status)) {
throw new ForbiddenException('Only courses pending review can be rejected'); 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 }, where: { id: courseId },
data: { data: {
status: CourseStatus.REJECTED, status: CourseStatus.REJECTED,
@ -118,10 +143,97 @@ export class ModerationService {
moderatedAt: new Date(), 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<any> {
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<any> {
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<any> { async hideReview(userId: string, reviewId: string): Promise<any> {
await this.assertStaff(userId); await this.access.assertStaff(userId);
const review = await this.prisma.review.update({ const review = await this.prisma.review.update({
where: { id: reviewId }, where: { id: reviewId },
data: { isApproved: false }, data: { isApproved: false },
@ -131,7 +243,7 @@ export class ModerationService {
} }
async unhideReview(userId: string, reviewId: string): Promise<any> { async unhideReview(userId: string, reviewId: string): Promise<any> {
await this.assertStaff(userId); await this.access.assertStaff(userId);
const review = await this.prisma.review.update({ const review = await this.prisma.review.update({
where: { id: reviewId }, where: { id: reviewId },
data: { isApproved: true }, data: { isApproved: true },
@ -141,7 +253,7 @@ export class ModerationService {
} }
async deleteCourse(userId: string, courseId: string): Promise<void> { async deleteCourse(userId: string, courseId: string): Promise<void> {
await this.assertStaff(userId); await this.access.assertStaff(userId);
const existing = await this.prisma.course.findUnique({ const existing = await this.prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
select: { id: true }, select: { id: true },
@ -153,13 +265,6 @@ export class ModerationService {
await this.prisma.course.delete({ where: { id: courseId } }); await this.prisma.course.delete({ where: { id: courseId } });
} }
private async assertStaff(userId: string): Promise<void> {
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<void> { private async recalculateAverageRating(courseId: string): Promise<void> {
const result = await this.prisma.review.aggregate({ const result = await this.prisma.review.aggregate({
where: { courseId, isApproved: true }, where: { courseId, isApproved: true },

View File

@ -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);
}
}

View File

@ -3,9 +3,10 @@ import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service'; import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service'; import { StripeService } from './stripe.service';
import { WebhooksController } from './webhooks.controller'; import { WebhooksController } from './webhooks.controller';
import { DevPaymentsController } from './dev-payments.controller';
@Module({ @Module({
controllers: [PaymentsController, WebhooksController], controllers: [PaymentsController, WebhooksController, DevPaymentsController],
providers: [PaymentsService, StripeService], providers: [PaymentsService, StripeService],
exports: [PaymentsService, StripeService], exports: [PaymentsService, StripeService],
}) })

View File

@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { StripeService } from './stripe.service'; import { StripeService } from './stripe.service';
import { SubscriptionTier } from '@coursecraft/database'; import { PaymentMode, PaymentProvider, SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared'; import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
@Injectable() @Injectable()
@ -78,6 +78,9 @@ export class PaymentsService {
if (!course) { if (!course) {
throw new NotFoundException('Course not found'); throw new NotFoundException('Course not found');
} }
if (!course.isPublished) {
throw new ForbiddenException('Course is not available for purchase');
}
if (!course.price) { if (!course.price) {
throw new Error('Course is free, checkout is not required'); throw new Error('Course is free, checkout is not required');
} }
@ -85,8 +88,26 @@ export class PaymentsService {
throw new Error('Course is already purchased'); throw new Error('Course is already purchased');
} }
const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000'; const appUrl = this.configService.get<string>('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 unitAmount = Math.round(Number(course.price) * 100);
const session = await this.stripeService.createOneTimeCheckoutSession({ 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) { async createPortalSession(userId: string) {
@ -169,6 +206,9 @@ export class PaymentsService {
await this.handleCoursePurchaseCompleted({ await this.handleCoursePurchaseCompleted({
userId: metadata.userId, userId: metadata.userId,
courseId: metadata.courseId || '', courseId: metadata.courseId || '',
provider: PaymentProvider.STRIPE,
mode: PaymentMode.PROD,
eventCode: 'STRIPE_PAYMENT_SUCCESS',
}); });
return; 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; const { userId, courseId } = params;
if (!courseId) return; if (!courseId) return;
@ -219,11 +265,27 @@ export class PaymentsService {
amount: course.price, amount: course.price,
currency: course.currency, currency: course.currency,
status: 'completed', 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: { update: {
status: 'completed', status: 'completed',
amount: course.price, amount: course.price,
currency: course.currency, 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<string>('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 }> { private async getOrCreateStripeCustomer(userId: string): Promise<{ stripeCustomerId: string; email: string; name: string | null }> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },

View File

@ -3,9 +3,10 @@ import { SupportController } from './support.controller';
import { SupportService } from './support.service'; import { SupportService } from './support.service';
import { SupportGateway } from './support.gateway'; import { SupportGateway } from './support.gateway';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { AccessModule } from '../common/access/access.module';
@Module({ @Module({
imports: [UsersModule], imports: [UsersModule, AccessModule],
controllers: [SupportController], controllers: [SupportController],
providers: [SupportService, SupportGateway], providers: [SupportService, SupportGateway],
exports: [SupportService], exports: [SupportService],

View File

@ -1,10 +1,14 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { UserRole } from '@coursecraft/database'; import { UserRole } from '@coursecraft/database';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { AccessService } from '../common/access/access.service';
@Injectable() @Injectable()
export class SupportService { export class SupportService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private access: AccessService,
) {}
async createTicket( async createTicket(
userId: string, userId: string,
@ -150,12 +154,6 @@ export class SupportService {
} }
private async assertStaff(userId: string): Promise<void> { private async assertStaff(userId: string): Promise<void> {
const user = await this.prisma.user.findUnique({ await this.access.assertStaff(userId);
where: { id: userId },
select: { role: true },
});
if (!user || (user.role !== UserRole.ADMIN && user.role !== UserRole.MODERATOR)) {
throw new ForbiddenException('Staff access only');
}
} }
} }

View File

@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; 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 { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateSettingsDto } from './dto/update-settings.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<User> {
return this.prisma.user.update({
where: { id: userId },
data: { role },
include: { settings: true, subscription: true },
});
}
} }

View File

@ -4,15 +4,20 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { import {
CheckSquare,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Eye, Eye,
FileArchive,
FileText, FileText,
FolderOpen,
ListChecks,
Layers3, Layers3,
Lock, Lock,
Save, Save,
Settings2, Settings2,
Shield, Shield,
Sparkles,
Upload, Upload,
Wallet, Wallet,
} from 'lucide-react'; } from 'lucide-react';
@ -35,7 +40,7 @@ type CourseData = {
coverImage?: string | null; coverImage?: string | null;
price?: number | null; price?: number | null;
currency?: string; currency?: string;
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string; status: 'DRAFT' | 'PENDING_MODERATION' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
moderationNote?: string | null; moderationNote?: string | null;
difficulty?: string | null; difficulty?: string | null;
estimatedHours?: number | null; estimatedHours?: number | null;
@ -43,12 +48,15 @@ type CourseData = {
chapters: Chapter[]; 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 emptyDoc = { type: 'doc', content: [] };
const tabs: { key: EditTab; label: string; icon: any }[] = [ const tabs: { key: EditTab; label: string; icon: any }[] = [
{ key: 'general', label: 'Общая информация', icon: FileText }, { key: 'general', label: 'Общая информация', icon: FileText },
{ key: 'content', label: 'Контент', icon: Layers3 }, { 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: 'pricing', label: 'Цены', icon: Wallet },
{ key: 'settings', label: 'Настройки', icon: Settings2 }, { key: 'settings', label: 'Настройки', icon: Settings2 },
{ key: 'access', label: 'Доступ', icon: Lock }, { key: 'access', label: 'Доступ', icon: Lock },
@ -84,6 +92,13 @@ export default function CourseEditPage() {
const [courseDifficulty, setCourseDifficulty] = useState(''); const [courseDifficulty, setCourseDifficulty] = useState('');
const [courseEstimatedHours, setCourseEstimatedHours] = useState(''); const [courseEstimatedHours, setCourseEstimatedHours] = useState('');
const [courseTags, setCourseTags] = useState(''); const [courseTags, setCourseTags] = useState('');
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
const [quizGenerated, setQuizGenerated] = useState(false);
const [materials, setMaterials] = useState<any[]>([]);
const [outlineHints, setOutlineHints] = useState<any[]>([]);
const [uploadingSource, setUploadingSource] = useState(false);
const [generatingHomework, setGeneratingHomework] = useState(false);
const [generatingQuiz, setGeneratingQuiz] = useState(false);
useEffect(() => { useEffect(() => {
if (!courseId || authLoading) return; if (!courseId || authLoading) return;
@ -156,6 +171,11 @@ export default function CourseEditPage() {
}; };
}, [courseId, activeLesson?.lessonId]); }, [courseId, activeLesson?.lessonId]);
useEffect(() => {
if (activeTab !== 'materials') return;
loadMaterials();
}, [activeTab, courseId]);
const handleSelectLesson = (lessonId: string) => { const handleSelectLesson = (lessonId: string) => {
if (!course) return; if (!course) return;
for (const chapter of course.chapters) { 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) { if (authLoading || loading) {
return ( return (
<div className="flex min-h-[400px] items-center justify-center"> <div className="flex min-h-[400px] items-center justify-center">
@ -376,6 +455,123 @@ export default function CourseEditPage() {
</Card> </Card>
)} )}
{activeTab === 'quiz' && (
<Card>
<CardHeader>
<CardTitle>Тест урока</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Выберите урок во вкладке «Контент», затем сгенерируйте тест в один клик.
</p>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<p className="font-medium">Текущий урок:</p>
<p className="text-muted-foreground">{activeLessonMeta?.title || 'Урок не выбран'}</p>
</div>
<Button onClick={handleGenerateQuiz} disabled={!activeLesson || generatingQuiz}>
<Sparkles className="mr-2 h-4 w-4" />
{generatingQuiz ? 'Генерация...' : 'Сгенерировать тест'}
</Button>
{quizGenerated ? (
<p className="text-sm text-emerald-600">Тест доступен студентам в режиме обучения.</p>
) : null}
</CardContent>
</Card>
)}
{activeTab === 'homework' && (
<Card>
<CardHeader>
<CardTitle>Домашнее задание</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Типы: Текстовый ответ, Файл, Проект, Тест, GitHub ссылка.
</p>
<div className="flex flex-wrap gap-2">
<select
value={homeworkType}
onChange={(e) => setHomeworkType(e.target.value as any)}
className="h-10 rounded-md border bg-background px-3 text-sm"
>
<option value="TEXT">Текстовый ответ</option>
<option value="FILE">Файл</option>
<option value="PROJECT">Проект</option>
<option value="QUIZ">Тест</option>
<option value="GITHUB">GitHub ссылка</option>
</select>
<Button onClick={handleGenerateHomework} disabled={!activeLesson || generatingHomework}>
<FileArchive className="mr-2 h-4 w-4" />
{generatingHomework ? 'Генерация...' : ' Добавить ДЗ'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
ДЗ создаётся для выбранного урока: {activeLessonMeta?.title || 'урок не выбран'}.
</p>
</CardContent>
</Card>
)}
{activeTab === 'materials' && (
<Card>
<CardHeader>
<CardTitle>Дополнительные материалы и источники</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
<p className="font-medium">Поддержка форматов</p>
<p className="text-muted-foreground mt-1">PDF, DOCX, TXT, PPTX, изображения, ZIP</p>
<p className="text-xs text-muted-foreground mt-1">
В фазе 1: PDF/TXT анализируются для структуры, остальные сохраняются как вложения.
</p>
</div>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<Upload className="h-4 w-4" />
{uploadingSource ? 'Загрузка...' : 'Загрузить источник'}
<input
type="file"
className="hidden"
accept=".pdf,.docx,.txt,.pptx,.zip,image/*"
onChange={handleUploadSource}
disabled={uploadingSource}
/>
</label>
<div className="space-y-2">
<p className="text-sm font-medium">Загруженные файлы</p>
{materials.length === 0 ? (
<p className="text-sm text-muted-foreground">Пока нет загруженных материалов.</p>
) : (
<div className="space-y-2">
{materials.map((file) => (
<div key={file.id} className="rounded-md border p-2 text-sm">
<p className="font-medium">{file.fileName}</p>
<p className="text-xs text-muted-foreground">
{file.sourceType} {file.parseStatus}
</p>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Предложенная структура из источников</p>
{outlineHints.length === 0 ? (
<p className="text-sm text-muted-foreground">Пока нет рекомендаций. Добавьте PDF/TXT.</p>
) : (
<ul className="list-disc pl-5 text-sm space-y-1">
{outlineHints.map((hint: any) => (
<li key={hint.id}>{hint.title}</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
)}
{activeTab === 'pricing' && ( {activeTab === 'pricing' && (
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -12,6 +12,7 @@ import {
ChevronRight, ChevronRight,
Clock, Clock,
Edit, Edit,
FilePlus2,
GraduationCap, GraduationCap,
Lock, Lock,
Play, Play,
@ -32,6 +33,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer'; import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonQuiz } from '@/components/dashboard/lesson-quiz'; import { LessonQuiz } from '@/components/dashboard/lesson-quiz';
import { LessonChatPanel } from '@/components/dashboard/lesson-chat-panel';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -53,7 +55,7 @@ type LessonProgressRow = {
}; };
type HomeworkState = { 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; 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 [homeworkLoading, setHomeworkLoading] = useState(false);
const [homeworkSubmitting, setHomeworkSubmitting] = useState(false); const [homeworkSubmitting, setHomeworkSubmitting] = useState(false);
const [homeworkContent, setHomeworkContent] = useState(''); const [homeworkContent, setHomeworkContent] = useState('');
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
const [groupId, setGroupId] = useState<string | null>(null);
const [activeLessonPanel, setActiveLessonPanel] = useState<'content' | 'quiz' | 'homework' | 'materials'>('content');
const flatLessons = useMemo(() => { const flatLessons = useMemo(() => {
if (!course) return []; if (!course) return [];
@ -156,7 +161,13 @@ export default function CoursePage() {
setCourse(courseData); setCourse(courseData);
setExpandedChapters((courseData.chapters || []).map((ch: Chapter) => ch.id)); 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 || []) const ordered = (courseData.chapters || [])
.sort((a: Chapter, b: Chapter) => a.order - b.order) .sort((a: Chapter, b: Chapter) => a.order - b.order)
@ -195,6 +206,7 @@ export default function CoursePage() {
setLessonContentLoading(true); setLessonContentLoading(true);
setShowQuiz(false); setShowQuiz(false);
setQuizQuestions([]); setQuizQuestions([]);
setActiveLessonPanel('content');
(async () => { (async () => {
try { try {
@ -277,7 +289,10 @@ export default function CoursePage() {
if (!id || !selectedLessonId || !homeworkContent.trim() || homeworkSubmitting) return; if (!id || !selectedLessonId || !homeworkContent.trim() || homeworkSubmitting) return;
setHomeworkSubmitting(true); setHomeworkSubmitting(true);
try { 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 })); setHomework((prev) => ({ ...prev, submission }));
await refreshProgress(id); await refreshProgress(id);
} finally { } 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 = () => { const goToPrevLesson = () => {
if (currentLessonIndex <= 0) return; if (currentLessonIndex <= 0) return;
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id); setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
@ -514,33 +535,96 @@ export default function CoursePage() {
</div> </div>
) : selectedLessonId ? ( ) : selectedLessonId ? (
<> <>
<LessonContentViewer content={lessonContent} className="min-h-[320px]" /> <div className="mb-6 flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-2">
<Button
size="sm"
variant={activeLessonPanel === 'content' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('content')}
>
Контент
</Button>
<Button
size="sm"
variant={activeLessonPanel === 'quiz' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('quiz')}
>
Тест
</Button>
<Button
size="sm"
variant={activeLessonPanel === 'homework' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('homework')}
>
Домашнее задание
</Button>
<Button
size="sm"
variant={activeLessonPanel === 'materials' ? 'default' : 'ghost'}
onClick={() => setActiveLessonPanel('materials')}
>
Доп. материалы
</Button>
</div>
{!activeProgress?.quizPassed && ( {activeLessonPanel === 'content' ? (
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center"> <LessonContentViewer content={lessonContent} className="min-h-[320px]" />
<h3 className="font-semibold mb-2">Шаг 1 из 2: тест</h3> ) : null}
<p className="text-sm text-muted-foreground mb-4">
Для открытия следующего урока нужно пройти тест и отправить письменное ДЗ. {activeLessonPanel === 'quiz' ? (
</p> <div className="space-y-4">
<Button onClick={handleStartQuiz} disabled={quizLoading}> {!activeProgress?.quizPassed ? (
{quizLoading ? 'Загрузка теста...' : 'Начать тест'} <div className="p-6 border rounded-xl bg-muted/20 text-center">
</Button> <h3 className="font-semibold mb-2">Шаг 1 из 2: тест</h3>
<p className="text-sm text-muted-foreground mb-4">
Для открытия следующего урока пройдите тест.
</p>
<Button onClick={handleStartQuiz} disabled={quizLoading}>
{quizLoading ? 'Загрузка теста...' : 'Начать тест'}
</Button>
</div>
) : (
<div className="p-4 rounded-xl border bg-emerald-50 text-emerald-800">
Тест уже пройден. Можно переходить к домашнему заданию.
</div>
)}
{showQuiz ? (
<LessonQuiz
courseId={id}
lessonId={selectedLessonId}
questions={quizQuestions}
onComplete={handleQuizComplete}
/>
) : null}
</div> </div>
)} ) : null}
{showQuiz && ( {activeLessonPanel === 'homework' ? (
<LessonQuiz <div className="p-6 border rounded-xl bg-muted/20">
courseId={id} <div className="mb-4 flex flex-wrap items-center justify-between gap-2">
lessonId={selectedLessonId} <h3 className="font-semibold">Домашнее задание</h3>
questions={quizQuestions} {isAuthor ? (
onComplete={handleQuizComplete} <div className="flex items-center gap-2">
/> <select
)} value={homeworkType}
onChange={(e) => setHomeworkType(e.target.value as any)}
{activeProgress?.quizPassed && ( className="h-9 rounded-lg border bg-background px-2 text-sm"
<div className="mt-8 p-6 border rounded-xl bg-muted/20"> >
<h3 className="font-semibold mb-2">Шаг 2 из 2: письменное домашнее задание</h3> <option value="TEXT">Текстовый ответ</option>
{homeworkLoading ? ( <option value="FILE">Файл</option>
<option value="PROJECT">Проект</option>
<option value="QUIZ">Тест</option>
<option value="GITHUB">GitHub ссылка</option>
</select>
<Button variant="outline" size="sm" onClick={handleGenerateHomework}>
<FilePlus2 className="mr-2 h-4 w-4" />
Добавить ДЗ
</Button>
</div>
) : null}
</div>
{!activeProgress?.quizPassed ? (
<p className="text-sm text-muted-foreground">Сначала пройдите тест этого урока.</p>
) : homeworkLoading ? (
<p className="text-sm text-muted-foreground">Подготовка задания...</p> <p className="text-sm text-muted-foreground">Подготовка задания...</p>
) : ( ) : (
<> <>
@ -559,7 +643,7 @@ export default function CoursePage() {
disabled={Boolean(activeProgress?.homeworkSubmitted)} disabled={Boolean(activeProgress?.homeworkSubmitted)}
/> />
<div className="mt-3 flex items-center justify-between"> <div className="mt-3 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Минимум 50 символов</p> <p className="text-xs text-muted-foreground">Рекомендуется подробный ответ и примеры</p>
<Button <Button
onClick={handleSubmitHomework} onClick={handleSubmitHomework}
disabled={homeworkSubmitting || Boolean(activeProgress?.homeworkSubmitted)} disabled={homeworkSubmitting || Boolean(activeProgress?.homeworkSubmitted)}
@ -582,7 +666,14 @@ export default function CoursePage() {
</> </>
)} )}
</div> </div>
)} ) : null}
{activeLessonPanel === 'materials' ? (
<div className="p-6 rounded-xl border bg-muted/20 text-sm text-muted-foreground">
Дополнительные материалы для урока можно добавить в редакторе курса во вкладке
{' '}<span className="font-medium text-foreground">«Доп. материалы»</span>.
</div>
) : null}
</> </>
) : ( ) : (
<div className="text-muted-foreground">Выберите урок</div> <div className="text-muted-foreground">Выберите урок</div>
@ -613,6 +704,7 @@ export default function CoursePage() {
</div> </div>
</main> </main>
</div> </div>
<LessonChatPanel groupId={groupId} lessonId={selectedLessonId} userId={backendUser?.id || null} />
</div> </div>
); );
} }

View File

@ -11,7 +11,7 @@ import { cn } from '@/lib/utils';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
type Step = 'prompt' | 'questions' | 'generating' | 'complete' | 'error'; type Step = 'prompt' | 'questions' | 'recommendations' | 'generating' | 'complete' | 'error';
interface ClarifyingQuestion { interface ClarifyingQuestion {
id: string; id: string;
@ -34,6 +34,12 @@ export default function NewCoursePage() {
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [courseId, setCourseId] = useState<string | null>(null); const [courseId, setCourseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [aiRecommendation, setAiRecommendation] = useState<{
modules: number;
lessonFormat: string;
assignmentTypes: string[];
suggestedStructure: string[];
} | null>(null);
// Poll for generation status // Poll for generation status
const pollStatus = useCallback(async () => { const pollStatus = useCallback(async () => {
@ -74,7 +80,7 @@ export default function NewCoursePage() {
break; break;
default: default:
// Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT) // Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT)
if (step !== 'questions') { if (step !== 'questions' && step !== 'recommendations') {
setStep('generating'); setStep('generating');
} }
} }
@ -85,7 +91,7 @@ export default function NewCoursePage() {
// Start polling when we have a generation ID // Start polling when we have a generation ID
useEffect(() => { useEffect(() => {
if (!generationId || step === 'complete' || step === 'error' || step === 'questions') { if (!generationId || step === 'complete' || step === 'error' || step === 'questions' || step === 'recommendations') {
return; return;
} }
@ -132,13 +138,44 @@ export default function NewCoursePage() {
const handleSubmitAnswers = async () => { const handleSubmitAnswers = async () => {
if (!generationId || isSubmitting) return; 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); setIsSubmitting(true);
try { try {
await api.answerQuestions(generationId, answers); await api.answerQuestions(generationId, answers);
setStep('generating'); setStep('generating');
// Resume polling
setTimeout(pollStatus, 1000); setTimeout(pollStatus, 1000);
} catch (error: any) { } catch (error: any) {
toast({ toast({
@ -171,6 +208,7 @@ export default function NewCoursePage() {
setCurrentStepText(''); setCurrentStepText('');
setErrorMessage(''); setErrorMessage('');
setCourseId(null); setCourseId(null);
setAiRecommendation(null);
}; };
const allRequiredAnswered = questions const allRequiredAnswered = questions
@ -392,6 +430,68 @@ export default function NewCoursePage() {
</motion.div> </motion.div>
)} )}
{/* Step 3: Generating */}
{step === 'recommendations' && (
<motion.div
key="recommendations"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">AI-рекомендации перед генерацией</h3>
</div>
{aiRecommendation ? (
<div className="space-y-3 text-sm">
<div className="rounded-lg border bg-muted/30 p-3">
<p><span className="font-medium">Рекомендуемое число модулей:</span> {aiRecommendation.modules}</p>
<p><span className="font-medium">Формат уроков:</span> {aiRecommendation.lessonFormat}</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="font-medium mb-1">Типы заданий:</p>
<ul className="list-disc pl-5 space-y-1">
{aiRecommendation.assignmentTypes.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="font-medium mb-1">Рекомендованная структура:</p>
<ul className="list-decimal pl-5 space-y-1">
{aiRecommendation.suggestedStructure.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</div>
) : null}
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={() => setStep('questions')}>
Назад к вопросам
</Button>
<Button onClick={handleConfirmGeneration} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Запуск...
</>
) : (
<>
Подтвердить и создать курс
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 3: Generating */} {/* Step 3: Generating */}
{step === 'generating' && ( {step === 'generating' && (
<motion.div <motion.div

View File

@ -14,7 +14,15 @@ type Course = {
id: string; id: string;
title: string; title: string;
description: string | null; description: string | null;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING' | 'PENDING_REVIEW' | 'REJECTED'; status:
| 'DRAFT'
| 'PUBLISHED'
| 'ARCHIVED'
| 'GENERATING'
| 'PENDING_MODERATION'
| 'PENDING_REVIEW'
| 'REJECTED'
| 'APPROVED';
chaptersCount: number; chaptersCount: number;
lessonsCount: number; lessonsCount: number;
updatedAt: string; updatedAt: string;
@ -56,7 +64,9 @@ export default function DashboardPage() {
const stats = useMemo(() => { const stats = useMemo(() => {
const drafts = courses.filter((course) => course.status === 'DRAFT' || course.status === 'REJECTED'); const drafts = courses.filter((course) => course.status === 'DRAFT' || course.status === 'REJECTED');
const published = courses.filter((course) => course.status === 'PUBLISHED'); 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 { return {
drafts, drafts,
published, published,

View File

@ -2,7 +2,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -19,9 +29,30 @@ type ModerationCourse = {
_count?: { chapters?: number; enrollments?: number; reviews?: number }; _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 = [ const statusFilters = [
{ value: '', label: 'Все статусы' }, { value: '', label: 'Все статусы' },
{ value: 'PENDING_REVIEW', label: 'На проверке' }, { value: 'PENDING_MODERATION', label: 'На проверке' },
{ value: 'PUBLISHED', label: 'Опубликованные' }, { value: 'PUBLISHED', label: 'Опубликованные' },
{ value: 'REJECTED', label: 'Отклонённые' }, { value: 'REJECTED', label: 'Отклонённые' },
{ value: 'DRAFT', label: 'Черновики' }, { value: 'DRAFT', label: 'Черновики' },
@ -29,6 +60,7 @@ const statusFilters = [
const badgeMap: Record<string, string> = { const badgeMap: Record<string, string> = {
PENDING_REVIEW: 'bg-amber-100 text-amber-900', PENDING_REVIEW: 'bg-amber-100 text-amber-900',
PENDING_MODERATION: 'bg-amber-100 text-amber-900',
PUBLISHED: 'bg-green-100 text-green-900', PUBLISHED: 'bg-green-100 text-green-900',
REJECTED: 'bg-rose-100 text-rose-900', REJECTED: 'bg-rose-100 text-rose-900',
DRAFT: 'bg-slate-100 text-slate-900', DRAFT: 'bg-slate-100 text-slate-900',
@ -38,6 +70,8 @@ const badgeMap: Record<string, string> = {
export default function AdminPage() { export default function AdminPage() {
const { toast } = useToast(); const { toast } = useToast();
const [activeTab, setActiveTab] = useState<'courses' | 'users' | 'payments'>('courses');
const [courses, setCourses] = useState<ModerationCourse[]>([]); const [courses, setCourses] = useState<ModerationCourse[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [status, setStatus] = useState(''); const [status, setStatus] = useState('');
@ -45,10 +79,20 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actingId, setActingId] = useState<string | null>(null); const [actingId, setActingId] = useState<string | null>(null);
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [usersSearch, setUsersSearch] = useState('');
const [payments, setPayments] = useState<AdminPayment[]>([]);
const [paymentsLoading, setPaymentsLoading] = useState(false);
const [paymentSearch, setPaymentSearch] = useState('');
const [paymentMode, setPaymentMode] = useState('');
const loadCourses = async () => { const loadCourses = async () => {
setLoading(true); setLoading(true);
try { 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 || []); setCourses(data || []);
} catch (error: any) { } catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить курсы', variant: 'destructive' }); 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(() => { useEffect(() => {
loadCourses(); loadCourses();
}, [status]); }, [status]);
useEffect(() => {
if (activeTab === 'users') {
loadUsers();
}
if (activeTab === 'payments') {
loadPayments();
}
}, [activeTab]);
const stats = useMemo(() => { const stats = useMemo(() => {
return { return {
total: courses.length, 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, published: courses.filter((course) => course.status === 'PUBLISHED').length,
}; };
}, [courses]); }, [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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<section className="rounded-2xl border bg-background p-6"> <section className="rounded-2xl border bg-background p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Модерация курсов</h1> <h1 className="text-3xl font-bold tracking-tight">Админ Панель</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Проверка курсов, публикация, отклонение и удаление. Модерация курсов, поддержка, управление пользователями и платежами.
</p> </p>
</div> </div>
<Button variant="outline" asChild> <Button variant="outline" asChild>
@ -128,133 +220,255 @@ export default function AdminPage() {
</div> </div>
</section> </section>
<section className="grid gap-4 md:grid-cols-3"> <section className="flex flex-wrap gap-2">
<Card> <Button variant={activeTab === 'courses' ? 'default' : 'outline'} onClick={() => setActiveTab('courses')}>
<CardHeader className="pb-2"> Курсы
<CardTitle className="text-sm">Всего в выдаче</CardTitle> </Button>
</CardHeader> <Button variant={activeTab === 'users' ? 'default' : 'outline'} onClick={() => setActiveTab('users')}>
<CardContent className="text-2xl font-bold">{stats.total}</CardContent> <Users className="mr-2 h-4 w-4" />
</Card> Пользователи
<Card> </Button>
<CardHeader className="pb-2"> <Button variant={activeTab === 'payments' ? 'default' : 'outline'} onClick={() => setActiveTab('payments')}>
<CardTitle className="text-sm">Ожидают модерации</CardTitle> <CreditCard className="mr-2 h-4 w-4" />
</CardHeader> Платежи
<CardContent className="text-2xl font-bold">{stats.pending}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Опубликовано</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.published}</CardContent>
</Card>
</section>
<section className="flex flex-col gap-3 rounded-2xl border bg-card p-4 md:flex-row">
<label className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по курсам и авторам"
className="h-10 w-full rounded-xl border bg-background pl-10 pr-3 text-sm"
/>
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-10 rounded-xl border bg-background px-3 text-sm"
>
{statusFilters.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<Button onClick={loadCourses} disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Применить
</Button> </Button>
</section> </section>
<section className="space-y-3"> {activeTab === 'courses' ? (
{courses.map((course) => ( <>
<Card key={course.id} className="border-border/60"> <section className="grid gap-4 md:grid-cols-3">
<CardHeader> <Card>
<div className="flex flex-wrap items-start justify-between gap-2"> <CardHeader className="pb-2">
<div> <CardTitle className="text-sm">Всего в выдаче</CardTitle>
<CardTitle className="text-lg">{course.title}</CardTitle> </CardHeader>
<CardDescription> <CardContent className="text-2xl font-bold">{stats.total}</CardContent>
{course.author?.name || 'Без имени'} ({course.author?.email || '—'}) </Card>
</CardDescription> <Card>
</div> <CardHeader className="pb-2">
<span <CardTitle className="text-sm">Ожидают модерации</CardTitle>
className={cn( </CardHeader>
'rounded-full px-2.5 py-1 text-xs font-semibold', <CardContent className="text-2xl font-bold">{stats.pending}</CardContent>
badgeMap[course.status] || 'bg-muted text-muted-foreground' </Card>
)} <Card>
> <CardHeader className="pb-2">
{course.status} <CardTitle className="text-sm">Опубликовано</CardTitle>
</span> </CardHeader>
</div> <CardContent className="text-2xl font-bold">{stats.published}</CardContent>
</CardHeader> </Card>
</section>
<CardContent className="space-y-3">
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<p>Глав: {course._count?.chapters || 0}</p>
<p>Студентов: {course._count?.enrollments || 0}</p>
<p>Отзывов: {course._count?.reviews || 0}</p>
</div>
<section className="flex flex-col gap-3 rounded-2xl border bg-card p-4 md:flex-row">
<label className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
value={noteDraft[course.id] || ''} value={search}
onChange={(e) => setNoteDraft((prev) => ({ ...prev, [course.id]: e.target.value }))} onChange={(e) => setSearch(e.target.value)}
placeholder="Комментарий модерации" placeholder="Поиск по курсам и авторам"
className="h-10 w-full rounded-lg border bg-background px-3 text-sm" className="h-10 w-full rounded-xl border bg-background pl-10 pr-3 text-sm"
/> />
</label>
<div className="flex flex-wrap gap-2"> <select
{course.status === 'PENDING_REVIEW' ? ( value={status}
<> onChange={(e) => setStatus(e.target.value)}
<Button size="sm" onClick={() => approve(course.id)} disabled={actingId === course.id}> className="h-10 rounded-xl border bg-background px-3 text-sm"
<CheckCircle2 className="mr-2 h-4 w-4" /> >
Опубликовать {statusFilters.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<Button onClick={loadCourses} disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Применить
</Button>
</section>
<section className="space-y-3">
{courses.map((course) => (
<Card key={course.id} className="border-border/60">
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-lg">{course.title}</CardTitle>
<CardDescription>
{course.author?.name || 'Без имени'} ({course.author?.email || '—'})
</CardDescription>
</div>
<span
className={cn(
'rounded-full px-2.5 py-1 text-xs font-semibold',
badgeMap[course.status] || 'bg-muted text-muted-foreground'
)}
>
{course.status}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<p>Глав: {course._count?.chapters || 0}</p>
<p>Студентов: {course._count?.enrollments || 0}</p>
<p>Отзывов: {course._count?.reviews || 0}</p>
</div>
<div className="flex gap-2">
<input
value={noteDraft[course.id] || ''}
onChange={(e) => setNoteDraft((prev) => ({ ...prev, [course.id]: e.target.value }))}
placeholder="Комментарий модерации"
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
/>
<Button variant="outline" asChild>
<Link href={`/courses/${course.id}`} target="_blank">
Preview
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button> </Button>
</div>
<div className="flex flex-wrap gap-2">
{course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW' ? (
<>
<Button size="sm" onClick={() => approve(course.id)} disabled={actingId === course.id}>
<CheckCircle2 className="mr-2 h-4 w-4" />
Опубликовать
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reject(course.id)}
disabled={actingId === course.id}
>
<XCircle className="mr-2 h-4 w-4" />
Отклонить
</Button>
</>
) : null}
<Button <Button
size="sm" size="sm"
variant="outline" variant="destructive"
onClick={() => reject(course.id)} onClick={() => removeCourse(course.id)}
disabled={actingId === course.id} disabled={actingId === course.id}
> >
<XCircle className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Отклонить Удалить курс
</Button> </Button>
</> </div>
</CardContent>
</Card>
))}
{!loading && courses.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-sm text-muted-foreground">
Курсы по заданным фильтрам не найдены.
</CardContent>
</Card>
) : null}
</section>
</>
) : null}
{activeTab === 'users' ? (
<section className="space-y-3">
<div className="flex gap-2">
<input
value={usersSearch}
onChange={(e) => setUsersSearch(e.target.value)}
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
placeholder="Поиск пользователя"
/>
<Button onClick={loadUsers} disabled={usersLoading}>
{usersLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Найти
</Button>
</div>
{adminUsers.map((user) => (
<Card key={user.id}>
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4">
<div>
<p className="font-medium">{user.name || user.email}</p>
<p className="text-sm text-muted-foreground">{user.email} {user.subscriptionTier || 'FREE'}</p>
</div>
<div className="flex items-center gap-2">
<select
value={user.role}
onChange={(e) => updateUserRole(user.id, e.target.value as any)}
className="h-9 rounded-lg border bg-background px-2 text-sm"
>
<option value="USER">USER</option>
<option value="MODERATOR">MODERATOR</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
</CardContent>
</Card>
))}
{!usersLoading && adminUsers.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-sm text-muted-foreground">Пользователи не найдены.</CardContent>
</Card>
) : null}
</section>
) : null}
{activeTab === 'payments' ? (
<section className="space-y-3">
<div className="flex flex-wrap gap-2">
<input
value={paymentSearch}
onChange={(e) => setPaymentSearch(e.target.value)}
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
placeholder="Поиск по курсу / пользователю"
/>
<select
value={paymentMode}
onChange={(e) => setPaymentMode(e.target.value)}
className="h-10 rounded-xl border bg-background px-3 text-sm"
>
<option value="">Все режимы</option>
<option value="DEV">DEV</option>
<option value="PROD">PROD</option>
</select>
<Button onClick={loadPayments} disabled={paymentsLoading}>
{paymentsLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Применить
</Button>
</div>
{payments.map((payment) => (
<Card key={payment.id}>
<CardContent className="space-y-2 p-4 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium">{payment.course?.title || 'Курс удалён'}</p>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
{payment.mode} {payment.provider}
</span>
</div>
<p className="text-muted-foreground">
{payment.user?.name || payment.user?.email} {payment.amount} {payment.currency} {payment.status}
</p>
{payment.eventCode ? (
<p className="text-xs text-muted-foreground">Событие: {payment.eventCode}</p>
) : null} ) : null}
</CardContent>
</Card>
))}
<Button {!paymentsLoading && payments.length === 0 ? (
size="sm" <Card className="border-dashed">
variant="destructive" <CardContent className="p-8 text-center text-sm text-muted-foreground">Платежи не найдены.</CardContent>
onClick={() => removeCourse(course.id)} </Card>
disabled={actingId === course.id} ) : null}
> </section>
<Trash2 className="mr-2 h-4 w-4" /> ) : null}
Удалить курс
</Button>
</div>
</CardContent>
</Card>
))}
{!loading && courses.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-sm text-muted-foreground">
Курсы по заданным фильтрам не найдены.
</CardContent>
</Card>
) : null}
</section>
</div> </div>
); );
} }

View File

@ -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<string | null>(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 (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container py-10 space-y-6">
<section className="rounded-3xl border border-border/50 bg-gradient-to-br from-primary/10 via-background to-primary/5 p-6">
<h1 className="text-3xl font-bold">Сотрудничество</h1>
<p className="mt-2 text-muted-foreground max-w-3xl">
Предоставляем платформу для вузов, школ, колледжей и компаний по договорённости:
запуск внутренних академий, каталогов курсов, трекинг прогресса и поддержка авторов.
</p>
</section>
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle>Что можем предоставить</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>1. White-label платформу с вашей айдентикой.</p>
<p>2. Инструменты для авторов и методистов.</p>
<p>3. Проверку контента, модерацию и аналитику обучения.</p>
<p>4. Корпоративные группы, чаты и домашние задания.</p>
<p>5. Интеграцию с процессами вашей организации.</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Оставить заявку</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-3">
<input
value={form.organization}
onChange={(e) => setForm((prev) => ({ ...prev, organization: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Организация"
required
/>
<input
value={form.contactName}
onChange={(e) => setForm((prev) => ({ ...prev, contactName: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Контактное лицо"
required
/>
<input
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Email"
required
/>
<input
value={form.phone}
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Телефон (необязательно)"
/>
<textarea
value={form.message}
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
className="min-h-[140px] w-full rounded-lg border bg-background p-3 text-sm"
placeholder="Опишите задачу и масштаб внедрения"
required
/>
<Button className="w-full" disabled={loading}>
{loading ? 'Отправка...' : 'Отправить заявку'}
</Button>
{success ? <p className="text-xs text-emerald-600">{success}</p> : null}
</form>
</CardContent>
</Card>
</div>
</main>
<Footer />
</div>
);
}

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, BookOpen, Check, ChevronDown, Clock, Loader2, Shield, Star, Users } from 'lucide-react'; import { ArrowLeft, BookOpen, Check, ChevronDown, Clock, Loader2, Shield, Star, Users } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -16,6 +16,7 @@ import { Footer } from '@/components/landing/footer';
export default function PublicCoursePage() { export default function PublicCoursePage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast(); const { toast } = useToast();
const { user } = useAuth(); const { user } = useAuth();
const id = params?.id as string; const id = params?.id as string;
@ -26,6 +27,7 @@ export default function PublicCoursePage() {
const [enrolling, setEnrolling] = useState(false); const [enrolling, setEnrolling] = useState(false);
const [enrolled, setEnrolled] = useState(false); const [enrolled, setEnrolled] = useState(false);
const [expandedChapters, setExpandedChapters] = useState<string[]>([]); const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
const isDevPayment = searchParams?.get('devPayment') === '1';
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@ -133,9 +135,12 @@ export default function PublicCoursePage() {
<div> <div>
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
{course.isVerified ? ( {course.isVerified ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white"> <span className="group relative inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
Проверен автором Проверен автором
<span className="pointer-events-none absolute -bottom-9 left-0 hidden w-56 rounded-md bg-black/85 px-2 py-1 text-[10px] font-normal leading-snug text-white shadow-lg group-hover:block">
Автор подтвердил корректность и актуальность материала.
</span>
</span> </span>
) : null} ) : null}
{course.difficulty ? ( {course.difficulty ? (
@ -218,6 +223,11 @@ export default function PublicCoursePage() {
<p className="text-3xl font-bold"> <p className="text-3xl font-bold">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'} {course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</p> </p>
{isDevPayment ? (
<span className="inline-flex w-fit items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-900">
DEV Payment
</span>
) : null}
{enrolled ? ( {enrolled ? (
<Button className="w-full" asChild> <Button className="w-full" asChild>
<Link href={`/dashboard/courses/${id}`}>Перейти к обучению</Link> <Link href={`/dashboard/courses/${id}`}>Перейти к обучению</Link>

View File

@ -123,9 +123,12 @@ export default function CoursesPage() {
</div> </div>
)} )}
{course.isVerified && ( {course.isVerified && (
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium"> <div className="group absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
Проверен Проверен автором
<span className="pointer-events-none absolute -bottom-9 right-0 hidden w-56 rounded-md bg-black/85 px-2 py-1 text-[10px] font-normal leading-snug text-white shadow-lg group-hover:block">
Автор подтвердил корректность и актуальность материала.
</span>
</div> </div>
)} )}
{course.difficulty && difficultyLabels[course.difficulty] && ( {course.difficulty && difficultyLabels[course.difficulty] && (

View File

@ -33,7 +33,15 @@ interface CourseCardProps {
id: string; id: string;
title: string; title: string;
description: string | null; description: string | null;
status: 'DRAFT' | 'GENERATING' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | 'ARCHIVED'; status:
| 'DRAFT'
| 'GENERATING'
| 'PENDING_MODERATION'
| 'PENDING_REVIEW'
| 'APPROVED'
| 'PUBLISHED'
| 'REJECTED'
| 'ARCHIVED';
chaptersCount: number; chaptersCount: number;
lessonsCount: number; lessonsCount: number;
updatedAt: string; updatedAt: string;
@ -59,6 +67,14 @@ const statusConfig = {
label: 'На модерации', label: 'На модерации',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
}, },
PENDING_MODERATION: {
label: 'На модерации',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
APPROVED: {
label: 'Одобрен',
className: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
},
REJECTED: { REJECTED: {
label: 'Отклонён', label: 'Отклонён',
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',

View File

@ -0,0 +1,154 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { ChevronRight, MessageCircle, Minus, Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { getWsBaseUrl } from '@/lib/ws';
import { cn } from '@/lib/utils';
type Props = {
groupId: string | null;
lessonId: string | null;
userId: string | null;
};
export function LessonChatPanel({ groupId, lessonId, userId }: Props) {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<any[]>([]);
const [message, setMessage] = useState('');
const [sending, setSending] = useState(false);
const socketRef = useRef<Socket | null>(null);
const endRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!groupId || !lessonId) {
setMessages([]);
return;
}
api
.getGroupMessages(groupId, lessonId)
.then((data) => setMessages(data || []))
.catch(() => setMessages([]));
}, [groupId, lessonId]);
useEffect(() => {
if (!groupId) return;
const token =
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
const socket = io(`${getWsBaseUrl()}/ws/course-groups`, {
transports: ['websocket'],
auth: { token },
});
socketRef.current = socket;
socket.emit('groups:join', { groupId });
socket.on('groups:new-message', (payload: any) => {
if (!lessonId) return;
if (payload?.lessonId !== lessonId) return;
setMessages((prev) => [...prev, payload]);
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [groupId, lessonId]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, open]);
const send = async () => {
if (!groupId || !lessonId || !message.trim() || sending) return;
setSending(true);
try {
await api.sendGroupMessage(groupId, message.trim(), lessonId);
setMessage('');
const latest = await api.getGroupMessages(groupId, lessonId).catch(() => []);
setMessages(latest || []);
} finally {
setSending(false);
}
};
return (
<div className="fixed bottom-4 right-4 z-40">
{open ? (
<div className="w-[360px] rounded-2xl border border-slate-700/60 bg-slate-950/85 text-slate-100 shadow-2xl backdrop-blur-xl">
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-primary/20 p-1.5 text-primary">
<MessageCircle className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold">Чат урока</p>
<p className="text-xs text-slate-300">Контекст текущего урока</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-slate-200 hover:bg-slate-800"
onClick={() => setOpen(false)}
>
<Minus className="h-4 w-4" />
</Button>
</div>
<div className="flex h-[420px] flex-col">
<div className="flex-1 space-y-2 overflow-auto p-3">
{messages.map((item) => {
const own = item.user?.id === userId;
return (
<div key={item.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[82%] rounded-2xl px-3 py-2 text-sm',
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-slate-800 text-slate-100'
)}
>
<p className="mb-1 text-xs opacity-80">{item.user?.name || 'Участник'}</p>
<p>{item.content}</p>
</div>
</div>
);
})}
{messages.length === 0 ? (
<p className="text-center text-xs text-slate-400">Сообщений по этому уроку пока нет</p>
) : null}
<div ref={endRef} />
</div>
<div className="border-t border-slate-700/60 p-3">
<div className="flex gap-2">
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
className="h-10 flex-1 rounded-lg border border-slate-700 bg-slate-900 px-3 text-sm text-slate-100 placeholder:text-slate-400"
placeholder="Написать в чат урока"
/>
<Button size="icon" className="h-10 w-10" onClick={send} disabled={sending}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
) : (
<Button
className="h-12 rounded-full px-4 shadow-lg"
onClick={() => setOpen(true)}
disabled={!groupId || !lessonId}
>
<MessageCircle className="mr-2 h-4 w-4" />
Чат урока
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { Sparkles } from 'lucide-react';
const navigation = { const navigation = {
product: [ product: [
{ name: 'Курсы', href: '/courses' }, { name: 'Курсы', href: '/courses' },
{ name: 'Сотрудничество', href: '/cooperation' },
{ name: 'Возможности', href: '/#features' }, { name: 'Возможности', href: '/#features' },
{ name: 'Тарифы', href: '/#pricing' }, { name: 'Тарифы', href: '/#pricing' },
{ name: 'FAQ', href: '/#faq' }, { name: 'FAQ', href: '/#faq' },

View File

@ -10,6 +10,7 @@ import { useAuth } from '@/contexts/auth-context';
const navigation = [ const navigation = [
{ name: 'Курсы', href: '/courses' }, { name: 'Курсы', href: '/courses' },
{ name: 'Сотрудничество', href: '/cooperation' },
{ name: 'Возможности', href: '/#features' }, { name: 'Возможности', href: '/#features' },
{ name: 'Как это работает', href: '/#how-it-works' }, { name: 'Как это работает', href: '/#how-it-works' },
{ name: 'Тарифы', href: '/#pricing' }, { name: 'Тарифы', href: '/#pricing' },

View File

@ -1,11 +1,60 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowRight, Zap, BookOpen, Brain } from 'lucide-react'; import { ArrowRight, Zap, BookOpen, Brain } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/auth-context';
import { useToast } from '@/components/ui/use-toast';
export function Hero() { export function Hero() {
const router = useRouter();
const { user, signIn } = useAuth();
const { toast } = useToast();
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [thinkingTick, setThinkingTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setThinkingTick((prev) => (prev + 1) % 3), 900);
return () => clearInterval(id);
}, []);
const handleCreateCourse = async () => {
if (user) {
setIsLaunching(true);
setTimeout(() => router.push('/dashboard/courses/new'), 320);
return;
}
setIsLoginOpen(true);
};
const handleModalLogin = async (event: React.FormEvent) => {
event.preventDefault();
if (signingIn) return;
setSigningIn(true);
const result = await signIn(email.trim(), password);
setSigningIn(false);
if (result.error) {
toast({
title: 'Не удалось войти',
description: result.error.message,
variant: 'destructive',
});
return;
}
setIsLoginOpen(false);
setIsLaunching(true);
setTimeout(() => router.push('/dashboard/courses/new'), 320);
};
return ( return (
<section className="relative overflow-hidden py-20 sm:py-32"> <section className="relative overflow-hidden py-20 sm:py-32">
{/* Background gradient */} {/* Background gradient */}
@ -58,17 +107,48 @@ export function Hero() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }} transition={{ duration: 0.5, delay: 0.3 }}
> >
<Button size="xl" asChild> <Button size="xl" onClick={handleCreateCourse}>
<Link href="/register"> Создать курс
Создать первый курс <ArrowRight className="ml-2 h-5 w-5" />
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button> </Button>
<Button size="xl" variant="outline" asChild> <Button size="xl" variant="outline" asChild>
<Link href="#how-it-works">Как это работает</Link> <Link href="/courses">
Смотреть курсы
</Link>
</Button> </Button>
</motion.div> </motion.div>
<motion.div
className={`mx-auto mt-10 w-full max-w-2xl rounded-2xl border border-white/25 bg-white/10 p-4 text-left shadow-xl backdrop-blur-xl transition-all duration-300 ${isLaunching ? 'scale-[1.02] ring-2 ring-primary/30' : ''}`}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.35 }}
>
<div className="mb-3 flex items-center justify-between text-xs text-muted-foreground">
<span className="rounded-full border border-white/20 bg-background/40 px-2 py-1">
AI Studio
</span>
<span className="text-primary">
thinking{'.'.repeat(thinkingTick + 1)}
</span>
</div>
<div className="rounded-xl border border-white/20 bg-background/70 p-3">
<p className="text-sm text-foreground/90">
О чём вы хотите создать курс?
</p>
<div className="mt-2 rounded-lg bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
Пример: «Python для аналитиков с нуля до проектов»
<span className="ml-1 inline-block h-4 w-[1px] animate-pulse bg-primary align-middle" />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>Квиз, структура и план модулей формируются автоматически.</span>
<Button size="sm" onClick={handleCreateCourse}>
Создать первый курс
</Button>
</div>
</motion.div>
{/* Stats */} {/* Stats */}
<motion.div <motion.div
className="mt-16 grid grid-cols-3 gap-8" className="mt-16 grid grid-cols-3 gap-8"
@ -100,6 +180,54 @@ export function Hero() {
</motion.div> </motion.div>
</div> </div>
</div> </div>
{isLoginOpen ? (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 px-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-background p-5 shadow-2xl">
<div className="mb-4">
<h3 className="text-lg font-semibold">Войдите, чтобы продолжить</h3>
<p className="mt-1 text-sm text-muted-foreground">
После входа сразу откроется создание курса.
</p>
</div>
<form className="space-y-3" onSubmit={handleModalLogin}>
<input
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Email"
/>
<input
type="password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Пароль"
/>
<div className="flex gap-2 pt-1">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => setIsLoginOpen(false)}
disabled={signingIn}
>
Отмена
</Button>
<Button type="submit" className="flex-1" disabled={signingIn}>
{signingIn ? 'Входим...' : 'Войти'}
</Button>
</div>
</form>
<p className="mt-3 text-xs text-muted-foreground">
Нет аккаунта? <Link href="/register" className="text-primary hover:underline">Зарегистрироваться</Link>
</p>
</div>
</div>
) : null}
</section> </section>
); );
} }

View File

@ -183,6 +183,13 @@ class ApiClient {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`); return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`);
} }
async generateLessonHomework(courseId: string, lessonId: string, type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB') {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/homework/generate`, {
method: 'POST',
body: JSON.stringify({ type }),
});
}
// Generation // Generation
async startGeneration(prompt: string) { async startGeneration(prompt: string) {
return this.request<{ id: string; status: string; progress: number }>('/generation/start', { return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
@ -290,10 +297,15 @@ class ApiClient {
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`); return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`);
} }
async submitLessonHomework(courseId: string, lessonId: string, content: string) { async submitLessonHomework(
courseId: string,
lessonId: string,
data: { content?: string; type?: string; attachmentUrl?: string; githubUrl?: string } | string
) {
const payload = typeof data === 'string' ? { content: data } : data;
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`, { return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ content }), body: JSON.stringify(payload),
}); });
} }
@ -340,18 +352,19 @@ class ApiClient {
return this.request<any>(`/groups/course/${courseId}/default`); return this.request<any>(`/groups/course/${courseId}/default`);
} }
async getGroupMessages(groupId: string) { async getGroupMessages(groupId: string, lessonId?: string) {
return this.request<any[]>(`/groups/${groupId}/messages`); const query = lessonId ? `?lessonId=${encodeURIComponent(lessonId)}` : '';
return this.request<any[]>(`/groups/${groupId}/messages${query}`);
} }
async getGroupMembers(groupId: string) { async getGroupMembers(groupId: string) {
return this.request<any[]>(`/groups/${groupId}/members`); return this.request<any[]>(`/groups/${groupId}/members`);
} }
async sendGroupMessage(groupId: string, content: string) { async sendGroupMessage(groupId: string, content: string, lessonId?: string) {
return this.request<any>(`/groups/${groupId}/messages`, { return this.request<any>(`/groups/${groupId}/messages`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ content }), body: JSON.stringify({ content, lessonId }),
}); });
} }
@ -431,6 +444,17 @@ class ApiClient {
return this.request<any[]>('/moderation/pending'); return this.request<any[]>('/moderation/pending');
} }
async getModerationCoursePreview(courseId: string) {
return this.request<any>(`/moderation/${courseId}/preview`);
}
async previewModerationQuiz(courseId: string, lessonId: string, answers?: number[]) {
return this.request<any>(`/moderation/${courseId}/quiz-preview`, {
method: 'POST',
body: JSON.stringify({ lessonId, answers }),
});
}
async approveModerationCourse(courseId: string, note?: string) { async approveModerationCourse(courseId: string, note?: string) {
return this.request<any>(`/moderation/${courseId}/approve`, { return this.request<any>(`/moderation/${courseId}/approve`, {
method: 'POST', method: 'POST',
@ -451,6 +475,80 @@ class ApiClient {
}); });
} }
async getAdminUsers(params?: { search?: string; role?: string; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
if (params?.role) searchParams.set('role', params.role);
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.request<any[]>(`/admin/users${query ? `?${query}` : ''}`);
}
async updateAdminUserRole(userId: string, role: 'USER' | 'MODERATOR' | 'ADMIN') {
return this.request<any>(`/admin/users/${userId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
});
}
async getAdminPayments(params?: {
mode?: 'DEV' | 'PROD';
provider?: 'STRIPE' | 'YOOMONEY';
status?: string;
search?: string;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.mode) searchParams.set('mode', params.mode);
if (params?.provider) searchParams.set('provider', params.provider);
if (params?.status) searchParams.set('status', params.status);
if (params?.search) searchParams.set('search', params.search);
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.request<any[]>(`/admin/payments${query ? `?${query}` : ''}`);
}
async submitCooperationRequest(data: {
organization: string;
contactName: string;
email: string;
phone?: string;
role?: string;
organizationType?: string;
message: string;
}) {
return this.request<any>('/cooperation/requests', {
method: 'POST',
body: JSON.stringify(data),
});
}
async uploadCourseSource(courseId: string, file: File) {
const token = getApiToken();
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_URL}/courses/${courseId}/sources/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
async getCourseSources(courseId: string) {
return this.request<any[]>(`/courses/${courseId}/sources`);
}
async getCourseSourceOutlineHints(courseId: string) {
return this.request<any>(`/courses/${courseId}/sources/outline-hints`);
}
// Search // Search
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) { async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
const searchParams = new URLSearchParams({ q: query }); const searchParams = new URLSearchParams({ q: query });

View File

@ -44,9 +44,12 @@ model User {
generations CourseGeneration[] generations CourseGeneration[]
groupMembers GroupMember[] groupMembers GroupMember[]
groupMessages GroupMessage[] groupMessages GroupMessage[]
uploadedSourceFiles CourseSourceFile[]
homeworkSubmissions HomeworkSubmission[] homeworkSubmissions HomeworkSubmission[]
supportTickets SupportTicket[] supportTickets SupportTicket[]
ticketMessages TicketMessage[] ticketMessages TicketMessage[]
statusChanges CourseStatusHistory[] @relation("StatusChangedBy")
cooperationRequests CooperationRequest[]
@@map("users") @@map("users")
} }
@ -129,7 +132,9 @@ model Subscription {
enum CourseStatus { enum CourseStatus {
DRAFT DRAFT
GENERATING GENERATING
PENDING_MODERATION
PENDING_REVIEW PENDING_REVIEW
APPROVED
PUBLISHED PUBLISHED
REJECTED REJECTED
ARCHIVED ARCHIVED
@ -189,6 +194,8 @@ model Course {
reviews Review[] reviews Review[]
generation CourseGeneration? generation CourseGeneration?
groups CourseGroup[] groups CourseGroup[]
statusHistory CourseStatusHistory[]
sourceFiles CourseSourceFile[]
// Vector embedding for semantic search // Vector embedding for semantic search
embedding Unsupported("vector(1536)")? embedding Unsupported("vector(1536)")?
@ -242,6 +249,7 @@ model Lesson {
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade) chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
homework Homework[] homework Homework[]
quiz Quiz? quiz Quiz?
groupMessages GroupMessage[]
// Vector embedding for semantic search // Vector embedding for semantic search
embedding Unsupported("vector(1536)")? embedding Unsupported("vector(1536)")?
@ -348,6 +356,16 @@ model Category {
@@map("categories") @@map("categories")
} }
enum PaymentMode {
DEV
PROD
}
enum PaymentProvider {
STRIPE
YOOMONEY
}
model Purchase { model Purchase {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
@ -357,6 +375,10 @@ model Purchase {
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
currency String @default("USD") currency String @default("USD")
stripePaymentId String? @map("stripe_payment_id") stripePaymentId String? @map("stripe_payment_id")
provider PaymentProvider @default(STRIPE)
mode PaymentMode @default(PROD)
eventCode String? @map("event_code")
metadata Json?
// Status // Status
status String @default("completed") // pending, completed, refunded status String @default("completed") // pending, completed, refunded
@ -497,17 +519,75 @@ model GroupMessage {
id String @id @default(uuid()) id String @id @default(uuid())
groupId String @map("group_id") groupId String @map("group_id")
userId String @map("user_id") userId String @map("user_id")
lessonId String? @map("lesson_id")
content String @db.Text content String @db.Text
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull)
@@index([groupId]) @@index([groupId])
@@index([lessonId])
@@map("group_messages") @@map("group_messages")
} }
model CourseStatusHistory {
id String @id @default(uuid())
courseId String @map("course_id")
fromStatus CourseStatus? @map("from_status")
toStatus CourseStatus @map("to_status")
note String? @db.Text
changedById String? @map("changed_by_id")
createdAt DateTime @default(now()) @map("created_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
changedBy User? @relation("StatusChangedBy", fields: [changedById], references: [id], onDelete: SetNull)
@@index([courseId, createdAt])
@@map("course_status_history")
}
enum CourseSourceType {
PDF
DOCX
TXT
PPTX
IMAGE
ZIP
OTHER
}
enum CourseSourceParseStatus {
PENDING
PARSED
FAILED
SKIPPED
}
model CourseSourceFile {
id String @id @default(uuid())
courseId String @map("course_id")
uploadedById String @map("uploaded_by_id")
fileName String @map("file_name")
mimeType String @map("mime_type")
fileSize Int @map("file_size")
sourceType CourseSourceType @map("source_type")
storagePath String @map("storage_path")
parseStatus CourseSourceParseStatus @default(PENDING) @map("parse_status")
extractedText String? @db.Text @map("extracted_text")
extractedMeta Json? @map("extracted_meta")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
uploadedBy User @relation(fields: [uploadedById], references: [id], onDelete: Cascade)
@@index([courseId, createdAt])
@@map("course_source_files")
}
// ============================================ // ============================================
// Homework & Assignments // Homework & Assignments
// ============================================ // ============================================
@ -517,6 +597,8 @@ model Homework {
lessonId String @unique @map("lesson_id") lessonId String @unique @map("lesson_id")
title String title String
description String @db.Text description String @db.Text
type HomeworkType @default(TEXT)
config Json?
dueDate DateTime? @map("due_date") dueDate DateTime? @map("due_date")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -535,11 +617,24 @@ enum HomeworkReviewStatus {
TEACHER_REVIEWED TEACHER_REVIEWED
} }
enum HomeworkType {
TEXT
FILE
PROJECT
QUIZ
GITHUB
}
model HomeworkSubmission { model HomeworkSubmission {
id String @id @default(uuid()) id String @id @default(uuid())
homeworkId String @map("homework_id") homeworkId String @map("homework_id")
userId String @map("user_id") userId String @map("user_id")
content String @db.Text content String? @db.Text
answerType HomeworkType @default(TEXT) @map("answer_type")
attachmentUrl String? @map("attachment_url")
githubUrl String? @map("github_url")
projectMeta Json? @map("project_meta")
quizAnswers Json? @map("quiz_answers")
// AI grading // AI grading
aiScore Int? @map("ai_score") // 1-5 aiScore Int? @map("ai_score") // 1-5
@ -597,3 +692,24 @@ model TicketMessage {
@@index([ticketId]) @@index([ticketId])
@@map("ticket_messages") @@map("ticket_messages")
} }
model CooperationRequest {
id String @id @default(uuid())
organization String
contactName String @map("contact_name")
email String
phone String?
role String?
organizationType String? @map("organization_type")
message String @db.Text
status String @default("new")
source String? @default("landing")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@map("cooperation_requests")
}

View File

@ -32,7 +32,20 @@ export type {
Review, Review,
Enrollment, Enrollment,
LessonProgress, LessonProgress,
CourseStatusHistory,
CourseSourceFile,
CooperationRequest,
} from '@prisma/client'; } from '@prisma/client';
// Enum re-exports // Enum re-exports
export { SubscriptionTier, CourseStatus, GenerationStatus, UserRole } from '@prisma/client'; export {
SubscriptionTier,
CourseStatus,
GenerationStatus,
UserRole,
PaymentMode,
PaymentProvider,
CourseSourceType,
CourseSourceParseStatus,
HomeworkType,
} from '@prisma/client';

45
pnpm-lock.yaml generated
View File

@ -123,12 +123,18 @@ importers:
meilisearch: meilisearch:
specifier: ^0.37.0 specifier: ^0.37.0
version: 0.37.0 version: 0.37.0
nodemailer:
specifier: ^6.10.0
version: 6.10.1
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
passport-jwt: passport-jwt:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
pdf-parse:
specifier: ^1.1.1
version: 1.1.4
reflect-metadata: reflect-metadata:
specifier: ^0.2.1 specifier: ^0.2.1
version: 0.2.2 version: 0.2.2
@ -160,9 +166,15 @@ importers:
'@types/node': '@types/node':
specifier: ^20.11.0 specifier: ^20.11.0
version: 20.19.32 version: 20.19.32
'@types/nodemailer':
specifier: ^6.4.17
version: 6.4.22
'@types/passport-jwt': '@types/passport-jwt':
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
'@types/pdf-parse':
specifier: ^1.1.5
version: 1.1.5
jest: jest:
specifier: ^29.7.0 specifier: ^29.7.0
version: 29.7.0(@types/node@20.19.32)(ts-node@10.9.2(@types/node@20.19.32)(typescript@5.9.3)) version: 29.7.0(@types/node@20.19.32)(ts-node@10.9.2(@types/node@20.19.32)(typescript@5.9.3))
@ -2029,6 +2041,9 @@ packages:
'@types/node@20.19.32': '@types/node@20.19.32':
resolution: {integrity: sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==} resolution: {integrity: sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==}
'@types/nodemailer@6.4.22':
resolution: {integrity: sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==}
'@types/passport-jwt@4.0.1': '@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
@ -2038,6 +2053,9 @@ packages:
'@types/passport@1.0.17': '@types/passport@1.0.17':
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
'@types/pdf-parse@1.1.5':
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
'@types/phoenix@1.6.7': '@types/phoenix@1.6.7':
resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==}
@ -4501,6 +4519,9 @@ packages:
node-emoji@1.11.0: node-emoji@1.11.0:
resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==}
node-ensure@0.0.0:
resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==}
node-fetch@2.7.0: node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0} engines: {node: 4.x || >=6.0.0}
@ -4520,6 +4541,10 @@ packages:
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@6.10.1:
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0: normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -4693,6 +4718,10 @@ packages:
pause@0.0.1: pause@0.0.1:
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
pdf-parse@1.1.4:
resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==}
engines: {node: '>=6.8.1'}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -7828,6 +7857,10 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/nodemailer@6.4.22':
dependencies:
'@types/node': 20.19.32
'@types/passport-jwt@4.0.1': '@types/passport-jwt@4.0.1':
dependencies: dependencies:
'@types/jsonwebtoken': 9.0.10 '@types/jsonwebtoken': 9.0.10
@ -7842,6 +7875,10 @@ snapshots:
dependencies: dependencies:
'@types/express': 4.17.25 '@types/express': 4.17.25
'@types/pdf-parse@1.1.5':
dependencies:
'@types/node': 20.19.32
'@types/phoenix@1.6.7': {} '@types/phoenix@1.6.7': {}
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
@ -10834,6 +10871,8 @@ snapshots:
dependencies: dependencies:
lodash: 4.17.23 lodash: 4.17.23
node-ensure@0.0.0: {}
node-fetch@2.7.0: node-fetch@2.7.0:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
@ -10847,6 +10886,8 @@ snapshots:
node-releases@2.0.27: {} node-releases@2.0.27: {}
nodemailer@6.10.1: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
npm-run-path@4.0.1: npm-run-path@4.0.1:
@ -11028,6 +11069,10 @@ snapshots:
pause@0.0.1: {} pause@0.0.1: {}
pdf-parse@1.1.4:
dependencies:
node-ensure: 0.0.0
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}