feat: phase1 platform upgrade with moderation, dev payments, admin panel and landing updates
This commit is contained in:
10
.env.example
10
.env.example
@ -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"
|
||||||
|
|||||||
@ -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')}`;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
43
apps/api/src/admin/admin.controller.ts
Normal file
43
apps/api/src/admin/admin.controller.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal 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 {}
|
||||||
|
|
||||||
64
apps/api/src/admin/admin.service.ts
Normal file
64
apps/api/src/admin/admin.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
11
apps/api/src/common/access/access.module.ts
Normal file
11
apps/api/src/common/access/access.module.ts
Normal 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 {}
|
||||||
|
|
||||||
61
apps/api/src/common/access/access.service.ts
Normal file
61
apps/api/src/common/access/access.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
11
apps/api/src/common/course-status.ts
Normal file
11
apps/api/src/common/course-status.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
19
apps/api/src/cooperation/cooperation.controller.ts
Normal file
19
apps/api/src/cooperation/cooperation.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
10
apps/api/src/cooperation/cooperation.module.ts
Normal file
10
apps/api/src/cooperation/cooperation.module.ts
Normal 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 {}
|
||||||
|
|
||||||
98
apps/api/src/cooperation/cooperation.service.ts
Normal file
98
apps/api/src/cooperation/cooperation.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
38
apps/api/src/courses/course-sources.controller.ts
Normal file
38
apps/api/src/courses/course-sources.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
apps/api/src/courses/course-sources.service.ts
Normal file
158
apps/api/src/courses/course-sources.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
22
apps/api/src/payments/dev-payments.controller.ts
Normal file
22
apps/api/src/payments/dev-payments.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
132
apps/web/src/app/cooperation/page.tsx
Normal file
132
apps/web/src/app/cooperation/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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] && (
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
154
apps/web/src/components/dashboard/lesson-chat-panel.tsx
Normal file
154
apps/web/src/components/dashboard/lesson-chat-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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' },
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@ -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
45
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user