Compare commits

...

3 Commits

91 changed files with 7373 additions and 1237 deletions

View File

@ -37,6 +37,7 @@ AI_MODEL_DEFAULT="openai/gpt-4o-mini"
STRIPE_SECRET_KEY="sk_test_..." STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..." STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
PAYMENT_MODE="PROD" # DEV | PROD
# Stripe Price IDs # Stripe Price IDs
STRIPE_PRICE_PREMIUM="price_..." STRIPE_PRICE_PREMIUM="price_..."
@ -53,6 +54,15 @@ S3_SECRET_ACCESS_KEY="your-secret-key"
S3_BUCKET_NAME="coursecraft" S3_BUCKET_NAME="coursecraft"
S3_REGION="auto" S3_REGION="auto"
# Cooperation form email (optional; if not set requests are still saved in DB)
COOPERATION_EMAIL_TO="exbytestudios@gmail.com"
COOPERATION_EMAIL_FROM="noreply@coursecraft.local"
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASS=""
SMTP_SECURE="false"
# App URLs (API на 3125; веб — свой порт, напр. 3000) # App URLs (API на 3125; веб — свой порт, напр. 3000)
NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="http://localhost:3125" NEXT_PUBLIC_API_URL="http://localhost:3125"

165
IMPLEMENTATION.md Normal file
View File

@ -0,0 +1,165 @@
# Реализация проекта CourseCraft
Документ описывает техническую реализацию платформы CourseCraft — создания образовательных курсов с помощью ИИ.
---
## 1. Обзор
**CourseCraft** — монорепозиторий с тремя приложениями:
| Приложение | Назначение |
|-------------|------------|
| **web** | Фронтенд на Next.js 14 (App Router): лендинг, авторизация, дашборд, каталог, прохождение курсов, сертификаты. |
| **api** | Backend на NestJS: REST API, авторизация (Supabase + JWT), курсы, генерация, записи, прогресс, сертификаты, поиск. |
| **ai-service** | Воркер на BullMQ: очередь задач генерации курсов, вызовы OpenRouter (структура курса, контент уроков). |
Дополнительно: **PostgreSQL** (Prisma), **Redis**, **Meilisearch**. Запуск через Docker Compose из корня проекта.
---
## 2. Стек и технологии
- **Frontend:** Next.js 14, TypeScript, Tailwind CSS, shadcn/ui, TipTap (редактор/просмотр контента), Supabase Client (auth).
- **Backend:** NestJS, Prisma, JWT (обмен Supabase access token на бэкенд-токен), BullMQ, Stripe (платежи).
- **AI:** OpenRouter API (GPT-4, Claude и др.), структурированный вывод (JSON), промпты для уточняющих вопросов, outline и контент уроков.
- **Инфраструктура:** Docker Compose, pgvector, Meilisearch (поиск курсов).
---
## 3. База данных (Prisma)
Схема в `packages/database/prisma/schema.prisma`.
### Основные сущности
- **User** — пользователь (supabaseId, email, name, subscriptionTier, role). Связь с UserSettings (customAiModel, theme, language).
- **Subscription** — подписка (FREE/PREMIUM/PRO), Stripe, лимиты (coursesCreatedThisMonth).
- **Course** — курс (authorId, title, description, slug, status, tags, difficulty, estimatedHours). Связи: Chapter → Lesson.
- **Chapter** — глава (courseId, title, description, order).
- **Lesson** — урок (chapterId, title, content в TipTap JSON, order, durationMinutes). Опционально: Quiz (questions JSON).
- **CourseGeneration** — процесс генерации (userId, initialPrompt, aiModel, status, progress, questions, answers, generatedOutline, courseId, jobId).
- **Enrollment** — запись на курс (userId, courseId, progress, completedAt, certificateUrl).
- **LessonProgress** — прогресс по уроку (userId, enrollmentId, lessonId, completedAt, quizScore).
- **Review** — отзыв по курсу (userId, courseId, rating, content).
- **Certificates** — не отдельная таблица: данные для сертификата берутся из Enrollment + User + Course; certificateUrl сохраняется в Enrollment.
Дополнительно: Category, Purchase, CourseGroup, GroupMember, GroupMessage, Homework, HomeworkSubmission, SupportTicket, TicketMessage.
---
## 4. Backend (API, NestJS)
### Авторизация
- **Supabase Auth** — вход/регистрация на фронте.
- **Обмен токена:** `POST /api/auth/exchange` принимает Supabase access token, создаёт/обновляет пользователя в БД, возвращает JWT бэкенда.
- Защищённые маршруты используют **JWT Guard**; текущий пользователь — `@CurrentUser() user: User`.
### Основные модули и маршруты
| Модуль | Назначение |
|---------------|------------|
| **auth** | exchange token, привязка к Supabase. |
| **users** | профиль, настройки (в т.ч. customAiModel). |
| **courses** | CRUD курсов, глав (chapters), уроков (lessons); отдача курса с главами и уроками. |
| **generation**| Старт генерации (очередь BullMQ), ответы на уточняющие вопросы, продолжение генерации, статус/прогресс. |
| **catalog** | Публичный каталог курсов (опубликованные), фильтры. |
| **enrollment**| Запись на курс, прогресс (прохождение уроков, квизы), отзывы, рейтинги, список записей пользователя. |
| **certificates** | `GET /certificates/:courseId` — генерация/возврат сертификата (certificateUrl, html); `GET /certificates/:courseId/data` — данные для страницы сертификата (userName, courseTitle, completedAt). |
| **search** | Полнотекстовый поиск (Meilisearch). |
| **payments** | Stripe: подписки, вебхуки. |
### Сертификаты
- Доступ только при завершённом курсе (enrollment.completedAt).
- **getCertificateData(userId, courseId)** — возвращает `{ userName, courseTitle, completedAt }` для отображения на странице.
- **generateCertificate** — формирует HTML сертификата, при необходимости сохраняет certificateUrl в Enrollment, возвращает certificateUrl и html (для обратной совместимости).
---
## 5. AI Service (BullMQ + OpenRouter)
### Очередь
- Очередь **course-generation** (Redis). API добавляет задачи, воркер в ai-service их обрабатывает.
- Типы задач: **generate-course** (старт), **continue-generation** (после ответов на вопросы).
### Пайплайн генерации курса
1. **generate-course:** анализ запроса → генерация уточняющих вопросов (OpenRouter) → сохранение вопросов в CourseGeneration → статус WAITING_FOR_ANSWERS (ожидание ответов).
2. Пользователь отвечает через API → ставится задача **continue-generation** с stage `after-questions`.
3. **continue-generation:** исследование (симуляция) → генерация outline (название, описание, главы, уроки, estimatedTotalHours, difficulty, tags) → создание курса в БД (Course + Chapter + Lesson без контента) → для каждого урока вызов **generateLessonContent** → запись TipTap JSON в Lesson → обновление подписки (coursesCreatedThisMonth) → статус COMPLETED.
### OpenRouter (промпты)
- **generateClarifyingQuestions** — уточняющие вопросы, в т.ч. объём курса (короткий / средний / длинный).
- **generateCourseOutline** — структура курса по ответам (количество глав и уроков, estimatedMinutes, estimatedTotalHours). При не указанном объёме — средний/длинный (57 глав, 46 уроков в главе, не менее 25 уроков).
- **generateLessonContent** — контент урока в формате TipTap JSON (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, mermaid). Промпт требует полный, подробный материал (10001500+ слов), примеры и пояснения, минимум 12 примера/кода на урок.
---
## 6. Frontend (Next.js)
### Маршруты
- **/** — лендинг.
- **/(auth)/login, register, forgot-password** — авторизация Supabase.
- **/(dashboard)/dashboard/** — дашборд (sidebar + header): главная, курсы (список, создание, просмотр/прохождение, редактирование), каталог, карточка курса каталога, мои обучения (learning), поиск, настройки, биллинг.
- **/(certificate)/certificate/[courseId]** — страница сертификата (без сайдбара): загрузка данных через `getCertificateData(courseId)`, отображение ФИО, названия курса, даты; кнопка «Печать», стили для печати (только блок сертификата).
### Авторизация на фронте
- **AuthProvider:** Supabase session → при наличии сессии вызов `/api/auth/exchange` с retry → сохранение JWT в sessionStorage и в памяти; в контексте доступны `user`, `backendUser` (id, email, name, subscriptionTier).
- Все запросы к API через общий клиент с заголовком `Authorization: Bearer <token>`.
### API-клиент (lib/api.ts)
- Базовый URL API через прокси Next.js или INTERNAL_API_URL на сервере.
- Методы: auth (exchange), users, courses, chapters, lessons, generation (start, answerQuestions, continue, status), catalog, enrollment (запись, прогресс, отзывы, мои записи), certificates (getCertificate, getCertificateData), search и др.
### Ключевые сценарии
- **Создание курса:** форма с промптом → старт генерации → опрос уточняющих вопросов → ответы → продолжение генерации → опрос статуса/прогресса до завершения → переход к курсу.
- **Просмотр/прохождение курса:** выбор урока, отображение контента (LessonContentViewer по TipTap JSON), отметка урока выполненным, квиз по уроку (LessonQuiz), обновление прогресса, при 100% — возможность открыть сертификат.
- **Сертификат:** кнопки «Получить сертификат» (страница курса, мои обучения) открывают в новой вкладке `/certificate/[courseId]`; страница запрашивает данные и рендерит сертификат с кнопкой «Печать».
---
## 7. Инфраструктура и запуск
### Docker Compose (из корня)
- **postgres** — порт 5432, pgvector.
- **redis** — порт 6395 (внешний).
- **meilisearch** — порт 7700.
- **api** — порт 3125, зависит от postgres, redis, meilisearch; переменные: DATABASE_URL, REDIS_URL, JWT_SECRET, Supabase, NEXT_PUBLIC_APP_URL и т.д.
- **ai-service** — зависит от postgres, redis; OPENROUTER_API_KEY, DATABASE_URL, REDIS_URL.
- **web** — порт 3080, INTERNAL_API_URL=http://api:3125, Next.js production build.
Запуск:
```bash
docker compose --env-file .env up -d
```
Перезапуск после изменений (например, промпты или API):
```bash
docker compose --env-file .env restart ai-service api web
```
### Переменные окружения (.env)
- **База и очереди:** DATABASE_URL, REDIS_URL.
- **Auth:** NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET.
- **AI:** OPENROUTER_API_KEY (обязателен для ai-service).
- **Приложение:** NEXT_PUBLIC_APP_URL (для ссылок и CORS).
- **Платежи:** STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (при использовании Stripe).
- **Поиск:** MEILISEARCH_API_KEY (в docker-compose для meilisearch).
---
## 8. Итог
Реализация покрывает: авторизацию через Supabase и JWT, создание курсов по промпту с уточняющими вопросами и генерацией структуры и контента через OpenRouter, каталог и запись на курсы, прохождение с прогрессом и квизами, выдачу сертификата с отдельной страницей для просмотра и печати, тарифы и настраиваемую AI-модель. Документация по быстрому старту и командам — в README.md.

View File

@ -235,6 +235,13 @@ export class CourseGenerationPipeline {
difficulty: outline.difficulty, difficulty: outline.difficulty,
estimatedHours: outline.estimatedTotalHours, estimatedHours: outline.estimatedTotalHours,
tags: outline.tags, tags: outline.tags,
groups: {
create: {
name: 'Основная группа',
description: 'Обсуждение курса и вопросы преподавателю',
isDefault: true,
},
},
chapters: { chapters: {
create: outline.chapters.map((chapter, chapterIndex) => ({ create: outline.chapters.map((chapter, chapterIndex) => ({
title: chapter.title, title: chapter.title,

View File

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

View File

@ -24,7 +24,9 @@
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/swagger": "^7.3.0", "@nestjs/swagger": "^7.3.0",
"@nestjs/websockets": "^10.3.0",
"@supabase/supabase-js": "^2.39.0", "@supabase/supabase-js": "^2.39.0",
"bullmq": "^5.1.0", "bullmq": "^5.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@ -32,10 +34,13 @@
"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",
"stripe": "^14.14.0" "stripe": "^14.14.0"
}, },
"devDependencies": { "devDependencies": {
@ -45,7 +50,9 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdf-parse": "^1.1.5",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",

View File

@ -0,0 +1,43 @@
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { PaymentMode, PaymentProvider, User, UserRole } from '@coursecraft/database';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AdminService } from './admin.service';
@ApiTags('admin')
@Controller('admin')
@ApiBearerAuth()
export class AdminController {
constructor(private adminService: AdminService) {}
@Get('users')
async getUsers(
@CurrentUser() user: User,
@Query('search') search?: string,
@Query('role') role?: UserRole,
@Query('limit') limit?: number,
) {
return this.adminService.getUsers(user.id, { search, role, limit });
}
@Patch('users/:id/role')
async updateUserRole(
@CurrentUser() user: User,
@Param('id') targetUserId: string,
@Body('role') role: UserRole,
) {
return this.adminService.updateUserRole(user.id, targetUserId, role);
}
@Get('payments')
async getPayments(
@CurrentUser() user: User,
@Query('mode') mode?: PaymentMode,
@Query('provider') provider?: PaymentProvider,
@Query('status') status?: string,
@Query('search') search?: string,
@Query('limit') limit?: number,
): Promise<any> {
return this.adminService.getPayments(user.id, { mode, provider, status, search, limit });
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AccessModule } from '../common/access/access.module';
import { UsersModule } from '../users/users.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AccessModule, UsersModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { PaymentMode, PaymentProvider, UserRole } from '@coursecraft/database';
import { PrismaService } from '../common/prisma/prisma.service';
import { AccessService } from '../common/access/access.service';
import { UsersService } from '../users/users.service';
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private access: AccessService,
private usersService: UsersService,
) {}
async getUsers(adminUserId: string, options?: { search?: string; role?: UserRole; limit?: number }) {
await this.access.assertAdmin(adminUserId);
return this.usersService.listUsers(options);
}
async updateUserRole(adminUserId: string, targetUserId: string, role: UserRole) {
await this.access.assertAdmin(adminUserId);
return this.usersService.updateRole(targetUserId, role);
}
async getPayments(
adminUserId: string,
filters?: {
mode?: PaymentMode;
provider?: PaymentProvider;
status?: string;
search?: string;
limit?: number;
}
): Promise<any> {
await this.access.assertAdmin(adminUserId);
const limit = Math.min(300, Math.max(1, filters?.limit || 150));
const where: any = {};
if (filters?.mode) where.mode = filters.mode;
if (filters?.provider) where.provider = filters.provider;
if (filters?.status) where.status = filters.status;
if (filters?.search?.trim()) {
const term = filters.search.trim();
where.OR = [
{ user: { email: { contains: term, mode: 'insensitive' } } },
{ user: { name: { contains: term, mode: 'insensitive' } } },
{ course: { title: { contains: term, mode: 'insensitive' } } },
];
}
return this.prisma.purchase.findMany({
where,
include: {
user: {
select: { id: true, email: true, name: true, avatarUrl: true },
},
course: {
select: { id: true, title: true, slug: true, authorId: true },
},
},
orderBy: { createdAt: 'desc' },
take: limit,
});
}
}

View File

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

View File

@ -43,6 +43,6 @@ import { UsersModule } from '../users/users.module';
useClass: JwtAuthGuard, useClass: JwtAuthGuard,
}, },
], ],
exports: [AuthService, SupabaseService, JwtAuthGuard], exports: [AuthService, SupabaseService, JwtAuthGuard, JwtModule],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -55,6 +55,7 @@ export class AuthService {
name: user.name, name: user.name,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
subscriptionTier: user.subscriptionTier, subscriptionTier: user.subscriptionTier,
role: user.role,
}, },
}; };
} }

View File

@ -32,9 +32,9 @@ export class CatalogController {
@Post(':id/submit') @Post(':id/submit')
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Submit course for review / publish' }) @ApiOperation({ summary: 'Submit course for moderation review' })
async submitForReview(@Param('id') id: string, @CurrentUser() user: User): Promise<any> { async submitForReview(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.catalogService.publishCourse(id, user.id); return this.catalogService.submitForReview(id, user.id);
} }
@Patch(':id/verify') @Patch(':id/verify')
@ -43,4 +43,12 @@ export class CatalogController {
async toggleVerify(@Param('id') id: string, @CurrentUser() user: User): Promise<any> { async toggleVerify(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.catalogService.toggleVerification(id, user.id); return this.catalogService.toggleVerification(id, user.id);
} }
@Post(':id/checkout')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Create one-time checkout session for paid course' })
async checkoutCourse(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.catalogService.createCourseCheckout(id, user.id);
}
} }

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CatalogController } from './catalog.controller'; import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service'; import { CatalogService } from './catalog.service';
import { PaymentsModule } from '../payments/payments.module';
@Module({ @Module({
imports: [PaymentsModule],
controllers: [CatalogController], controllers: [CatalogController],
providers: [CatalogService], providers: [CatalogService],
exports: [CatalogService], exports: [CatalogService],

View File

@ -1,10 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { CourseStatus } from '@coursecraft/database'; import { CourseStatus } from '@coursecraft/database';
import { PaymentsService } from '../payments/payments.service';
@Injectable() @Injectable()
export class CatalogService { export class CatalogService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private paymentsService: PaymentsService
) {}
async getPublishedCourses(options?: { async getPublishedCourses(options?: {
page?: number; page?: number;
@ -92,21 +96,38 @@ export class CatalogService {
} }
async submitForReview(courseId: string, userId: string): Promise<any> { async submitForReview(courseId: string, userId: string): Promise<any> {
return this.prisma.course.update({ const course = await this.prisma.course.findUnique({ where: { id: courseId } });
where: { id: courseId, authorId: userId }, if (!course) {
data: { status: CourseStatus.PENDING_REVIEW }, throw new NotFoundException('Course not found');
}); }
if (course.authorId !== userId) {
throw new ForbiddenException('Only course author can submit for moderation');
} }
async publishCourse(courseId: string, userId: string): Promise<any> { const fromStatus = course.status;
return this.prisma.course.update({ const updated = await this.prisma.course.update({
where: { id: courseId, authorId: userId }, where: { id: courseId },
data: { data: {
status: CourseStatus.PUBLISHED, status: CourseStatus.PENDING_MODERATION,
isPublished: true, isPublished: false,
publishedAt: new Date(),
}, },
}); });
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> {
return this.paymentsService.createCourseCheckoutSession(userId, courseId);
} }
async toggleVerification(courseId: string, userId: string): Promise<any> { async toggleVerification(courseId: string, userId: string): Promise<any> {

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AccessService } from './access.service';
@Module({
imports: [PrismaModule],
providers: [AccessService],
exports: [AccessService],
})
export class AccessModule {}

View File

@ -0,0 +1,61 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { UserRole } from '@coursecraft/database';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AccessService {
constructor(private prisma: PrismaService) {}
async getUserRole(userId: string): Promise<UserRole> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
return user.role;
}
async assertStaff(userId: string): Promise<void> {
const role = await this.getUserRole(userId);
if (role !== UserRole.MODERATOR && role !== UserRole.ADMIN) {
throw new ForbiddenException('Staff access only');
}
}
async assertAdmin(userId: string): Promise<void> {
const role = await this.getUserRole(userId);
if (role !== UserRole.ADMIN) {
throw new ForbiddenException('Admin access only');
}
}
async assertCourseOwner(courseId: string, userId: string): Promise<void> {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { id: true, authorId: true },
});
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('Only course author can perform this action');
}
}
async assertCourseOwnerOrStaff(courseId: string, userId: string): Promise<void> {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { id: true, authorId: true },
});
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId === userId) {
return;
}
await this.assertStaff(userId);
}
}

View File

@ -0,0 +1,11 @@
import { CourseStatus } from '@coursecraft/database';
export const COURSE_PENDING_STATUSES: CourseStatus[] = [
CourseStatus.PENDING_MODERATION,
CourseStatus.PENDING_REVIEW, // backward compatibility
];
export function isPendingModeration(status: CourseStatus): boolean {
return COURSE_PENDING_STATUSES.includes(status);
}

View File

@ -0,0 +1,19 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/decorators/public.decorator';
import { CooperationService } from './cooperation.service';
import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto';
@ApiTags('cooperation')
@Controller('cooperation')
export class CooperationController {
constructor(private cooperationService: CooperationService) {}
@Public()
@Post('requests')
@ApiOperation({ summary: 'Create cooperation request from landing page' })
async createRequest(@Body() dto: CreateCooperationRequestDto) {
return this.cooperationService.createRequest(dto);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CooperationController } from './cooperation.controller';
import { CooperationService } from './cooperation.service';
@Module({
controllers: [CooperationController],
providers: [CooperationService],
})
export class CooperationModule {}

View File

@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma/prisma.service';
import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto';
@Injectable()
export class CooperationService {
constructor(
private prisma: PrismaService,
private config: ConfigService,
) {}
async createRequest(dto: CreateCooperationRequestDto) {
const created = await this.prisma.cooperationRequest.create({
data: {
organization: dto.organization,
contactName: dto.contactName,
email: dto.email,
phone: dto.phone,
role: dto.role,
organizationType: dto.organizationType,
message: dto.message,
source: 'landing',
status: 'new',
},
});
const emailError = await this.trySendEmail(created).catch((error) => error as Error);
if (emailError) {
await this.prisma.cooperationRequest.update({
where: { id: created.id },
data: {
status: 'stored_email_failed',
},
});
}
return {
id: created.id,
status: emailError ? 'stored_email_failed' : 'stored_and_sent',
};
}
private async trySendEmail(request: {
id: string;
organization: string;
contactName: string;
email: string;
phone: string | null;
role: string | null;
organizationType: string | null;
message: string;
createdAt: Date;
}) {
const host = this.config.get<string>('SMTP_HOST');
const portRaw = this.config.get<string>('SMTP_PORT');
const user = this.config.get<string>('SMTP_USER');
const pass = this.config.get<string>('SMTP_PASS');
const secureRaw = this.config.get<string>('SMTP_SECURE');
const to = this.config.get<string>('COOPERATION_EMAIL_TO') || 'exbytestudios@gmail.com';
const from = this.config.get<string>('COOPERATION_EMAIL_FROM') || user;
if (!host || !portRaw || !user || !pass || !from) {
throw new Error('SMTP is not configured');
}
const nodemailer = await import('nodemailer');
const transporter = nodemailer.createTransport({
host,
port: Number(portRaw),
secure: String(secureRaw || '').toLowerCase() === 'true',
auth: { user, pass },
});
const text = [
'Новая заявка на сотрудничество (CourseCraft)',
`ID: ${request.id}`,
`Организация: ${request.organization}`,
`Контакт: ${request.contactName}`,
`Email: ${request.email}`,
`Телефон: ${request.phone || '—'}`,
`Роль: ${request.role || '—'}`,
`Тип организации: ${request.organizationType || '—'}`,
`Создано: ${request.createdAt.toISOString()}`,
'',
'Сообщение:',
request.message,
].join('\n');
await transporter.sendMail({
from,
to,
subject: `[CourseCraft] Cooperation request: ${request.organization}`,
text,
});
}
}

View File

@ -0,0 +1,45 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateCooperationRequestDto {
@ApiProperty({ example: 'Tech College #17' })
@IsString()
@MinLength(2)
@MaxLength(160)
organization: string;
@ApiProperty({ example: 'Иван Петров' })
@IsString()
@MinLength(2)
@MaxLength(120)
contactName: string;
@ApiProperty({ example: 'partner@example.edu' })
@IsEmail()
email: string;
@ApiPropertyOptional({ example: '+7 900 000-00-00' })
@IsOptional()
@IsString()
@MaxLength(80)
phone?: string;
@ApiPropertyOptional({ example: 'Руководитель цифрового обучения' })
@IsOptional()
@IsString()
@MaxLength(120)
role?: string;
@ApiPropertyOptional({ example: 'college' })
@IsOptional()
@IsString()
@MaxLength(80)
organizationType?: string;
@ApiProperty({ example: 'Хотим пилот на 300 студентов и интеграцию с LMS.' })
@IsString()
@MinLength(10)
@MaxLength(5000)
message: string;
}

View File

@ -0,0 +1,38 @@
import { Controller, Get, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { CourseSourcesService } from './course-sources.service';
@ApiTags('course-sources')
@Controller('courses/:courseId/sources')
@ApiBearerAuth()
export class CourseSourcesController {
constructor(private sourcesService: CourseSourcesService) {}
@Post('upload')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 25 * 1024 * 1024 },
}),
)
async upload(
@Param('courseId') courseId: string,
@UploadedFile() file: any,
@CurrentUser() user: User,
): Promise<any> {
return this.sourcesService.uploadSource(courseId, user.id, file);
}
@Get()
async list(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.sourcesService.getSources(courseId, user.id);
}
@Get('outline-hints')
async getOutlineHints(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.sourcesService.buildOutlineHints(courseId, user.id);
}
}

View File

@ -0,0 +1,158 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CourseSourceParseStatus, CourseSourceType } from '@coursecraft/database';
import { basename, extname, join } from 'path';
import { promises as fs } from 'fs';
import pdfParse from 'pdf-parse';
import { PrismaService } from '../common/prisma/prisma.service';
import { AccessService } from '../common/access/access.service';
@Injectable()
export class CourseSourcesService {
constructor(
private prisma: PrismaService,
private access: AccessService,
) {}
async uploadSource(courseId: string, userId: string, file: any): Promise<any> {
if (!file) {
throw new NotFoundException('File is required');
}
await this.access.assertCourseOwner(courseId, userId);
const sourceType = this.resolveSourceType(file.originalname, file.mimetype);
const storageDir = join('/tmp', 'coursecraft_uploads', courseId);
await fs.mkdir(storageDir, { recursive: true });
const timestamp = Date.now();
const safeName = `${timestamp}-${basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const storagePath = join(storageDir, safeName);
await fs.writeFile(storagePath, file.buffer);
let parseStatus: CourseSourceParseStatus = CourseSourceParseStatus.SKIPPED;
let extractedText: string | null = null;
let extractedMeta: any = null;
try {
if (sourceType === CourseSourceType.TXT) {
extractedText = file.buffer.toString('utf8');
parseStatus = CourseSourceParseStatus.PARSED;
extractedMeta = { method: 'utf8', chars: extractedText?.length || 0 };
} else if (sourceType === CourseSourceType.PDF) {
const parsed = await pdfParse(file.buffer);
extractedText = (parsed.text || '').trim();
parseStatus = CourseSourceParseStatus.PARSED;
extractedMeta = {
method: 'pdf-parse',
pages: parsed.numpages || null,
chars: extractedText?.length || 0,
};
}
} catch (error: any) {
parseStatus = CourseSourceParseStatus.FAILED;
extractedMeta = {
method: sourceType === CourseSourceType.PDF ? 'pdf-parse' : 'unknown',
error: error?.message || 'Parse failed',
};
}
const created = await this.prisma.courseSourceFile.create({
data: {
courseId,
uploadedById: userId,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
fileSize: file.size || file.buffer.length,
sourceType,
storagePath,
parseStatus,
extractedText,
extractedMeta,
},
select: {
id: true,
fileName: true,
mimeType: true,
fileSize: true,
sourceType: true,
parseStatus: true,
extractedMeta: true,
createdAt: true,
},
});
return {
...created,
extractedPreview:
parseStatus === CourseSourceParseStatus.PARSED && extractedText
? extractedText.slice(0, 500)
: null,
};
}
async getSources(courseId: string, userId: string): Promise<any> {
await this.access.assertCourseOwnerOrStaff(courseId, userId);
return this.prisma.courseSourceFile.findMany({
where: { courseId },
select: {
id: true,
fileName: true,
mimeType: true,
fileSize: true,
sourceType: true,
parseStatus: true,
extractedMeta: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
async buildOutlineHints(courseId: string, userId: string): Promise<any> {
await this.access.assertCourseOwnerOrStaff(courseId, userId);
const files = await this.prisma.courseSourceFile.findMany({
where: { courseId, parseStatus: CourseSourceParseStatus.PARSED },
select: { id: true, fileName: true, sourceType: true, extractedText: true },
orderBy: { createdAt: 'asc' },
take: 10,
});
const mergedText = files.map((f) => f.extractedText || '').join('\n\n').trim();
if (!mergedText) {
return { filesCount: files.length, hints: [], summary: null };
}
const lines = mergedText
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 8);
const headingCandidates = lines
.filter((line) => /^(\\d+[.)]\\s+|[A-ZА-ЯЁ][^.!?]{8,80}$)/.test(line))
.slice(0, 24);
const hints = headingCandidates.slice(0, 8).map((line, idx) => ({
id: `hint_${idx + 1}`,
title: line.replace(/^\\d+[.)]\\s+/, ''),
}));
return {
filesCount: files.length,
summary: `Найдено ${files.length} источников с текстом. Сформированы рекомендации по структуре.`,
hints,
};
}
private resolveSourceType(fileName: string, mimeType: string): CourseSourceType {
const ext = extname(fileName).toLowerCase();
if (mimeType.startsWith('image/') || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'].includes(ext)) {
return CourseSourceType.IMAGE;
}
if (ext === '.pdf' || mimeType.includes('pdf')) return CourseSourceType.PDF;
if (ext === '.docx') return CourseSourceType.DOCX;
if (ext === '.txt' || mimeType.includes('text/plain')) return CourseSourceType.TXT;
if (ext === '.pptx') return CourseSourceType.PPTX;
if (ext === '.zip' || mimeType.includes('zip')) return CourseSourceType.ZIP;
return CourseSourceType.OTHER;
}
}

View File

@ -16,6 +16,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User, CourseStatus } from '@coursecraft/database'; import { User, CourseStatus } from '@coursecraft/database';
import { CreateCourseDto } from './dto/create-course.dto'; import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto';
import { ReviewHomeworkDto } from './dto/review-homework.dto';
@ApiTags('courses') @ApiTags('courses')
@Controller('courses') @Controller('courses')
@ -75,4 +76,24 @@ export class CoursesController {
): Promise<any> { ): Promise<any> {
return this.coursesService.updateStatus(id, user.id, status); return this.coursesService.updateStatus(id, user.id, status);
} }
@Post(':id/homework-submissions/:submissionId/review')
@ApiOperation({ summary: 'Review homework submission as course author' })
async reviewHomework(
@Param('id') id: string,
@Param('submissionId') submissionId: string,
@CurrentUser() user: User,
@Body() dto: ReviewHomeworkDto
): Promise<any> {
return this.coursesService.reviewHomeworkSubmission(id, submissionId, user.id, dto);
}
@Get(':id/homework-submissions')
@ApiOperation({ summary: 'Get homework submissions for course author review' })
async getHomeworkSubmissions(
@Param('id') id: string,
@CurrentUser() user: User
): Promise<any> {
return this.coursesService.getHomeworkSubmissionsForAuthor(id, user.id);
}
} }

View File

@ -5,10 +5,14 @@ import { ChaptersController } from './chapters.controller';
import { ChaptersService } from './chapters.service'; import { ChaptersService } from './chapters.service';
import { LessonsController } from './lessons.controller'; import { LessonsController } from './lessons.controller';
import { LessonsService } from './lessons.service'; import { LessonsService } from './lessons.service';
import { CourseSourcesController } from './course-sources.controller';
import { CourseSourcesService } from './course-sources.service';
import { AccessModule } from '../common/access/access.module';
@Module({ @Module({
controllers: [CoursesController, ChaptersController, LessonsController], imports: [AccessModule],
providers: [CoursesService, ChaptersService, LessonsService], controllers: [CoursesController, ChaptersController, LessonsController, CourseSourcesController],
exports: [CoursesService, ChaptersService, LessonsService], providers: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
exports: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
}) })
export class CoursesModule {} export class CoursesModule {}

View File

@ -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 { Course, CourseStatus } from '@coursecraft/database'; import { Course, CourseStatus, Prisma } from '@coursecraft/database';
import { generateUniqueSlug } from '@coursecraft/shared'; import { generateUniqueSlug } from '@coursecraft/shared';
import { CreateCourseDto } from './dto/create-course.dto'; import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto';
@ -12,13 +12,20 @@ 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,
description: dto.description, description: dto.description,
slug, slug,
status: CourseStatus.DRAFT, status: CourseStatus.DRAFT,
groups: {
create: {
name: 'Основная группа',
description: 'Обсуждение курса и вопросы преподавателю',
isDefault: true,
},
},
}, },
include: { include: {
chapters: { chapters: {
@ -29,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(
@ -117,6 +136,7 @@ export class CoursesService {
orderBy: { order: 'asc' }, orderBy: { order: 'asc' },
}, },
category: true, category: true,
groups: true,
}, },
}); });
} }
@ -167,6 +187,12 @@ export class CoursesService {
estimatedHours: dto.estimatedHours, estimatedHours: dto.estimatedHours,
metaTitle: dto.metaTitle, metaTitle: dto.metaTitle,
metaDescription: dto.metaDescription, metaDescription: dto.metaDescription,
...(dto.price !== undefined
? {
price: dto.price > 0 ? new Prisma.Decimal(dto.price) : null,
}
: {}),
...(dto.currency ? { currency: dto.currency.toUpperCase() } : {}),
}, },
include: { include: {
chapters: { chapters: {
@ -208,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');
} }
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status }; if (status === CourseStatus.PUBLISHED || status === CourseStatus.APPROVED) {
throw new ForbiddenException('Course can be published only by moderation');
if (status === CourseStatus.PUBLISHED && !course.publishedAt) {
updateData.publishedAt = new Date();
} }
return this.prisma.course.update({ const nextStatus = status === CourseStatus.PENDING_REVIEW ? CourseStatus.PENDING_MODERATION : status;
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status: nextStatus };
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> {
@ -228,4 +266,91 @@ export class CoursesService {
return course?.authorId === userId; return course?.authorId === userId;
} }
async reviewHomeworkSubmission(
courseId: string,
submissionId: string,
authorId: string,
dto: { teacherScore: number; teacherFeedback?: string }
) {
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 !== authorId) {
throw new ForbiddenException('Only course author can review homework');
}
const submission = await this.prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
homework: {
include: {
lesson: {
include: {
chapter: {
select: { courseId: true },
},
},
},
},
},
},
});
if (!submission || submission.homework.lesson.chapter.courseId !== courseId) {
throw new NotFoundException('Homework submission not found');
}
return this.prisma.homeworkSubmission.update({
where: { id: submissionId },
data: {
teacherScore: dto.teacherScore,
teacherFeedback: dto.teacherFeedback,
reviewStatus: 'TEACHER_REVIEWED',
gradedAt: new Date(),
},
});
}
async getHomeworkSubmissionsForAuthor(courseId: string, authorId: string) {
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 !== authorId) {
throw new ForbiddenException('Only course author can view submissions');
}
return this.prisma.homeworkSubmission.findMany({
where: {
homework: {
lesson: {
chapter: {
courseId,
},
},
},
},
include: {
user: {
select: { id: true, name: true, email: true, avatarUrl: true },
},
homework: {
include: {
lesson: {
select: { id: true, title: true },
},
},
},
},
orderBy: [{ reviewStatus: 'asc' }, { submittedAt: 'desc' }],
});
}
} }

View File

@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ReviewHomeworkDto {
@ApiProperty({ description: 'Teacher score from 1 to 5', minimum: 1, maximum: 5 })
@IsInt()
@Min(1)
@Max(5)
teacherScore: number;
@ApiPropertyOptional({ description: 'Teacher feedback' })
@IsOptional()
@IsString()
teacherFeedback?: string;
}

View File

@ -8,6 +8,7 @@ import {
MinLength, MinLength,
MaxLength, MaxLength,
Min, Min,
IsIn,
} from 'class-validator'; } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { CourseStatus } from '@coursecraft/database'; import { CourseStatus } from '@coursecraft/database';
@ -68,4 +69,17 @@ export class UpdateCourseDto {
@IsString() @IsString()
@MaxLength(300) @MaxLength(300)
metaDescription?: string; metaDescription?: string;
@ApiPropertyOptional({ description: 'Course price. Null or 0 means free' })
@IsOptional()
@IsNumber()
@Min(0)
price?: number;
@ApiPropertyOptional({ description: 'Currency (ISO code)', example: 'USD' })
@IsOptional()
@IsString()
@MaxLength(3)
@IsIn(['USD', 'EUR', 'RUB'])
currency?: string;
} }

View File

@ -70,4 +70,15 @@ export class LessonsController {
async generateQuiz(@Param('lessonId') lessonId: string): Promise<any> { async generateQuiz(@Param('lessonId') lessonId: string): Promise<any> {
return this.lessonsService.generateQuiz(lessonId); return this.lessonsService.generateQuiz(lessonId);
} }
@Post('lessons/:lessonId/homework/generate')
@ApiOperation({ summary: 'Generate homework task for lesson (author only)' })
async generateHomework(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@CurrentUser() user: User,
@Body('type') type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'
): Promise<any> {
return this.lessonsService.generateHomework(courseId, lessonId, user.id, type);
}
} }

View File

@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { Lesson } from '@coursecraft/database'; import { HomeworkType, Lesson } from '@coursecraft/database';
import { CoursesService } from './courses.service'; import { CoursesService } from './courses.service';
import { ChaptersService } from './chapters.service'; import { ChaptersService } from './chapters.service';
import { CreateLessonDto } from './dto/create-lesson.dto'; import { CreateLessonDto } from './dto/create-lesson.dto';
@ -268,4 +268,92 @@ ${textContent.slice(0, 3000)}
}, },
]; ];
} }
async generateHomework(
courseId: string,
lessonId: string,
userId: string,
type: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB' = 'TEXT'
): Promise<any> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: {
id: true,
title: true,
courseId: true,
},
},
},
});
if (!lesson || lesson.chapter.courseId !== courseId) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
if (!isOwner) {
throw new ForbiddenException('Only course author can generate homework');
}
const homeworkType = HomeworkType[type] ? (type as HomeworkType) : HomeworkType.TEXT;
const template = this.buildHomeworkTemplate(lesson.title, homeworkType);
return this.prisma.homework.upsert({
where: { lessonId },
create: {
lessonId,
title: template.title,
description: template.description,
type: homeworkType,
config: template.config as any,
},
update: {
title: template.title,
description: template.description,
type: homeworkType,
config: template.config as any,
},
});
}
private buildHomeworkTemplate(lessonTitle: string, type: HomeworkType): {
title: string;
description: string;
config: Record<string, unknown>;
} {
if (type === HomeworkType.FILE) {
return {
title: `Практическая работа (файл): ${lessonTitle}`,
description: 'Подготовьте файл с выполненным заданием и приложите ссылку/файл в ответе.',
config: { acceptedFormats: ['pdf', 'docx', 'txt', 'zip'] },
};
}
if (type === HomeworkType.PROJECT) {
return {
title: `Мини-проект: ${lessonTitle}`,
description: 'Сделайте небольшой проект по материалу урока. Опишите архитектуру и результат.',
config: { rubric: ['Понимание темы', 'Практичность', 'Качество решения'] },
};
}
if (type === HomeworkType.GITHUB) {
return {
title: `GitHub задача: ${lessonTitle}`,
description: 'Выполните задачу и приложите ссылку на публичный GitHub-репозиторий.',
config: { requireGithubUrl: true },
};
}
if (type === HomeworkType.QUIZ) {
return {
title: `Тест-кейс: ${lessonTitle}`,
description: 'Ответьте на контрольные вопросы и приложите обоснование решений.',
config: { requireExplanation: true },
};
}
return {
title: `Письменное домашнее задание: ${lessonTitle}`,
description:
'Опишите, как вы примените изученную тему на практике. Приведите примеры, обоснования и собственные выводы.',
config: { minLength: 200 },
};
}
} }

View File

@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateReviewDto {
@ApiProperty({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
@IsInt()
@Min(1)
@Max(5)
rating: number;
@ApiPropertyOptional({ description: 'Review title' })
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@ApiPropertyOptional({ description: 'Review content' })
@IsOptional()
@IsString()
@MaxLength(4000)
content?: string;
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { HomeworkType } from '@coursecraft/database';
import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SubmitHomeworkDto {
@ApiProperty({
description: 'Written homework answer',
minLength: 1,
maxLength: 20000,
})
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(20000)
content?: string;
@ApiProperty({ enum: HomeworkType, default: HomeworkType.TEXT })
@IsOptional()
@IsEnum(HomeworkType)
type?: HomeworkType;
@ApiProperty({ required: false, description: 'File URL for FILE/PROJECT submission' })
@IsOptional()
@IsString()
@MaxLength(1024)
attachmentUrl?: string;
@ApiProperty({ required: false, description: 'GitHub repository URL' })
@IsOptional()
@IsString()
@MaxLength(1024)
githubUrl?: string;
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsInt, Min } from 'class-validator';
export class SubmitQuizDto {
@ApiProperty({
description: 'Selected option index for each quiz question',
type: [Number],
example: [0, 2, 1],
})
@IsArray()
@ArrayMinSize(1)
@IsInt({ each: true })
@Min(0, { each: true })
answers: number[];
}

View File

@ -12,6 +12,9 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { EnrollmentService } from './enrollment.service'; import { EnrollmentService } from './enrollment.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database'; import { User } from '@coursecraft/database';
import { SubmitQuizDto } from './dto/submit-quiz.dto';
import { CreateReviewDto } from './dto/create-review.dto';
import { SubmitHomeworkDto } from './dto/submit-homework.dto';
@ApiTags('enrollment') @ApiTags('enrollment')
@Controller('enrollment') @Controller('enrollment')
@ -55,10 +58,32 @@ export class EnrollmentController {
async submitQuiz( async submitQuiz(
@Param('courseId') courseId: string, @Param('courseId') courseId: string,
@Param('lessonId') lessonId: string, @Param('lessonId') lessonId: string,
@Body('score') score: number, @Body() dto: SubmitQuizDto,
@CurrentUser() user: User, @CurrentUser() user: User,
): Promise<any> { ): Promise<any> {
return this.enrollmentService.saveQuizScore(user.id, courseId, lessonId, score); return this.enrollmentService.submitQuiz(user.id, courseId, lessonId, dto.answers);
}
@Get(':courseId/lessons/:lessonId/homework')
@ApiOperation({ summary: 'Get (or lazy-create) homework for lesson' })
async getHomework(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.getHomework(user.id, courseId, lessonId);
}
@Post(':courseId/lessons/:lessonId/homework')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Submit written homework for lesson' })
async submitHomework(
@Param('courseId') courseId: string,
@Param('lessonId') lessonId: string,
@Body() dto: SubmitHomeworkDto,
@CurrentUser() user: User,
): Promise<any> {
return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto);
} }
@Post(':courseId/review') @Post(':courseId/review')
@ -66,7 +91,7 @@ export class EnrollmentController {
@ApiOperation({ summary: 'Leave a review' }) @ApiOperation({ summary: 'Leave a review' })
async createReview( async createReview(
@Param('courseId') courseId: string, @Param('courseId') courseId: string,
@Body() body: { rating: number; title?: string; content?: string }, @Body() body: CreateReviewDto,
@CurrentUser() user: User, @CurrentUser() user: User,
): Promise<any> { ): Promise<any> {
return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content); return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content);

View File

@ -1,20 +1,47 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { HomeworkReviewStatus, HomeworkType } from '@coursecraft/database';
const QUIZ_PASS_THRESHOLD = 70;
@Injectable() @Injectable()
export class EnrollmentService { export class EnrollmentService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async enroll(userId: string, courseId: string): Promise<any> { async enroll(userId: string, courseId: string): Promise<any> {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { id: true, price: true, title: true, slug: true },
});
if (!course) throw new NotFoundException('Course not found');
if (course.price) {
const purchase = await this.prisma.purchase.findUnique({
where: { userId_courseId: { userId, courseId } },
});
if (!purchase || purchase.status !== 'completed') {
throw new ForbiddenException('Purchase is required for paid course');
}
}
const existing = await this.prisma.enrollment.findUnique({ const existing = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } }, where: { userId_courseId: { userId, courseId } },
}); });
if (existing) throw new ConflictException('Already enrolled'); if (existing) throw new ConflictException('Already enrolled');
return this.prisma.enrollment.create({ const enrollment = await this.prisma.enrollment.create({
data: { userId, courseId }, data: { userId, courseId },
include: { course: { select: { id: true, title: true, slug: true } } }, include: { course: { select: { id: true, title: true, slug: true } } },
}); });
await this.addUserToDefaultCourseGroup(courseId, userId);
return enrollment;
} }
async getUserEnrollments(userId: string): Promise<any> { async getUserEnrollments(userId: string): Promise<any> {
@ -34,10 +61,56 @@ export class EnrollmentService {
} }
async completeLesson(userId: string, courseId: string, lessonId: string): Promise<any> { async completeLesson(userId: string, courseId: string, lessonId: string): Promise<any> {
const enrollment = await this.prisma.enrollment.findUnique({ const enrollment = await this.requireEnrollment(userId, courseId);
where: { userId_courseId: { userId, courseId } }, await this.assertLessonUnlocked(userId, courseId, lessonId);
const progress = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId } },
}); });
if (!enrollment) throw new NotFoundException('Not enrolled in this course'); if (!progress?.quizPassed || !progress?.homeworkSubmitted) {
throw new BadRequestException('Lesson can be completed only after quiz and homework');
}
const updated = await this.prisma.lessonProgress.update({
where: { userId_lessonId: { userId, lessonId } },
data: { completedAt: new Date() },
});
await this.recalculateProgress(enrollment.id, courseId);
return updated;
}
async submitQuiz(userId: string, courseId: string, lessonId: string, answers: number[]): Promise<any> {
const enrollment = await this.requireEnrollment(userId, courseId);
await this.assertLessonUnlocked(userId, courseId, lessonId);
const quiz = await this.prisma.quiz.findUnique({
where: { lessonId },
});
if (!quiz) {
throw new NotFoundException('Quiz not found for this lesson');
}
const questions = Array.isArray(quiz.questions) ? (quiz.questions as any[]) : [];
if (questions.length === 0) {
throw new BadRequestException('Quiz has no questions');
}
if (!Array.isArray(answers) || answers.length !== questions.length) {
throw new BadRequestException('Answers count must match questions count');
}
const correctAnswers = questions.reduce((acc, question, index) => {
const expected = Number(question.correctAnswer);
const actual = Number(answers[index]);
return acc + (expected === actual ? 1 : 0);
}, 0);
const score = Math.round((correctAnswers / questions.length) * 100);
const passed = score >= QUIZ_PASS_THRESHOLD;
const existing = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId } },
});
const finalPassed = Boolean(existing?.quizPassed || passed);
const completedAt = finalPassed && existing?.homeworkSubmitted ? new Date() : existing?.completedAt;
const progress = await this.prisma.lessonProgress.upsert({ const progress = await this.prisma.lessonProgress.upsert({
where: { userId_lessonId: { userId, lessonId } }, where: { userId_lessonId: { userId, lessonId } },
@ -45,78 +118,240 @@ export class EnrollmentService {
userId, userId,
enrollmentId: enrollment.id, enrollmentId: enrollment.id,
lessonId, lessonId,
completedAt: new Date(), quizScore: score,
quizPassed: finalPassed,
quizPassedAt: finalPassed ? new Date() : null,
completedAt: completedAt || null,
}, },
update: { update: {
completedAt: new Date(), quizScore: score,
quizPassed: finalPassed,
quizPassedAt: finalPassed ? existing?.quizPassedAt || new Date() : null,
completedAt: completedAt || null,
}, },
}); });
await this.recalculateProgress(enrollment.id, courseId); await this.recalculateProgress(enrollment.id, courseId);
return progress; return {
score,
passed,
passThreshold: QUIZ_PASS_THRESHOLD,
totalQuestions: questions.length,
correctAnswers,
progress,
};
} }
async saveQuizScore(userId: string, courseId: string, lessonId: string, score: number): Promise<any> { async getHomework(userId: string, courseId: string, lessonId: string): Promise<any> {
const enrollment = await this.prisma.enrollment.findUnique({ await this.requireEnrollment(userId, courseId);
where: { userId_courseId: { userId, courseId } }, await this.assertLessonUnlocked(userId, courseId, lessonId);
});
if (!enrollment) throw new NotFoundException('Not enrolled in this course');
return this.prisma.lessonProgress.upsert({ const lesson = await this.prisma.lesson.findFirst({
where: { id: lessonId, chapter: { courseId } },
select: { id: true, title: true },
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const homework = await this.prisma.homework.upsert({
where: { lessonId: lesson.id },
create: {
lessonId: lesson.id,
title: `Письменное домашнее задание: ${lesson.title}`,
description:
'Опишите, как вы примените изученную тему на практике. Приведите примеры, обоснования и собственные выводы.',
},
update: {},
});
const submission = await this.prisma.homeworkSubmission.findUnique({
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
});
return { homework, submission };
}
async submitHomework(
userId: string,
courseId: string,
lessonId: string,
dto: { content?: string; type?: HomeworkType; attachmentUrl?: string; githubUrl?: string }
): Promise<any> {
const enrollment = await this.requireEnrollment(userId, courseId);
await this.assertLessonUnlocked(userId, courseId, lessonId);
const lessonProgress = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId } },
});
if (!lessonProgress?.quizPassed) {
throw new BadRequestException('Pass the quiz before submitting homework');
}
const { homework } = await this.getHomework(userId, courseId, lessonId);
const submissionType = dto.type || homework.type || HomeworkType.TEXT;
const normalizedContent = (dto.content || '').trim();
if (!normalizedContent && !dto.attachmentUrl && !dto.githubUrl) {
throw new BadRequestException('Provide content, attachment URL, or GitHub URL');
}
const fallbackContent =
normalizedContent ||
dto.githubUrl ||
dto.attachmentUrl ||
`Submission type: ${submissionType}`;
const aiResult = this.gradeHomeworkWithAI(fallbackContent);
const submission = await this.prisma.homeworkSubmission.upsert({
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
create: {
homeworkId: homework.id,
userId,
content: fallbackContent,
answerType: submissionType,
attachmentUrl: dto.attachmentUrl || null,
githubUrl: dto.githubUrl || null,
aiScore: aiResult.score,
aiFeedback: aiResult.feedback,
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
},
update: {
content: fallbackContent,
answerType: submissionType,
attachmentUrl: dto.attachmentUrl || null,
githubUrl: dto.githubUrl || null,
aiScore: aiResult.score,
aiFeedback: aiResult.feedback,
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
submittedAt: new Date(),
},
});
await this.prisma.lessonProgress.upsert({
where: { userId_lessonId: { userId, lessonId } }, where: { userId_lessonId: { userId, lessonId } },
create: { create: {
userId, userId,
enrollmentId: enrollment.id, enrollmentId: enrollment.id,
lessonId, lessonId,
quizScore: score, quizScore: lessonProgress.quizScore,
quizPassed: true,
quizPassedAt: lessonProgress.quizPassedAt || new Date(),
homeworkSubmitted: true,
homeworkSubmittedAt: new Date(),
completedAt: new Date(),
},
update: {
homeworkSubmitted: true,
homeworkSubmittedAt: new Date(),
completedAt: new Date(), completedAt: new Date(),
}, },
update: { quizScore: score },
}); });
await this.recalculateProgress(enrollment.id, courseId);
return submission;
} }
async getProgress(userId: string, courseId: string): Promise<any> { async getProgress(userId: string, courseId: string): Promise<any> {
return this.prisma.enrollment.findUnique({ return this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } }, where: { userId_courseId: { userId, courseId } },
include: { lessons: true }, include: {
lessons: true,
},
}); });
} }
async createReview(userId: string, courseId: string, rating: number, title?: string, content?: string): Promise<any> { async createReview(userId: string, courseId: string, rating: number, title?: string, content?: string): Promise<any> {
await this.requireEnrollment(userId, courseId);
const review = await this.prisma.review.upsert({ const review = await this.prisma.review.upsert({
where: { userId_courseId: { userId, courseId } }, where: { userId_courseId: { userId, courseId } },
create: { userId, courseId, rating, title, content }, create: { userId, courseId, rating, title, content, isApproved: true },
update: { rating, title, content }, update: { rating, title, content, isApproved: true },
}); });
// Recalculate avg rating await this.recalculateAverageRating(courseId);
const result = await this.prisma.review.aggregate({
where: { courseId, isApproved: true },
_avg: { rating: true },
});
if (result._avg.rating !== null) {
await this.prisma.course.update({
where: { id: courseId },
data: { averageRating: result._avg.rating },
});
}
return review; return review;
} }
async getCourseReviews(courseId: string, page = 1, limit = 20): Promise<any> { async getCourseReviews(courseId: string, page = 1, limit = 20): Promise<any> {
const skip = (page - 1) * limit; const safePage = Math.max(1, Number(page) || 1);
const safeLimit = Math.min(100, Math.max(1, Number(limit) || 20));
const skip = (safePage - 1) * safeLimit;
const [reviews, total] = await Promise.all([ const [reviews, total] = await Promise.all([
this.prisma.review.findMany({ this.prisma.review.findMany({
where: { courseId, isApproved: true }, where: { courseId, isApproved: true },
include: { user: { select: { id: true, name: true, avatarUrl: true } } }, include: { user: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip, skip,
take: limit, take: safeLimit,
}), }),
this.prisma.review.count({ where: { courseId, isApproved: true } }), this.prisma.review.count({ where: { courseId, isApproved: true } }),
]); ]);
return { data: reviews, meta: { page, limit, total } }; return { data: reviews, meta: { page: safePage, limit: safeLimit, total } };
}
async recalculateAverageRating(courseId: string): Promise<void> {
const result = await this.prisma.review.aggregate({
where: { courseId, isApproved: true },
_avg: { rating: true },
});
await this.prisma.course.update({
where: { id: courseId },
data: { averageRating: result._avg.rating ?? null },
});
}
private async requireEnrollment(userId: string, courseId: string) {
const enrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
});
if (!enrollment) {
throw new NotFoundException('Not enrolled in this course');
}
return enrollment;
}
private async assertLessonUnlocked(userId: string, courseId: string, lessonId: string): Promise<void> {
const orderedLessons = await this.prisma.lesson.findMany({
where: { chapter: { courseId } },
orderBy: [{ chapter: { order: 'asc' } }, { order: 'asc' }],
select: { id: true },
});
const targetIndex = orderedLessons.findIndex((lesson) => lesson.id === lessonId);
if (targetIndex === -1) {
throw new NotFoundException('Lesson not found in this course');
}
if (targetIndex === 0) return;
const prevLessonId = orderedLessons[targetIndex - 1].id;
const prevProgress = await this.prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId, lessonId: prevLessonId } },
select: { quizPassed: true, homeworkSubmitted: true },
});
if (!prevProgress?.quizPassed || !prevProgress?.homeworkSubmitted) {
throw new ForbiddenException('Complete previous lesson quiz and homework first');
}
}
private async addUserToDefaultCourseGroup(courseId: string, userId: string): Promise<void> {
let group = await this.prisma.courseGroup.findFirst({
where: { courseId, isDefault: true },
});
if (!group) {
group = await this.prisma.courseGroup.create({
data: {
courseId,
name: 'Основная группа',
description: 'Обсуждение курса и вопросы преподавателю',
isDefault: true,
},
});
}
await this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId: group.id, userId } },
create: { groupId: group.id, userId, role: 'student' },
update: {},
});
} }
private async recalculateProgress(enrollmentId: string, courseId: string): Promise<void> { private async recalculateProgress(enrollmentId: string, courseId: string): Promise<void> {
@ -124,7 +359,11 @@ export class EnrollmentService {
where: { chapter: { courseId } }, where: { chapter: { courseId } },
}); });
const completedLessons = await this.prisma.lessonProgress.count({ const completedLessons = await this.prisma.lessonProgress.count({
where: { enrollmentId, completedAt: { not: null } }, where: {
enrollmentId,
quizPassed: true,
homeworkSubmitted: true,
},
}); });
const progress = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; const progress = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
@ -137,4 +376,28 @@ export class EnrollmentService {
}, },
}); });
} }
private gradeHomeworkWithAI(content: string): { score: number; feedback: string } {
const normalized = content.trim();
const words = normalized.split(/\s+/).filter(Boolean).length;
const hasStructure = /(^|\n)\s*[-*•]|\d+\./.test(normalized);
const hasExamples = /например|пример|кейc|case/i.test(normalized);
let score = 3;
if (words >= 250) score = 4;
if (words >= 450 && hasStructure && hasExamples) score = 5;
if (words < 120) score = 2;
if (words < 60) score = 1;
let feedback = 'Хорошая работа. Раскройте больше практических деталей и аргументов.';
if (score === 5) {
feedback = 'Отличная работа: есть структура, примеры и практическая применимость.';
} else if (score === 4) {
feedback = 'Сильная работа. Добавьте ещё один практический кейс для максимальной оценки.';
} else if (score <= 2) {
feedback = 'Ответ слишком краткий. Раскройте тему глубже и добавьте практические примеры.';
}
return { score, feedback };
}
} }

View File

@ -1,4 +1,4 @@
import { Controller, Post, Get, Param, Body } from '@nestjs/common'; import { Controller, Post, Get, Param, Body, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
@ -16,17 +16,45 @@ export class GroupsController {
} }
@Post(':groupId/members') @Post(':groupId/members')
async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string): Promise<any> { async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string, @CurrentUser() user: User): Promise<any> {
return this.groupsService.addMember(groupId, userId); return this.groupsService.addMember(groupId, user.id, userId);
}
@Get('course/:courseId/default')
async getDefaultGroup(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.groupsService.getDefaultGroup(courseId, user.id);
}
@Get(':groupId/members')
async getMembers(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
return this.groupsService.getGroupMembers(groupId, user.id);
} }
@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')
async createInviteLink(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
return this.groupsService.createInviteLink(groupId, user.id);
}
@Post('join/:groupId')
async joinByInvite(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
return this.groupsService.joinByInvite(groupId, user.id);
} }
} }

View File

@ -0,0 +1,113 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { AuthService } from '../auth/auth.service';
import { SupabaseService } from '../auth/supabase.service';
import { UsersService } from '../users/users.service';
import { GroupsService } from './groups.service';
@WebSocketGateway({
namespace: '/ws/course-groups',
cors: {
origin: true,
credentials: true,
},
})
export class GroupsGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
private jwtService: JwtService,
private authService: AuthService,
private supabaseService: SupabaseService,
private usersService: UsersService,
private groupsService: GroupsService
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const user = await this.resolveUser(client);
if (!user) {
client.disconnect();
return;
}
client.data.user = user;
} catch {
client.disconnect();
}
}
@SubscribeMessage('groups:join')
async joinGroup(
@ConnectedSocket() client: Socket,
@MessageBody() body: { groupId: string }
) {
const user = client.data.user;
if (!user || !body?.groupId) return { ok: false };
const canJoin = await this.groupsService.isMember(body.groupId, user.id);
if (!canJoin) {
await this.groupsService.joinByInvite(body.groupId, user.id);
}
await client.join(this.room(body.groupId));
const messages = await this.groupsService.getGroupMessages(body.groupId, user.id);
return { ok: true, messages };
}
@SubscribeMessage('groups:send')
async sendMessage(
@ConnectedSocket() client: Socket,
@MessageBody() body: { groupId: string; content: string; lessonId?: string }
) {
const user = client.data.user;
if (!user || !body?.groupId || !body?.content?.trim()) return { ok: false };
const message = await this.groupsService.sendMessage(body.groupId, user.id, body.content.trim(), body.lessonId);
this.server.to(this.room(body.groupId)).emit('groups:new-message', message);
return { ok: true, message };
}
private room(groupId: string): string {
return `group:${groupId}`;
}
private async resolveUser(client: Socket) {
const authToken =
(client.handshake.auth?.token as string | undefined) ||
((client.handshake.headers.authorization as string | undefined)?.replace(/^Bearer\s+/i, '')) ||
undefined;
if (!authToken) return null;
try {
const payload = this.jwtService.verify<{ sub: string; email: string; supabaseId: string }>(authToken);
const user = await this.authService.validateJwtPayload(payload);
if (user) return user;
} catch {
// Fallback to Supabase access token
}
const supabaseUser = await this.supabaseService.verifyToken(authToken);
if (!supabaseUser) return null;
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name:
supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
return user;
}
}

View File

@ -1,10 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { GroupsController } from './groups.controller'; import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { GroupsGateway } from './groups.gateway';
import { UsersModule } from '../users/users.module';
import { AccessModule } from '../common/access/access.module';
@Module({ @Module({
imports: [UsersModule, AccessModule],
controllers: [GroupsController], controllers: [GroupsController],
providers: [GroupsService], providers: [GroupsService, GroupsGateway],
exports: [GroupsService], exports: [GroupsService],
}) })
export class GroupsModule {} export class GroupsModule {}

View File

@ -1,48 +1,188 @@
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 },
}); });
} }
async addMember(groupId: string, userId: string, role = 'student'): Promise<any> { async ensureDefaultGroup(courseId: string): Promise<any> {
return this.prisma.groupMember.create({ const existing = await this.prisma.courseGroup.findFirst({
data: { groupId, userId, role }, where: { courseId, isDefault: true },
});
if (existing) return existing;
return this.prisma.courseGroup.create({
data: {
courseId,
name: 'Основная группа',
description: 'Обсуждение курса и вопросы преподавателю',
isDefault: true,
},
}); });
} }
async getGroupMessages(groupId: string, userId: string): Promise<any> { async getDefaultGroup(courseId: string, userId: string): Promise<any> {
const member = await this.prisma.groupMember.findUnique({ const group = await this.ensureDefaultGroup(courseId);
where: { groupId_userId: { groupId, userId } }, await this.assertCanReadGroup(group.id, userId);
const [messages, members] = await Promise.all([
this.getGroupMessages(group.id, userId),
this.getGroupMembers(group.id, userId),
]);
return { group, messages, members };
}
async addMember(groupId: string, requesterId: string, targetUserId: string, role = 'student'): Promise<any> {
const group = await this.prisma.courseGroup.findUnique({
where: { id: groupId },
include: { course: { select: { authorId: true } } },
}); });
if (!member) throw new ForbiddenException('Not a member of this group'); if (!group) throw new NotFoundException('Group not found');
if (group.course.authorId !== requesterId) {
throw new ForbiddenException('Only course author can add members manually');
}
return this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId, userId: targetUserId } },
create: { groupId, userId: targetUserId, role },
update: { role },
});
}
async getGroupMembers(groupId: string, userId: string): Promise<any> {
await this.assertCanReadGroup(groupId, userId);
return this.prisma.groupMember.findMany({
where: { groupId },
include: {
user: {
select: { id: true, name: true, email: true, avatarUrl: true },
},
},
orderBy: { joinedAt: 'asc' },
});
}
async getGroupMessages(groupId: string, userId: string, lessonId?: string): Promise<any> {
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: 100, take: 200,
}); });
} }
async sendMessage(groupId: string, userId: string, content: string): Promise<any> { async sendMessage(groupId: string, userId: string, content: string, lessonId?: string): Promise<any> {
const member = await this.prisma.groupMember.findUnique({ await this.assertCanReadGroup(groupId, userId);
where: { groupId_userId: { groupId, userId } },
});
if (!member) throw new ForbiddenException('Not a member of this group');
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 } } },
}); });
} }
async createInviteLink(groupId: string, userId: string): Promise<any> {
const group = await this.prisma.courseGroup.findUnique({
where: { id: groupId },
include: { course: { select: { authorId: true } } },
});
if (!group) throw new NotFoundException('Group not found');
if (group.course.authorId !== userId) throw new ForbiddenException('Only course author can create invite links');
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3080';
return {
groupId,
inviteUrl: `${appUrl}/dashboard/groups/${groupId}`,
};
}
async joinByInvite(groupId: string, userId: string): Promise<any> {
const group = await this.prisma.courseGroup.findUnique({
where: { id: groupId },
include: {
course: {
select: { id: true, authorId: true, price: true },
},
},
});
if (!group) throw new NotFoundException('Group not found');
const hasEnrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: group.course.id } },
});
const hasPurchase = await this.prisma.purchase.findUnique({
where: { userId_courseId: { userId, courseId: group.course.id } },
});
if (!hasEnrollment && !hasPurchase && group.course.authorId !== userId) {
throw new ForbiddenException('Enroll or purchase course first');
}
return this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId, userId } },
create: { groupId, userId, role: group.course.authorId === userId ? 'teacher' : 'student' },
update: {},
});
}
async isMember(groupId: string, userId: string): Promise<boolean> {
const member = await this.prisma.groupMember.findUnique({
where: { groupId_userId: { groupId, userId } },
select: { id: true },
});
return Boolean(member);
}
private async assertCanReadGroup(groupId: string, userId: string): Promise<void> {
const group = await this.prisma.courseGroup.findUnique({
where: { id: groupId },
include: {
course: {
select: { id: true, authorId: true },
},
},
});
if (!group) {
throw new NotFoundException('Group not found');
}
if (group.course.authorId === userId) return;
const member = await this.prisma.groupMember.findUnique({
where: { groupId_userId: { groupId, userId } },
select: { id: true },
});
if (member) return;
const enrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: group.course.id } },
select: { id: true },
});
if (enrollment) {
await this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId, userId } },
create: { groupId, userId, role: 'student' },
update: {},
});
return;
}
throw new ForbiddenException('No access to this group');
}
} }

View File

@ -7,7 +7,7 @@ import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/all-exceptions.filter'; import { AllExceptionsFilter } from './common/all-exceptions.filter';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, { rawBody: true });
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalFilters(new AllExceptionsFilter());

View File

@ -1,4 +1,4 @@
import { Controller, Get, Post, Param, Body } from '@nestjs/common'; import { Controller, Get, Post, Param, Body, Query, Delete, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ModerationService } from './moderation.service'; import { ModerationService } from './moderation.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
@ -15,6 +15,15 @@ export class ModerationController {
return this.moderationService.getPendingCourses(user.id); return this.moderationService.getPendingCourses(user.id);
} }
@Get('courses')
async getCourses(
@CurrentUser() user: User,
@Query('status') status?: string,
@Query('search') search?: string,
): Promise<any> {
return this.moderationService.getCourses(user.id, { status, search });
}
@Post(':courseId/approve') @Post(':courseId/approve')
async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise<any> { async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise<any> {
return this.moderationService.approveCourse(user.id, courseId, note); return this.moderationService.approveCourse(user.id, courseId, note);
@ -24,4 +33,34 @@ export class ModerationController {
async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise<any> { async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise<any> {
return this.moderationService.rejectCourse(user.id, courseId, reason); return this.moderationService.rejectCourse(user.id, courseId, reason);
} }
@Post('reviews/:reviewId/hide')
async hideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise<any> {
return this.moderationService.hideReview(user.id, reviewId);
}
@Post('reviews/:reviewId/unhide')
async unhideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise<any> {
return this.moderationService.unhideReview(user.id, reviewId);
}
@Delete(':courseId')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<void> {
await this.moderationService.deleteCourse(user.id, courseId);
}
@Get(':courseId/preview')
async previewCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.moderationService.previewCourse(user.id, courseId);
}
@Post(':courseId/quiz-preview')
async previewQuiz(
@Param('courseId') courseId: string,
@Body() body: { lessonId: string; answers?: number[] },
@CurrentUser() user: User,
): Promise<any> {
return this.moderationService.previewQuiz(user.id, courseId, body);
}
} }

View File

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

View File

@ -1,19 +1,21 @@
import { Injectable, ForbiddenException } 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 } },
@ -22,31 +24,118 @@ export class ModerationService {
}); });
} }
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> { async getCourses(
const user = await this.prisma.user.findUnique({ where: { id: userId } }); userId: string,
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { options?: {
throw new ForbiddenException('Moderators only'); status?: string;
search?: string;
}
): Promise<any[]> {
await this.access.assertStaff(userId);
const allowedStatuses = Object.values(CourseStatus);
const where: any = {};
if (options?.status && allowedStatuses.includes(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;
}
} }
return this.prisma.course.update({ if (options?.search?.trim()) {
const term = options.search.trim();
where.OR = [
{ title: { contains: term, mode: 'insensitive' } },
{ description: { contains: term, mode: 'insensitive' } },
{ author: { name: { contains: term, mode: 'insensitive' } } },
{ author: { email: { contains: term, mode: 'insensitive' } } },
];
}
return this.prisma.course.findMany({
where,
include: {
author: { select: { id: true, name: true, email: true } },
_count: {
select: {
chapters: true,
enrollments: true,
reviews: true,
},
},
},
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
take: 200,
});
}
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> {
await this.access.assertStaff(userId);
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { id: true, status: true },
});
if (!course) {
throw new NotFoundException('Course not found');
}
if (!isPendingModeration(course.status)) {
throw new ForbiddenException('Only courses pending moderation can be approved');
}
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({
where: { id: courseId },
select: { id: true, status: true },
});
if (!course) {
throw new NotFoundException('Course not found');
}
if (!isPendingModeration(course.status)) {
throw new ForbiddenException('Only courses pending moderation can be rejected');
} }
return this.prisma.course.update({ const updated = await this.prisma.course.update({
where: { id: courseId }, where: { id: courseId },
data: { data: {
status: CourseStatus.REJECTED, status: CourseStatus.REJECTED,
@ -54,5 +143,136 @@ 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> {
await this.access.assertStaff(userId);
const review = await this.prisma.review.update({
where: { id: reviewId },
data: { isApproved: false },
});
await this.recalculateAverageRating(review.courseId);
return review;
}
async unhideReview(userId: string, reviewId: string): Promise<any> {
await this.access.assertStaff(userId);
const review = await this.prisma.review.update({
where: { id: reviewId },
data: { isApproved: true },
});
await this.recalculateAverageRating(review.courseId);
return review;
}
async deleteCourse(userId: string, courseId: string): Promise<void> {
await this.access.assertStaff(userId);
const existing = await this.prisma.course.findUnique({
where: { id: courseId },
select: { id: true },
});
if (!existing) {
throw new NotFoundException('Course not found');
}
await this.prisma.course.delete({ where: { id: courseId } });
}
private async recalculateAverageRating(courseId: string): Promise<void> {
const result = await this.prisma.review.aggregate({
where: { courseId, isApproved: true },
_avg: { rating: true },
});
await this.prisma.course.update({
where: { id: courseId },
data: { averageRating: result._avg.rating ?? null },
});
} }
} }

View File

@ -0,0 +1,22 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { PaymentsService } from './payments.service';
@ApiTags('payments')
@Controller('payments')
@ApiBearerAuth()
export class DevPaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Post('dev/yoomoney/complete')
@ApiOperation({ summary: 'Complete DEV YooMoney payment (mock flow)' })
async completeDevYoomoneyPayment(
@CurrentUser() user: User,
@Body('courseId') courseId: string,
) {
return this.paymentsService.completeDevYoomoneyPayment(user.id, courseId);
}
}

View File

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

View File

@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { StripeService } from './stripe.service'; import { StripeService } from './stripe.service';
import { SubscriptionTier } from '@coursecraft/database'; import { PaymentMode, PaymentProvider, SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared'; import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
@Injectable() @Injectable()
@ -28,34 +28,7 @@ export class PaymentsService {
} }
async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') { async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') {
const user = await this.prisma.user.findUnique({ const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
where: { id: userId },
include: { subscription: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
// Get or create Stripe customer
let stripeCustomerId = user.subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await this.stripeService.createCustomer(user.email, user.name || undefined);
stripeCustomerId = customer.id;
await this.prisma.subscription.upsert({
where: { userId },
create: {
userId,
tier: SubscriptionTier.FREE,
stripeCustomerId: customer.id,
},
update: {
stripeCustomerId: customer.id,
},
});
}
// Get price ID for tier // Get price ID for tier
const priceId = const priceId =
@ -83,6 +56,94 @@ export class PaymentsService {
return { url: session.url }; return { url: session.url };
} }
async createCourseCheckoutSession(userId: string, courseId: string) {
const [course, existingPurchase] = await Promise.all([
this.prisma.course.findUnique({
where: { id: courseId },
select: {
id: true,
title: true,
description: true,
price: true,
currency: true,
isPublished: true,
status: true,
},
}),
this.prisma.purchase.findUnique({
where: { userId_courseId: { userId, courseId } },
}),
]);
if (!course) {
throw new NotFoundException('Course not found');
}
if (!course.isPublished) {
throw new ForbiddenException('Course is not available for purchase');
}
if (!course.price) {
throw new Error('Course is free, checkout is not required');
}
if (existingPurchase?.status === 'completed') {
throw new Error('Course is already purchased');
}
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 session = await this.stripeService.createOneTimeCheckoutSession({
customerId: stripeCustomerId,
currency: course.currency || 'USD',
unitAmount,
productName: course.title,
productDescription: course.description || undefined,
successUrl: `${appUrl}/dashboard/catalog/${courseId}?purchase=success`,
cancelUrl: `${appUrl}/dashboard/catalog/${courseId}?purchase=canceled`,
metadata: {
type: 'course_purchase',
userId,
courseId,
},
});
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) {
const subscription = await this.prisma.subscription.findUnique({ const subscription = await this.prisma.subscription.findUnique({
where: { userId }, where: { userId },
@ -107,8 +168,8 @@ export class PaymentsService {
case 'checkout.session.completed': case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as { await this.handleCheckoutCompleted(event.data.object as {
customer: string; customer: string;
subscription: string; subscription?: string;
metadata: { userId: string; tier: string }; metadata: { userId?: string; tier?: string; type?: string; courseId?: string };
}); });
break; break;
@ -133,10 +194,29 @@ export class PaymentsService {
private async handleCheckoutCompleted(session: { private async handleCheckoutCompleted(session: {
customer: string; customer: string;
subscription: string; subscription?: string;
metadata: { userId: string; tier: string }; metadata: { userId?: string; tier?: string; type?: string; courseId?: string };
}) { }) {
const { customer, subscription: subscriptionId, metadata } = session; const { customer, subscription: subscriptionId, metadata } = session;
if (!metadata?.userId) {
return;
}
if (metadata.type === 'course_purchase') {
await this.handleCoursePurchaseCompleted({
userId: metadata.userId,
courseId: metadata.courseId || '',
provider: PaymentProvider.STRIPE,
mode: PaymentMode.PROD,
eventCode: 'STRIPE_PAYMENT_SUCCESS',
});
return;
}
if (!subscriptionId || !metadata.tier) {
return;
}
const tier = metadata.tier as SubscriptionTier; const tier = metadata.tier as SubscriptionTier;
const stripeSubscription = await this.stripeService.getSubscription(subscriptionId); const stripeSubscription = await this.stripeService.getSubscription(subscriptionId);
@ -161,6 +241,122 @@ export class PaymentsService {
}); });
} }
private async handleCoursePurchaseCompleted(params: {
userId: string;
courseId: string;
provider?: PaymentProvider;
mode?: PaymentMode;
eventCode?: string;
}) {
const { userId, courseId } = params;
if (!courseId) return;
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { id: true, price: true, currency: true, authorId: true },
});
if (!course || !course.price) return;
await this.prisma.purchase.upsert({
where: { userId_courseId: { userId, courseId } },
create: {
userId,
courseId,
amount: course.price,
currency: course.currency,
status: 'completed',
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
eventCode: params.eventCode || null,
metadata: {
eventCode: params.eventCode || null,
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
},
},
update: {
status: 'completed',
amount: course.price,
currency: course.currency,
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
eventCode: params.eventCode || null,
metadata: {
eventCode: params.eventCode || null,
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
},
},
});
await this.prisma.enrollment.upsert({
where: { userId_courseId: { userId, courseId } },
create: { userId, courseId },
update: {},
});
const defaultGroup = await this.ensureDefaultCourseGroup(courseId);
await this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId: defaultGroup.id, userId } },
create: { groupId: defaultGroup.id, userId, role: 'student' },
update: {},
});
}
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 }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
let stripeCustomerId = user.subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await this.stripeService.createCustomer(user.email, user.name || undefined);
stripeCustomerId = customer.id;
await this.prisma.subscription.upsert({
where: { userId },
create: {
userId,
tier: SubscriptionTier.FREE,
stripeCustomerId: customer.id,
},
update: {
stripeCustomerId: customer.id,
},
});
}
return {
stripeCustomerId,
email: user.email,
name: user.name,
};
}
private async ensureDefaultCourseGroup(courseId: string) {
const existing = await this.prisma.courseGroup.findFirst({
where: { courseId, isDefault: true },
});
if (existing) return existing;
return this.prisma.courseGroup.create({
data: {
courseId,
name: 'Основная группа',
description: 'Обсуждение курса и вопросы преподавателю',
isDefault: true,
},
});
}
private async handleSubscriptionUpdated(subscription: { private async handleSubscriptionUpdated(subscription: {
id: string; id: string;
customer: string; customer: string;

View File

@ -45,6 +45,38 @@ export class StripeService {
}); });
} }
async createOneTimeCheckoutSession(params: {
customerId: string;
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
currency: string;
unitAmount: number;
productName: string;
productDescription?: string;
}): Promise<Stripe.Checkout.Session> {
return this.stripe.checkout.sessions.create({
customer: params.customerId,
mode: 'payment',
line_items: [
{
price_data: {
currency: params.currency.toLowerCase(),
unit_amount: params.unitAmount,
product_data: {
name: params.productName,
description: params.productDescription,
},
},
quantity: 1,
},
],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
});
}
async createPortalSession(customerId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session> { async createPortalSession(customerId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session> {
return this.stripe.billingPortal.sessions.create({ return this.stripe.billingPortal.sessions.create({
customer: customerId, customer: customerId,

View File

@ -11,8 +11,11 @@ export class SupportController {
constructor(private supportService: SupportService) {} constructor(private supportService: SupportService) {}
@Post('tickets') @Post('tickets')
async createTicket(@Body('title') title: string, @CurrentUser() user: User): Promise<any> { async createTicket(
return this.supportService.createTicket(user.id, title); @Body() body: { title: string; initialMessage?: string; priority?: string },
@CurrentUser() user: User
): Promise<any> {
return this.supportService.createTicket(user.id, body.title, body.initialMessage, body.priority);
} }
@Get('tickets') @Get('tickets')
@ -29,4 +32,24 @@ export class SupportController {
async sendMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> { async sendMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
return this.supportService.sendMessage(id, user.id, content); return this.supportService.sendMessage(id, user.id, content);
} }
@Get('admin/tickets')
async getAllTickets(@CurrentUser() user: User): Promise<any> {
return this.supportService.getAllTickets(user.id);
}
@Get('admin/tickets/:id/messages')
async getMessagesForStaff(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.supportService.getTicketMessagesForStaff(id, user.id);
}
@Post('admin/tickets/:id/messages')
async sendStaffMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
return this.supportService.sendStaffMessage(id, user.id, content);
}
@Post('admin/tickets/:id/status')
async updateStatus(@Param('id') id: string, @Body('status') status: string, @CurrentUser() user: User): Promise<any> {
return this.supportService.updateTicketStatus(id, user.id, status);
}
} }

View File

@ -0,0 +1,125 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { AuthService } from '../auth/auth.service';
import { SupabaseService } from '../auth/supabase.service';
import { UsersService } from '../users/users.service';
import { SupportService } from './support.service';
@WebSocketGateway({
namespace: '/ws/support',
cors: {
origin: true,
credentials: true,
},
})
export class SupportGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
private jwtService: JwtService,
private authService: AuthService,
private supabaseService: SupabaseService,
private usersService: UsersService,
private supportService: SupportService
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const user = await this.resolveUser(client);
if (!user) {
client.disconnect();
return;
}
client.data.user = user;
} catch {
client.disconnect();
}
}
@SubscribeMessage('support:join')
async joinTicket(
@ConnectedSocket() client: Socket,
@MessageBody() body: { ticketId: string }
) {
const user = client.data.user;
if (!user || !body?.ticketId) return { ok: false };
const canAccess = await this.supportService.canAccessTicket(body.ticketId, user.id);
if (!canAccess) return { ok: false };
await client.join(this.room(body.ticketId));
const messages =
(await this.supportService.getTicketMessagesForStaff(body.ticketId, user.id).catch(async () =>
this.supportService.getTicketMessages(body.ticketId, user.id)
)) || [];
return { ok: true, messages };
}
@SubscribeMessage('support:send')
async sendMessage(
@ConnectedSocket() client: Socket,
@MessageBody() body: { ticketId: string; content: string }
) {
const user = client.data.user;
if (!user || !body?.ticketId || !body?.content?.trim()) return { ok: false };
const canAccess = await this.supportService.canAccessTicket(body.ticketId, user.id);
if (!canAccess) return { ok: false };
const message =
(await this.supportService.sendStaffMessage(body.ticketId, user.id, body.content.trim()).catch(async () =>
this.supportService.sendMessage(body.ticketId, user.id, body.content.trim())
)) || null;
if (message) {
this.server.to(this.room(body.ticketId)).emit('support:new-message', message);
return { ok: true, message };
}
return { ok: false };
}
private room(ticketId: string): string {
return `ticket:${ticketId}`;
}
private async resolveUser(client: Socket) {
const authToken =
(client.handshake.auth?.token as string | undefined) ||
((client.handshake.headers.authorization as string | undefined)?.replace(/^Bearer\s+/i, '')) ||
undefined;
if (!authToken) return null;
try {
const payload = this.jwtService.verify<{ sub: string; email: string; supabaseId: string }>(authToken);
const user = await this.authService.validateJwtPayload(payload);
if (user) return user;
} catch {
// Fallback to Supabase access token
}
const supabaseUser = await this.supabaseService.verifyToken(authToken);
if (!supabaseUser) return null;
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name:
supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
return user;
}
}

View File

@ -1,10 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SupportController } from './support.controller'; import { SupportController } from './support.controller';
import { SupportService } from './support.service'; import { SupportService } from './support.service';
import { SupportGateway } from './support.gateway';
import { UsersModule } from '../users/users.module';
import { AccessModule } from '../common/access/access.module';
@Module({ @Module({
imports: [UsersModule, AccessModule],
controllers: [SupportController], controllers: [SupportController],
providers: [SupportService], providers: [SupportService, SupportGateway],
exports: [SupportService], exports: [SupportService],
}) })
export class SupportModule {} export class SupportModule {}

View File

@ -1,14 +1,44 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
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(userId: string, title: string): Promise<any> { async createTicket(
return this.prisma.supportTicket.create({ userId: string,
data: { userId, title }, title: string,
include: { messages: { include: { user: { select: { name: true } } } } }, initialMessage?: string,
priority: string = 'normal'
): Promise<any> {
const ticket = await this.prisma.supportTicket.create({
data: { userId, title, priority, status: 'open' },
});
if (initialMessage?.trim()) {
await this.prisma.ticketMessage.create({
data: {
ticketId: ticket.id,
userId,
content: initialMessage.trim(),
isStaff: false,
},
});
}
return this.prisma.supportTicket.findUnique({
where: { id: ticket.id },
include: {
messages: {
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'asc' },
},
},
}); });
} }
@ -22,7 +52,7 @@ export class SupportService {
async getTicketMessages(ticketId: string, userId: string): Promise<any> { async getTicketMessages(ticketId: string, userId: string): Promise<any> {
const ticket = await this.prisma.supportTicket.findFirst({ where: { id: ticketId, userId } }); const ticket = await this.prisma.supportTicket.findFirst({ where: { id: ticketId, userId } });
if (!ticket) throw new Error('Ticket not found'); if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticketMessage.findMany({ return this.prisma.ticketMessage.findMany({
where: { ticketId }, where: { ticketId },
@ -32,9 +62,98 @@ export class SupportService {
} }
async sendMessage(ticketId: string, userId: string, content: string): Promise<any> { async sendMessage(ticketId: string, userId: string, content: string): Promise<any> {
return this.prisma.ticketMessage.create({ const ticket = await this.prisma.supportTicket.findFirst({
where: { id: ticketId, userId },
select: { id: true, status: true },
});
if (!ticket) throw new NotFoundException('Ticket not found');
const message = await this.prisma.ticketMessage.create({
data: { ticketId, userId, content, isStaff: false }, data: { ticketId, userId, content, isStaff: false },
include: { user: { select: { id: true, name: true } } }, include: { user: { select: { id: true, name: true, avatarUrl: true } } },
});
await this.prisma.supportTicket.update({
where: { id: ticketId },
data: {
status: ticket.status === 'resolved' ? 'in_progress' : ticket.status,
updatedAt: new Date(),
},
});
return message;
}
async getAllTickets(staffUserId: string): Promise<any> {
await this.assertStaff(staffUserId);
return this.prisma.supportTicket.findMany({
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true } },
messages: { orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
}); });
} }
async getTicketMessagesForStaff(ticketId: string, staffUserId: string): Promise<any> {
await this.assertStaff(staffUserId);
const ticket = await this.prisma.supportTicket.findUnique({
where: { id: ticketId },
select: { id: true },
});
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticketMessage.findMany({
where: { ticketId },
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'asc' },
});
}
async sendStaffMessage(ticketId: string, staffUserId: string, content: string): Promise<any> {
await this.assertStaff(staffUserId);
const ticket = await this.prisma.supportTicket.findUnique({
where: { id: ticketId },
select: { id: true },
});
if (!ticket) throw new NotFoundException('Ticket not found');
const message = await this.prisma.ticketMessage.create({
data: { ticketId, userId: staffUserId, content, isStaff: true },
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
});
await this.prisma.supportTicket.update({
where: { id: ticketId },
data: { status: 'in_progress', updatedAt: new Date() },
});
return message;
}
async updateTicketStatus(ticketId: string, staffUserId: string, status: string): Promise<any> {
await this.assertStaff(staffUserId);
const allowed = ['open', 'in_progress', 'resolved', 'closed'];
if (!allowed.includes(status)) {
throw new ForbiddenException('Invalid status');
}
return this.prisma.supportTicket.update({
where: { id: ticketId },
data: { status, updatedAt: new Date() },
});
}
async canAccessTicket(ticketId: string, userId: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!user) return false;
if (user.role === UserRole.ADMIN || user.role === UserRole.MODERATOR) return true;
const ticket = await this.prisma.supportTicket.findFirst({
where: { id: ticketId, userId },
select: { id: true },
});
return Boolean(ticket);
}
private async assertStaff(userId: string): Promise<void> {
await this.access.assertStaff(userId);
}
} }

View File

@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service'; import { PrismaService } from '../common/prisma/prisma.service';
import { User, UserSettings, SubscriptionTier } from '@coursecraft/database'; import { User, UserRole, UserSettings, SubscriptionTier } from '@coursecraft/database';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateSettingsDto } from './dto/update-settings.dto'; import { UpdateSettingsDto } from './dto/update-settings.dto';
@ -147,4 +147,42 @@ export class UsersService {
}, },
}); });
} }
async listUsers(options?: { search?: string; role?: UserRole; limit?: number }) {
const limit = Math.min(200, Math.max(1, options?.limit || 100));
const where: any = {};
if (options?.role) {
where.role = options.role;
}
if (options?.search?.trim()) {
const term = options.search.trim();
where.OR = [
{ email: { contains: term, mode: 'insensitive' } },
{ name: { contains: term, mode: 'insensitive' } },
];
}
return this.prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
role: true,
subscriptionTier: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'desc' },
take: limit,
});
}
async updateRole(userId: string, role: UserRole): Promise<User> {
return this.prisma.user.update({
where: { id: userId },
data: { role },
include: { settings: true, subscription: true },
});
}
} }

View File

@ -49,6 +49,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.50.0", "react-hook-form": "^7.50.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4", "zod": "^3.22.4",

View File

@ -80,7 +80,7 @@ export default function CertificatePage() {
<div className="no-print absolute top-4 right-4"> <div className="no-print absolute top-4 right-4">
<Button onClick={handlePrint} variant="secondary" className="shadow-lg"> <Button onClick={handlePrint} variant="secondary" className="shadow-lg">
<Printer className="h-4 w-4 mr-2" /> <Printer className="h-4 w-4 mr-2" />
Печать Сохранить в PDF / Печать
</Button> </Button>
</div> </div>

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function DashboardAdminRedirectPage() {
redirect('/admin');
}

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function DashboardAdminSupportRedirectPage() {
redirect('/admin/support');
}

View File

@ -1,305 +1,5 @@
'use client'; import { redirect } from 'next/navigation';
import { useEffect, useState } from 'react'; export default function DashboardCatalogCourseRedirectPage({ params }: { params: { id: string } }) {
import { useParams, useRouter } from 'next/navigation'; redirect(`/courses/${params.id}`);
import {
ArrowLeft,
BookOpen,
Clock,
Users,
Star,
Shield,
Check,
Loader2,
ChevronDown,
} from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { useToast } from '@/components/ui/use-toast';
import { cn } from '@/lib/utils';
export default function PublicCoursePage() {
const params = useParams();
const router = useRouter();
const { toast } = useToast();
const { user } = useAuth();
const id = params?.id as string;
const [course, setCourse] = useState<any>(null);
const [reviews, setReviews] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [enrolling, setEnrolling] = useState(false);
const [enrolled, setEnrolled] = useState(false);
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
useEffect(() => {
if (!id) return;
(async () => {
setLoading(true);
try {
const [courseData, reviewsData] = await Promise.all([
api.getPublicCourse(id),
api.getCourseReviews(id).catch(() => ({ data: [] })),
]);
setCourse(courseData);
setReviews(reviewsData.data || []);
// Check if already enrolled
if (user) {
const enrollments = await api.getMyEnrollments().catch(() => []);
setEnrolled(enrollments.some((e: any) => e.course.id === id));
}
} catch {
setCourse(null);
} finally {
setLoading(false);
}
})();
}, [id, user]);
const handleEnroll = async () => {
if (!id || enrolling) return;
setEnrolling(true);
try {
await api.enrollInCourse(id);
toast({ title: 'Успех', description: 'Вы записались на курс' });
router.push(`/dashboard/courses/${id}`);
} catch (e: any) {
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
} finally {
setEnrolling(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!course) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Курс не найден</p>
<Link href="/dashboard/catalog" className="text-primary hover:underline mt-2 inline-block">
Вернуться к каталогу
</Link>
</div>
);
}
const totalLessons = course.chapters.reduce((acc: number, ch: any) => acc + ch.lessons.length, 0);
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard/catalog">
<ArrowLeft className="mr-2 h-4 w-4" />
К каталогу
</Link>
</Button>
{/* Course header */}
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-4">
{/* Cover */}
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 overflow-hidden">
{course.coverImage ? (
<img src={course.coverImage} alt={course.title} className="w-full h-full object-cover" />
) : (
<div className="flex items-center justify-center h-full">
<BookOpen className="h-16 w-16 text-primary/30" />
</div>
)}
</div>
{/* Title & meta */}
<div>
<div className="flex items-center gap-2 mb-2">
{course.isVerified && (
<div className="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" />
Проверен автором
</div>
)}
{course.difficulty && (
<span className="px-2 py-0.5 rounded-full bg-muted text-xs font-medium">
{course.difficulty === 'beginner' ? 'Начинающий' : course.difficulty === 'intermediate' ? 'Средний' : 'Продвинутый'}
</span>
)}
</div>
<h1 className="text-3xl font-bold">{course.title}</h1>
<p className="text-muted-foreground mt-2">{course.description}</p>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
{course.averageRating && (
<div className="flex items-center gap-1 text-yellow-600 font-medium">
<Star className="h-4 w-4 fill-current" />
{course.averageRating.toFixed(1)} ({course._count.reviews} отзывов)
</div>
)}
<span className="flex items-center gap-1 text-muted-foreground">
<Users className="h-4 w-4" />
{course._count.enrollments} студентов
</span>
<span className="text-muted-foreground">{totalLessons} уроков</span>
{course.estimatedHours && (
<span className="flex items-center gap-1 text-muted-foreground">
<Clock className="h-4 w-4" />
{course.estimatedHours}ч
</span>
)}
</div>
<div className="h-px bg-border my-4" />
{/* Author */}
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary">
{course.author.name?.[0] || 'A'}
</div>
<div>
<p className="text-sm font-medium">{course.author.name || 'Автор'}</p>
<p className="text-xs text-muted-foreground">Преподаватель</p>
</div>
</div>
</div>
{/* Sidebar: Enroll / Price */}
<div className="md:col-span-1">
<Card className="sticky top-4">
<CardContent className="p-6 space-y-4">
<div>
<p className="text-3xl font-bold">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</p>
</div>
{enrolled ? (
<Button className="w-full" asChild>
<Link href={`/dashboard/courses/${id}`}>Продолжить обучение</Link>
</Button>
) : (
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{course.price ? 'Купить курс' : 'Записаться'}
</Button>
)}
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>{course.chapters.length} глав</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>{totalLessons} видеоуроков</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>Сертификат по окончании</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>Пожизненный доступ</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Course content (chapters & lessons) */}
<Card>
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-4">Содержание курса</h2>
<div className="space-y-2">
{course.chapters.map((chapter: any) => {
const expanded = expandedChapters.includes(chapter.id);
const toggleExpanded = () => {
setExpandedChapters(prev =>
prev.includes(chapter.id)
? prev.filter(id => id !== chapter.id)
: [...prev, chapter.id]
);
};
return (
<div key={chapter.id} className="border rounded-lg overflow-hidden">
<button
className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors"
onClick={toggleExpanded}
>
<div className="flex items-center gap-3">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">{chapter.title}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{chapter.lessons.length} уроков</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', expanded && 'rotate-180')} />
</div>
</button>
{expanded && (
<div className="border-t bg-muted/20 p-2">
{chapter.lessons.map((lesson: any) => (
<div key={lesson.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<Check className="h-3.5 w-3.5 text-muted-foreground" />
<span>{lesson.title}</span>
{lesson.durationMinutes && (
<span className="ml-auto text-xs text-muted-foreground">{lesson.durationMinutes} мин</span>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Reviews */}
{reviews.length > 0 && (
<Card>
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-4">Отзывы студентов</h2>
<div className="space-y-4">
{reviews.map((review: any) => (
<div key={review.id} className="border-b last:border-b-0 pb-4 last:pb-0">
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary text-sm">
{review.user.name?.[0] || 'U'}
</div>
<div className="flex-1">
<p className="font-medium text-sm">{review.user.name || 'Пользователь'}</p>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
'h-3.5 w-3.5',
i < review.rating ? 'text-yellow-500 fill-current' : 'text-muted-foreground/30'
)}
/>
))}
</div>
</div>
</div>
{review.title && <p className="font-medium text-sm mb-1">{review.title}</p>}
{review.content && <p className="text-sm text-muted-foreground">{review.content}</p>}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
} }

View File

@ -1,185 +1,5 @@
'use client'; import { redirect } from 'next/navigation';
import { useEffect, useState } from 'react'; export default function DashboardCatalogRedirectPage() {
import Link from 'next/link'; redirect('/courses');
import { Search, Star, Users, BookOpen, Shield, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { cn } from '@/lib/utils';
interface CatalogCourse {
id: string;
title: string;
description: string | null;
coverImage: string | null;
difficulty: string | null;
price: number | null;
currency: string;
isVerified: boolean;
averageRating: number | null;
enrollmentCount: number;
reviewCount: number;
chaptersCount: number;
lessonsCount: number;
author: { id: string; name: string | null; avatarUrl: string | null };
}
const difficultyLabels: Record<string, { label: string; color: string }> = {
beginner: { label: 'Начинающий', color: 'text-green-600 bg-green-50 dark:bg-green-900/30' },
intermediate: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30' },
advanced: { label: 'Продвинутый', color: 'text-red-600 bg-red-50 dark:bg-red-900/30' },
};
export default function CatalogPage() {
const { loading: authLoading } = useAuth();
const [courses, setCourses] = useState<CatalogCourse[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [difficulty, setDifficulty] = useState('');
const loadCourses = async () => {
setLoading(true);
try {
const result = await api.getCatalog({
search: searchQuery || undefined,
difficulty: difficulty || undefined,
});
setCourses(result.data);
} catch {
// silent
} finally {
setLoading(false);
}
};
useEffect(() => {
if (authLoading) return;
loadCourses();
}, [authLoading, difficulty]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
loadCourses();
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Каталог курсов</h1>
<p className="text-muted-foreground mt-1">Изучайте курсы от других авторов</p>
</div>
{/* Search & filters */}
<div className="flex flex-col sm:flex-row gap-3">
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Поиск курсов..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<Button type="submit" size="sm">Найти</Button>
</form>
<div className="flex gap-2">
{['', 'beginner', 'intermediate', 'advanced'].map((d) => (
<Button
key={d}
variant={difficulty === d ? 'default' : 'outline'}
size="sm"
onClick={() => setDifficulty(d)}
>
{d ? difficultyLabels[d]?.label : 'Все'}
</Button>
))}
</div>
</div>
{/* Courses grid */}
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : courses.length === 0 ? (
<div className="text-center py-20">
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground">Пока нет опубликованных курсов</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => (
<Link key={course.id} href={`/dashboard/catalog/${course.id}`}>
<Card className="group overflow-hidden transition-all hover:shadow-lg hover:-translate-y-0.5 cursor-pointer h-full">
{/* Cover image */}
<div className="aspect-video bg-gradient-to-br from-primary/20 to-primary/5 relative overflow-hidden">
{course.coverImage ? (
<img src={course.coverImage} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<BookOpen className="h-10 w-10 text-primary/30" />
</div>
)}
{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">
<Shield className="h-3 w-3" />
Проверен
</div>
)}
{course.difficulty && difficultyLabels[course.difficulty] && (
<div className={cn(
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-xs font-medium',
difficultyLabels[course.difficulty].color
)}>
{difficultyLabels[course.difficulty].label}
</div>
)}
</div>
<CardContent className="p-4">
<h3 className="font-semibold line-clamp-1 group-hover:text-primary transition-colors">
{course.title}
</h3>
{course.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">{course.description}</p>
)}
{/* Author */}
<p className="text-xs text-muted-foreground mt-3">
{course.author.name || 'Автор'}
</p>
{/* Stats */}
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
{course.averageRating && (
<span className="flex items-center gap-0.5 text-yellow-600 font-medium">
<Star className="h-3.5 w-3.5 fill-current" />
{course.averageRating.toFixed(1)}
</span>
)}
<span className="flex items-center gap-0.5">
<Users className="h-3.5 w-3.5" />
{course.enrollmentCount}
</span>
<span>{course.lessonsCount} уроков</span>
</div>
{/* Price */}
<div className="mt-3 pt-3 border-t">
<span className="font-bold text-lg">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
} }

View File

@ -1,11 +1,29 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; 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 { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil, Upload, Shield } from 'lucide-react'; import {
CheckSquare,
ChevronLeft,
ChevronRight,
Eye,
FileArchive,
FileText,
FolderOpen,
ListChecks,
Layers3,
Lock,
Save,
Settings2,
Shield,
Sparkles,
Upload,
Wallet,
} from 'lucide-react';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CourseEditor } from '@/components/editor/course-editor'; import { CourseEditor } from '@/components/editor/course-editor';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer'; import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonSidebar } from '@/components/editor/lesson-sidebar'; import { LessonSidebar } from '@/components/editor/lesson-sidebar';
@ -15,9 +33,34 @@ import { useAuth } from '@/contexts/auth-context';
type Lesson = { id: string; title: string }; type Lesson = { id: string; title: string };
type Chapter = { id: string; title: string; lessons: Lesson[] }; type Chapter = { id: string; title: string; lessons: Lesson[] };
type CourseData = { id: string; title: string; chapters: Chapter[] }; type CourseData = {
id: string;
title: string;
description?: string | null;
coverImage?: string | null;
price?: number | null;
currency?: string;
status: 'DRAFT' | 'PENDING_MODERATION' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
moderationNote?: string | null;
difficulty?: string | null;
estimatedHours?: number | null;
tags?: string[];
chapters: Chapter[];
};
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 }[] = [
{ key: 'general', label: 'Общая информация', icon: FileText },
{ key: 'content', label: 'Контент', icon: Layers3 },
{ key: 'quiz', label: 'Тест', icon: CheckSquare },
{ key: 'homework', label: 'Домашнее задание', icon: ListChecks },
{ key: 'materials', label: 'Доп. материалы', icon: FolderOpen },
{ key: 'pricing', label: 'Цены', icon: Wallet },
{ key: 'settings', label: 'Настройки', icon: Settings2 },
{ key: 'access', label: 'Доступ', icon: Lock },
];
export default function CourseEditPage() { export default function CourseEditPage() {
const params = useParams(); const params = useParams();
@ -29,48 +72,81 @@ export default function CourseEditPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<EditTab>('general');
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null); const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null);
const [content, setContent] = useState<Record<string, unknown>>(emptyDoc); const [content, setContent] = useState<Record<string, unknown>>(emptyDoc);
const [contentLoading, setContentLoading] = useState(false); const [contentLoading, setContentLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [readOnly, setReadOnly] = useState(false); const [savingLesson, setSavingLesson] = useState(false);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [verifying, setVerifying] = useState(false); const [verifying, setVerifying] = useState(false);
const [savingMeta, setSavingMeta] = useState(false);
const [readOnly, setReadOnly] = useState(false);
const [courseTitle, setCourseTitle] = useState('');
const [courseDescription, setCourseDescription] = useState('');
const [courseCover, setCourseCover] = useState('');
const [coursePrice, setCoursePrice] = useState('');
const [courseCurrency, setCourseCurrency] = useState('USD');
const [courseDifficulty, setCourseDifficulty] = useState('');
const [courseEstimatedHours, setCourseEstimatedHours] = useState('');
const [courseTags, setCourseTags] = useState('');
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
const [quizGenerated, setQuizGenerated] = useState(false);
const [materials, setMaterials] = useState<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;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await api.getCourse(courseId); const data = await api.getCourse(courseId);
if (!cancelled) { if (cancelled) return;
setCourse(data); setCourse(data);
setCourseTitle(data.title || '');
setCourseDescription(data.description || '');
setCourseCover(data.coverImage || '');
setCoursePrice(data.price ? String(data.price) : '');
setCourseCurrency(data.currency || 'USD');
setCourseDifficulty(data.difficulty || '');
setCourseEstimatedHours(data.estimatedHours ? String(data.estimatedHours) : '');
setCourseTags(Array.isArray(data.tags) ? data.tags.join(', ') : '');
const firstChapter = data.chapters?.[0]; const firstChapter = data.chapters?.[0];
const firstLesson = firstChapter?.lessons?.[0]; const firstLesson = firstChapter?.lessons?.[0];
if (firstChapter && firstLesson) { if (firstChapter && firstLesson) {
setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id }); setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id });
} }
}
} catch (e: any) { } catch (e: any) {
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс'); if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [courseId, authLoading]); }, [courseId, authLoading]);
// Load lesson content when active lesson changes
useEffect(() => { useEffect(() => {
if (!courseId || !activeLesson) { if (!courseId || !activeLesson) {
setContent(emptyDoc); setContent(emptyDoc);
return; return;
} }
let cancelled = false; let cancelled = false;
setContentLoading(true); setContentLoading(true);
(async () => { (async () => {
try { try {
const lessonData = await api.getLesson(courseId, activeLesson.lessonId); const lessonData = await api.getLesson(courseId, activeLesson.lessonId);
@ -89,30 +165,63 @@ export default function CourseEditPage() {
if (!cancelled) setContentLoading(false); if (!cancelled) setContentLoading(false);
} }
})(); })();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [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 ch of course.chapters) { for (const chapter of course.chapters) {
const lesson = ch.lessons.find((l) => l.id === lessonId); const lesson = chapter.lessons.find((item) => item.id === lessonId);
if (lesson) { if (lesson) {
setActiveLesson({ chapterId: ch.id, lessonId: lesson.id }); setActiveLesson({ chapterId: chapter.id, lessonId: lesson.id });
return; return;
} }
} }
}; };
const handleSave = async () => { const handleSaveLesson = async () => {
if (!courseId || !activeLesson || saving) return; if (!courseId || !activeLesson || savingLesson) return;
setSaving(true); setSavingLesson(true);
try { try {
await api.updateLesson(courseId, activeLesson.lessonId, { content }); await api.updateLesson(courseId, activeLesson.lessonId, { content });
toast({ title: 'Сохранено', description: 'Изменения успешно сохранены' }); toast({ title: 'Сохранено', description: 'Контент урока сохранён' });
} catch (e: any) { } catch {
toast({ title: 'Ошибка', description: 'Не удалось сохранить', variant: 'destructive' }); toast({ title: 'Ошибка', description: 'Не удалось сохранить контент', variant: 'destructive' });
} finally { } finally {
setSaving(false); setSavingLesson(false);
}
};
const handleSaveMeta = async () => {
if (!courseId || savingMeta) return;
setSavingMeta(true);
try {
await api.updateCourse(courseId, {
title: courseTitle,
description: courseDescription || undefined,
coverImage: courseCover || undefined,
price: coursePrice ? Number(coursePrice) : 0,
currency: courseCurrency,
difficulty: courseDifficulty || undefined,
estimatedHours: courseEstimatedHours ? Number(courseEstimatedHours) : undefined,
tags: courseTags
.split(',')
.map((item) => item.trim())
.filter(Boolean),
});
toast({ title: 'Сохранено', description: 'Параметры курса обновлены' });
} catch (e: any) {
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
} finally {
setSavingMeta(false);
} }
}; };
@ -121,7 +230,7 @@ export default function CourseEditPage() {
setPublishing(true); setPublishing(true);
try { try {
await api.publishCourse(courseId); await api.publishCourse(courseId);
toast({ title: 'Опубликовано', description: 'Курс теперь доступен в каталоге' }); toast({ title: 'Отправлено', description: 'Курс отправлен на модерацию' });
window.location.reload(); window.location.reload();
} catch (e: any) { } catch (e: any) {
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' }); toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
@ -144,9 +253,68 @@ 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 h-[calc(100vh-4rem)] items-center justify-center"> <div className="flex min-h-[400px] items-center justify-center">
<p className="text-muted-foreground">Загрузка курса...</p> <p className="text-muted-foreground">Загрузка курса...</p>
</div> </div>
); );
@ -154,96 +322,126 @@ export default function CourseEditPage() {
if (error || !course) { if (error || !course) {
return ( return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center"> <div className="flex min-h-[400px] items-center justify-center">
<p className="text-destructive">{error || 'Курс не найден'}</p> <p className="text-destructive">{error || 'Курс не найден'}</p>
</div> </div>
); );
} }
const flatLessons = course.chapters.flatMap((ch) => const flatLessons = course.chapters.flatMap((chapter) => chapter.lessons.map((lesson) => ({ ...lesson, chapterId: chapter.id })));
ch.lessons.map((l) => ({ ...l, chapterId: ch.id })) const activeLessonMeta = activeLesson ? flatLessons.find((lesson) => lesson.id === activeLesson.lessonId) : null;
);
const activeLessonMeta = activeLesson
? flatLessons.find((l) => l.id === activeLesson.lessonId)
: null;
return ( return (
<div className="relative flex h-[calc(100vh-4rem)] -m-6"> <div className="space-y-4">
<div <Card>
className={cn( <CardHeader>
'border-r bg-muted/30 transition-all duration-300', <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
sidebarOpen ? 'w-72' : 'w-0' <div>
)} <CardTitle className="text-2xl">Редактор курса</CardTitle>
> <p className="text-sm text-muted-foreground mt-1">
{sidebarOpen && ( {course.title} Статус: {course.status}
<LessonSidebar {course.moderationNote ? ` • Заметка модерации: ${course.moderationNote}` : ''}
course={course} </p>
activeLesson={activeLesson?.lessonId ?? ''}
onSelectLesson={handleSelectLesson}
/>
)}
</div> </div>
<Button variant="outline" asChild>
<button
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-colors"
style={{ left: sidebarOpen ? '288px' : '0px' }}
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
<h2 className="font-medium truncate">
{activeLessonMeta?.title ?? 'Выберите урок'}
</h2>
<div className="flex items-center gap-2">
{readOnly ? (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/courses/${courseId}`}> <Link href={`/dashboard/courses/${courseId}`}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Просмотр курса Просмотр курса
</Link> </Link>
</Button> </Button>
<Button size="sm" onClick={() => setReadOnly(false)}> </div>
<Pencil className="mr-2 h-4 w-4" /> </CardHeader>
Редактировать </Card>
<div className="flex flex-wrap gap-2">
{tabs.map((tab) => (
<Button
key={tab.key}
variant={activeTab === tab.key ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveTab(tab.key)}
>
<tab.icon className="mr-2 h-4 w-4" />
{tab.label}
</Button> </Button>
</> ))}
) : ( </div>
<>
<Button variant="outline" size="sm" onClick={() => setReadOnly(true)}> {activeTab === 'general' && (
<Eye className="mr-2 h-4 w-4" /> <Card>
Режим просмотра <CardHeader>
</Button> <CardTitle>Общая информация</CardTitle>
<Button variant="outline" size="sm"> </CardHeader>
<Wand2 className="mr-2 h-4 w-4" /> <CardContent className="space-y-3">
AI помощник <input
</Button> value={courseTitle}
<Button size="sm" onClick={handleSave} disabled={saving || !activeLesson}> onChange={(e) => setCourseTitle(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
placeholder="Название курса"
/>
<input
value={courseCover}
onChange={(e) => setCourseCover(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
placeholder="URL обложки"
/>
<textarea
value={courseDescription}
onChange={(e) => setCourseDescription(e.target.value)}
className="w-full rounded-md border bg-background p-3 text-sm"
rows={4}
placeholder="Описание курса"
/>
<Button onClick={handleSaveMeta} disabled={savingMeta}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{saving ? 'Сохранение...' : 'Сохранить'} {savingMeta ? 'Сохранение...' : 'Сохранить'}
</Button> </Button>
<Button size="sm" variant="outline" onClick={handleToggleVerify} disabled={verifying}> </CardContent>
<Shield className="mr-2 h-4 w-4" /> </Card>
{verifying ? 'Обработка...' : 'Верификация'}
</Button>
<Button size="sm" variant="default" onClick={handlePublish} disabled={publishing}>
<Upload className="mr-2 h-4 w-4" />
{publishing ? 'Публикация...' : 'Опубликовать'}
</Button>
</>
)} )}
{activeTab === 'content' && (
<Card className="overflow-hidden">
<div className="flex h-[70vh]">
<div
className={cn(
'border-r bg-muted/30 transition-all duration-300',
sidebarOpen ? 'w-72' : 'w-14'
)}
>
<div className="flex h-10 items-center justify-between border-b px-2">
<span className={cn('text-xs text-muted-foreground', !sidebarOpen && 'hidden')}>Уроки</span>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarOpen((prev) => !prev)}>
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</div>
{sidebarOpen ? (
<LessonSidebar
course={course}
activeLesson={activeLesson?.lessonId ?? ''}
onSelectLesson={handleSelectLesson}
/>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<div>
<p className="text-sm font-medium truncate">{activeLessonMeta?.title ?? 'Выберите урок'}</p>
<p className="text-xs text-muted-foreground">Редактирование контента урока</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setReadOnly((prev) => !prev)}>
{readOnly ? 'Редактировать' : 'Режим просмотра'}
</Button>
<Button size="sm" onClick={handleSaveLesson} disabled={savingLesson || !activeLesson || readOnly}>
<Save className="mr-2 h-4 w-4" />
{savingLesson ? 'Сохранение...' : 'Сохранить урок'}
</Button>
</div> </div>
</div> </div>
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6"> <div className="flex-1 overflow-auto p-4">
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
{contentLoading ? ( {contentLoading ? (
<p className="text-muted-foreground">Загрузка контента...</p> <p className="text-muted-foreground">Загрузка контента...</p>
) : readOnly ? ( ) : readOnly ? (
@ -254,6 +452,220 @@ export default function CourseEditPage() {
</div> </div>
</div> </div>
</div> </div>
</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' && (
<Card>
<CardHeader>
<CardTitle>Цены</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 max-w-md">
<input
value={coursePrice}
onChange={(e) => setCoursePrice(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
placeholder="Цена (пусто или 0 = бесплатно)"
/>
<select
value={courseCurrency}
onChange={(e) => setCourseCurrency(e.target.value)}
className="rounded-md border bg-background px-2 py-2 text-sm"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="RUB">RUB</option>
</select>
</div>
<Button onClick={handleSaveMeta} disabled={savingMeta}>
<Save className="mr-2 h-4 w-4" />
{savingMeta ? 'Сохранение...' : 'Сохранить'}
</Button>
</CardContent>
</Card>
)}
{activeTab === 'settings' && (
<Card>
<CardHeader>
<CardTitle>Настройки</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<select
value={courseDifficulty}
onChange={(e) => setCourseDifficulty(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="">Уровень сложности</option>
<option value="beginner">Начинающий</option>
<option value="intermediate">Средний</option>
<option value="advanced">Продвинутый</option>
</select>
<input
value={courseEstimatedHours}
onChange={(e) => setCourseEstimatedHours(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm"
placeholder="Оценка длительности (часы)"
/>
</div>
<input
value={courseTags}
onChange={(e) => setCourseTags(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
placeholder="Теги через запятую"
/>
<Button onClick={handleSaveMeta} disabled={savingMeta}>
<Save className="mr-2 h-4 w-4" />
{savingMeta ? 'Сохранение...' : 'Сохранить'}
</Button>
</CardContent>
</Card>
)}
{activeTab === 'access' && (
<Card>
<CardHeader>
<CardTitle>Доступ и публикация</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
<p><span className="font-medium">Статус:</span> {course.status}</p>
{course.moderationNote ? (
<p className="mt-1 text-muted-foreground">Комментарий модерации: {course.moderationNote}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleToggleVerify} disabled={verifying}>
<Shield className="mr-2 h-4 w-4" />
{verifying ? 'Обработка...' : 'Верификация автора'}
</Button>
<Button onClick={handlePublish} disabled={publishing}>
<Upload className="mr-2 h-4 w-4" />
{publishing ? 'Отправка...' : 'Отправить на модерацию'}
</Button>
</div>
</CardContent>
</Card>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,250 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'next/navigation';
import { io, Socket } from 'socket.io-client';
import { CheckCircle2, Link as LinkIcon, Send, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
import { getWsBaseUrl } from '@/lib/ws';
import { useAuth } from '@/contexts/auth-context';
import { cn } from '@/lib/utils';
export default function CourseGroupPage() {
const params = useParams();
const courseId = params?.id as string;
const { backendUser } = useAuth();
const [group, setGroup] = useState<any>(null);
const [messages, setMessages] = useState<any[]>([]);
const [members, setMembers] = useState<any[]>([]);
const [message, setMessage] = useState('');
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [submissions, setSubmissions] = useState<any[]>([]);
const [reviewState, setReviewState] = useState<Record<string, { score: number; feedback: string }>>({});
const [sending, setSending] = useState(false);
const socketRef = useRef<Socket | null>(null);
const endRef = useRef<HTMLDivElement | null>(null);
const isAuthor = useMemo(() => {
if (!backendUser || !group?.courseId) return false;
return group?.courseAuthorId === backendUser.id;
}, [backendUser, group]);
const loadData = async () => {
if (!courseId) return;
const [groupData, courseData] = await Promise.all([
api.getDefaultCourseGroup(courseId),
api.getCourse(courseId).catch(() => null),
]);
setGroup({
...(groupData.group || {}),
courseAuthorId: courseData?.authorId,
});
setMessages(groupData.messages || []);
setMembers(groupData.members || []);
if (courseData?.authorId && backendUser?.id === courseData.authorId) {
const list = await api.getHomeworkSubmissions(courseId).catch(() => []);
setSubmissions(list);
}
};
useEffect(() => {
loadData();
}, [courseId, backendUser?.id]);
useEffect(() => {
if (!group?.id) 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: group.id });
socket.on('groups:new-message', (msg: any) => {
setMessages((prev) => [...prev, msg]);
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [group?.id]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
if (!group?.id || !message.trim()) return;
setSending(true);
await api.sendGroupMessage(group.id, message.trim());
setMessage('');
const latest = await api.getGroupMessages(group.id).catch(() => []);
setMessages(latest);
setSending(false);
};
const createInvite = async () => {
if (!group?.id) return;
const res = await api.createGroupInviteLink(group.id);
setInviteLink(res.inviteUrl);
};
const submitReview = async (submissionId: string) => {
const state = reviewState[submissionId];
if (!state) return;
await api.reviewHomeworkSubmission(courseId, submissionId, {
teacherScore: state.score,
teacherFeedback: state.feedback || undefined,
});
const list = await api.getHomeworkSubmissions(courseId).catch(() => []);
setSubmissions(list);
};
return (
<div className="space-y-5">
<div>
<h1 className="text-2xl font-bold tracking-tight">Комьюнити курса</h1>
<p className="text-sm text-muted-foreground">Общение студентов, приглашения и проверка домашних заданий</p>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<Card className="border-border/60">
<CardHeader className="border-b bg-muted/20">
<CardTitle className="flex items-center justify-between text-base">
<span>{group?.name || 'Основная группа'}</span>
<span className="text-xs font-medium text-muted-foreground">{messages.length} сообщений</span>
</CardTitle>
</CardHeader>
<CardContent className="flex h-[460px] flex-col p-4">
<div className="flex-1 space-y-2 overflow-auto pr-2">
{messages.map((msg) => {
const own = msg.user?.id === backendUser?.id;
return (
<div key={msg.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[78%] rounded-2xl px-3 py-2 text-sm',
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md border bg-card'
)}
>
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Участник'}</p>
<p>{msg.content}</p>
</div>
</div>
);
})}
<div ref={endRef} />
</div>
<div className="mt-3 flex gap-2 border-t pt-3">
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Сообщение в чат"
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
/>
<Button onClick={sendMessage} disabled={sending || !message.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4" />
Участники ({members.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{members.map((member) => (
<div key={member.id} className="rounded-xl border p-2 text-sm">
<p className="font-medium">{member.user?.name || member.user?.email}</p>
<p className="text-xs text-muted-foreground">{member.role}</p>
</div>
))}
{isAuthor ? (
<div className="space-y-2 border-t pt-3">
<Button variant="outline" onClick={createInvite} className="w-full">
<LinkIcon className="mr-2 h-4 w-4" />
Invite-ссылка
</Button>
{inviteLink ? <p className="break-all text-xs text-muted-foreground">{inviteLink}</p> : null}
</div>
) : null}
</CardContent>
</Card>
</div>
{isAuthor ? (
<Card className="border-border/60">
<CardHeader>
<CardTitle>Проверка домашних заданий</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{submissions.map((submission) => (
<div key={submission.id} className="space-y-2 rounded-xl border p-3">
<p className="text-sm font-medium">
{submission.user?.name || submission.user?.email} {submission.homework?.lesson?.title}
</p>
<p className="whitespace-pre-wrap text-sm">{submission.content}</p>
{submission.aiScore ? (
<p className="text-xs text-muted-foreground">
AI-предоценка: {submission.aiScore}/5 {submission.aiFeedback ? `${submission.aiFeedback}` : ''}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<select
className="h-9 rounded-lg border bg-background px-2 text-sm"
value={reviewState[submission.id]?.score ?? submission.teacherScore ?? 5}
onChange={(e) =>
setReviewState((prev) => ({
...prev,
[submission.id]: {
score: Number(e.target.value),
feedback: prev[submission.id]?.feedback || submission.teacherFeedback || '',
},
}))
}
>
{[1, 2, 3, 4, 5].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<input
className="h-9 flex-1 rounded-lg border bg-background px-3 text-sm"
value={reviewState[submission.id]?.feedback ?? submission.teacherFeedback ?? ''}
onChange={(e) =>
setReviewState((prev) => ({
...prev,
[submission.id]: {
score: prev[submission.id]?.score ?? submission.teacherScore ?? 5,
feedback: e.target.value,
},
}))
}
placeholder="Комментарий преподавателя"
/>
<Button size="sm" onClick={() => submitReview(submission.id)}>
<CheckCircle2 className="mr-2 h-4 w-4" />
Сохранить
</Button>
</div>
</div>
))}
{submissions.length === 0 ? <p className="text-sm text-muted-foreground">Пока нет отправленных ДЗ</p> : null}
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@ -1,23 +1,22 @@
'use client'; 'use client';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { import {
ArrowLeft, ArrowLeft,
Edit, BookOpen,
Trash2, CheckCircle2,
ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
CheckCircle2,
Circle,
Lock,
BookOpen,
Clock, Clock,
Edit,
FilePlus2,
GraduationCap, GraduationCap,
ChevronDown, Lock,
ChevronUp,
Play, Play,
Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
@ -32,94 +31,172 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
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 { useAuth } from '@/contexts/auth-context';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number }; type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] }; type Chapter = { id: string; title: string; order: number; lessons: Lesson[] };
type CourseData = { type CourseData = {
id: string; id: string;
title: string; title: string;
description?: string | null;
status: string;
authorId: string; authorId: string;
chapters: Chapter[]; chapters: Chapter[];
}; };
type LessonProgressRow = {
lessonId: string;
quizPassed?: boolean;
homeworkSubmitted?: boolean;
quizScore?: number | null;
};
type HomeworkState = {
homework: { id: string; title: string; description: string; type?: string; config?: any } | null;
submission: { id: string; content: string; aiScore?: number | null; aiFeedback?: string | null } | null;
};
export default function CoursePage() { export default function CoursePage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const { loading: authLoading, backendUser } = useAuth();
const id = params?.id as string; const id = params?.id as string;
const { loading: authLoading, backendUser } = useAuth();
const [course, setCourse] = useState<CourseData | null>(null); const [course, setCourse] = useState<CourseData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null); const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null); const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
const [lessonContentLoading, setLessonContentLoading] = useState(false); const [lessonContentLoading, setLessonContentLoading] = useState(false);
const [completedLessons, setCompletedLessons] = useState<Set<string>>(new Set()); const [deleting, setDeleting] = useState(false);
const [expandedChapters, setExpandedChapters] = useState<string[]>([]); const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [enrollmentProgress, setEnrollmentProgress] = useState<any>(null); const [progressRows, setProgressRows] = useState<Record<string, LessonProgressRow>>({});
const [showQuiz, setShowQuiz] = useState(false); const [showQuiz, setShowQuiz] = useState(false);
const [quizQuestions, setQuizQuestions] = useState<any[]>([]); const [quizQuestions, setQuizQuestions] = useState<any[]>([]);
const [quizLoading, setQuizLoading] = useState(false);
const [homework, setHomework] = useState<HomeworkState>({ homework: null, submission: null });
const [homeworkLoading, setHomeworkLoading] = useState(false);
const [homeworkSubmitting, setHomeworkSubmitting] = useState(false);
const [homeworkContent, setHomeworkContent] = useState('');
const [homeworkType, setHomeworkType] = useState<'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'>('TEXT');
const [groupId, setGroupId] = useState<string | null>(null);
const [activeLessonPanel, setActiveLessonPanel] = useState<'content' | 'quiz' | 'homework' | 'materials'>('content');
// Flat list of all lessons
const flatLessons = useMemo(() => { const flatLessons = useMemo(() => {
if (!course) return []; if (!course) return [];
return course.chapters return course.chapters
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.flatMap((ch) => .flatMap((chapter) =>
ch.lessons.sort((a, b) => a.order - b.order).map((l) => ({ ...l, chapterId: ch.id, chapterTitle: ch.title })) chapter.lessons
.sort((a, b) => a.order - b.order)
.map((lesson) => ({
...lesson,
chapterId: chapter.id,
chapterTitle: chapter.title,
}))
); );
}, [course]); }, [course]);
const currentLessonIndex = flatLessons.findIndex((l) => l.id === selectedLessonId); const currentLessonIndex = flatLessons.findIndex((lesson) => lesson.id === selectedLessonId);
const totalLessons = flatLessons.length; const totalLessons = flatLessons.length;
const completedCount = completedLessons.size; const isAuthor = Boolean(course && backendUser && course.authorId === backendUser.id);
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
const isLessonDone = (lessonId: string): boolean => {
const row = progressRows[lessonId];
return Boolean(row?.quizPassed && row?.homeworkSubmitted);
};
const isLessonUnlocked = (lessonId: string): boolean => {
if (isAuthor) return true;
const idx = flatLessons.findIndex((lesson) => lesson.id === lessonId);
if (idx <= 0) return true;
const prevLesson = flatLessons[idx - 1];
return isLessonDone(prevLesson.id);
};
const completedCount = flatLessons.filter((lesson) => isLessonDone(lesson.id)).length;
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
const activeLesson = selectedLessonId ? flatLessons.find((lesson) => lesson.id === selectedLessonId) : null;
const activeProgress = selectedLessonId ? progressRows[selectedLessonId] : undefined;
const courseCompleted = totalLessons > 0 && completedCount >= totalLessons;
const refreshProgress = async (courseId: string) => {
const progressData = await api.getEnrollmentProgress(courseId).catch(() => null);
const map: Record<string, LessonProgressRow> = {};
(progressData?.lessons || []).forEach((row: any) => {
map[String(row.lessonId)] = row;
});
setProgressRows(map);
return map;
};
const loadHomework = async (courseId: string, lessonId: string) => {
setHomeworkLoading(true);
try {
const data = await api.getLessonHomework(courseId, lessonId);
setHomework({ homework: data.homework || null, submission: data.submission || null });
setHomeworkContent(data.submission?.content || '');
} catch {
setHomework({ homework: null, submission: null });
setHomeworkContent('');
} finally {
setHomeworkLoading(false);
}
};
// Load course and progress
useEffect(() => { useEffect(() => {
if (!id || authLoading) return; if (!id || authLoading) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [courseData, progressData] = await Promise.all([ const courseData = await api.getCourse(id);
api.getCourse(id), if (cancelled) return;
api.getEnrollmentProgress(id).catch(() => null),
]);
if (!cancelled) {
setCourse(courseData); setCourse(courseData);
setEnrollmentProgress(progressData); setExpandedChapters((courseData.chapters || []).map((ch: Chapter) => ch.id));
if (progressData?.lessons) {
const completed: Set<string> = new Set( const [map, groupData] = await Promise.all([
progressData.lessons refreshProgress(id),
.filter((l: any) => l.completedAt) api.getDefaultCourseGroup(id).catch(() => null),
.map((l: any) => String(l.lessonId)) ]);
if (groupData?.group?.id) {
setGroupId(groupData.group.id);
}
const ordered = (courseData.chapters || [])
.sort((a: Chapter, b: Chapter) => a.order - b.order)
.flatMap((chapter: Chapter) =>
(chapter.lessons || [])
.sort((a: Lesson, b: Lesson) => a.order - b.order)
.map((lesson: Lesson) => lesson.id)
); );
setCompletedLessons(completed); const firstUnlocked = ordered.find((lessonId: string) => {
} if (isAuthor) return true;
const first = courseData.chapters?.[0]?.lessons?.[0]; const idx = ordered.indexOf(lessonId);
if (first) setSelectedLessonId(first.id); if (idx <= 0) return true;
setExpandedChapters(courseData.chapters.map((ch: Chapter) => ch.id)); const prevLessonId = ordered[idx - 1];
} const prevRow = map[prevLessonId];
return Boolean(prevRow?.quizPassed && prevRow?.homeworkSubmitted);
});
setSelectedLessonId(firstUnlocked || ordered[0] || null);
} catch (e: any) { } catch (e: any) {
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс'); if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [id, authLoading]); }, [id, authLoading]);
// Load lesson content
useEffect(() => { useEffect(() => {
if (!id || !selectedLessonId) { if (!id || !selectedLessonId) {
setLessonContent(null); setLessonContent(null);
@ -127,25 +204,44 @@ export default function CoursePage() {
} }
let cancelled = false; let cancelled = false;
setLessonContentLoading(true); setLessonContentLoading(true);
setShowQuiz(false);
setQuizQuestions([]);
setActiveLessonPanel('content');
(async () => { (async () => {
try { try {
const data = await api.getLesson(id, selectedLessonId); const data = await api.getLesson(id, selectedLessonId);
const content = data?.content; if (!cancelled) {
if (!cancelled)
setLessonContent( setLessonContent(
typeof content === 'object' && content !== null ? (content as Record<string, unknown>) : null typeof data?.content === 'object' && data.content !== null
? (data.content as Record<string, unknown>)
: null
); );
}
} catch { } catch {
if (!cancelled) setLessonContent(null); if (!cancelled) setLessonContent(null);
} finally { } finally {
if (!cancelled) setLessonContentLoading(false); if (!cancelled) setLessonContentLoading(false);
} }
})(); })();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [id, selectedLessonId]); }, [id, selectedLessonId]);
useEffect(() => {
if (!id || !selectedLessonId) return;
if (activeProgress?.quizPassed && !activeProgress?.homeworkSubmitted) {
loadHomework(id, selectedLessonId);
return;
}
setHomework({ homework: null, submission: null });
setHomeworkContent('');
}, [id, selectedLessonId, activeProgress?.quizPassed, activeProgress?.homeworkSubmitted]);
const handleDelete = async () => { const handleDelete = async () => {
if (!course?.id || deleting) return; if (!course || deleting) return;
setDeleting(true); setDeleting(true);
try { try {
await api.deleteCourse(course.id); await api.deleteCourse(course.id);
@ -158,95 +254,95 @@ export default function CoursePage() {
} }
}; };
const markComplete = async () => {
if (!selectedLessonId || !id) return;
try {
await api.completeLesson(id, selectedLessonId);
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
} catch {
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
}
};
const handleStartQuiz = async () => { const handleStartQuiz = async () => {
if (!selectedLessonId || !id) return; if (!id || !selectedLessonId || quizLoading) return;
setQuizLoading(true);
try { try {
const quiz = await api.getLessonQuiz(id, selectedLessonId); const data = await api.getLessonQuiz(id, selectedLessonId);
setQuizQuestions(quiz.questions || []); setQuizQuestions(Array.isArray(data?.questions) ? data.questions : []);
setShowQuiz(true); setShowQuiz(true);
} catch { } finally {
setQuizQuestions([]); setQuizLoading(false);
} }
}; };
const handleQuizComplete = async (score: number) => { const handleQuizComplete = async (answers: number[]) => {
if (!selectedLessonId || !id) return; if (!id || !selectedLessonId) {
return { score: 0, passed: false };
}
const result = await api.submitQuizAnswers(id, selectedLessonId, answers);
setProgressRows((prev) => ({
...prev,
[selectedLessonId]: result.progress || {
lessonId: selectedLessonId,
quizPassed: Boolean(result.passed),
},
}));
if (result.passed) {
await loadHomework(id, selectedLessonId);
}
return result;
};
const handleSubmitHomework = async () => {
if (!id || !selectedLessonId || !homeworkContent.trim() || homeworkSubmitting) return;
setHomeworkSubmitting(true);
try { try {
await api.submitQuizScore(id, selectedLessonId, score); const submission = await api.submitLessonHomework(id, selectedLessonId, {
markComplete(); content: homeworkContent.trim(),
} catch { type: homeworkType,
markComplete(); });
setHomework((prev) => ({ ...prev, submission }));
await refreshProgress(id);
} finally {
setHomeworkSubmitting(false);
} }
}; };
const handleGetCertificate = () => { const handleGenerateHomework = async () => {
if (!id) return; if (!id || !selectedLessonId) return;
window.open(`/certificate/${id}`, '_blank'); await api.generateLessonHomework(id, selectedLessonId, homeworkType).catch(() => null);
}; await loadHomework(id, selectedLessonId);
const goToNextLesson = () => {
if (currentLessonIndex < flatLessons.length - 1) {
markComplete();
setSelectedLessonId(flatLessons[currentLessonIndex + 1].id);
}
}; };
const goToPrevLesson = () => { const goToPrevLesson = () => {
if (currentLessonIndex > 0) { if (currentLessonIndex <= 0) return;
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id); setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
}
}; };
const toggleChapter = (chapterId: string) => { const goToNextLesson = () => {
setExpandedChapters((prev) => if (currentLessonIndex < 0 || currentLessonIndex >= flatLessons.length - 1) return;
prev.includes(chapterId) if (!selectedLessonId || !isLessonDone(selectedLessonId)) return;
? prev.filter((id) => id !== chapterId) const nextLesson = flatLessons[currentLessonIndex + 1];
: [...prev, chapterId] if (!isLessonUnlocked(nextLesson.id)) return;
); setSelectedLessonId(nextLesson.id);
}; };
if (authLoading || loading) { if (authLoading || loading) {
return ( return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center"> <div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Загрузка курса...</p>
</div>
</div> </div>
); );
} }
if (error || !course) { if (!course || error) {
return ( return (
<div className="flex flex-col gap-4 p-6"> <div className="p-6">
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
<Link href="/dashboard"><ArrowLeft className="mr-2 h-4 w-4" />Назад к курсам</Link> <Link href="/dashboard">
<ArrowLeft className="mr-2 h-4 w-4" />
Назад
</Link>
</Button> </Button>
<p className="text-destructive">{error || 'Курс не найден'}</p> <p className="text-destructive mt-4">{error || 'Курс не найден'}</p>
</div> </div>
); );
} }
const activeLessonMeta = selectedLessonId
? flatLessons.find((l) => l.id === selectedLessonId)
: null;
const isAuthor = course && backendUser && course.authorId === backendUser.id;
const courseCompleted = completedCount >= totalLessons && totalLessons > 0;
return ( return (
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col"> <div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
{/* Top bar */}
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-2.5 shadow-sm"> <div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-2.5 shadow-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
@ -258,25 +354,21 @@ export default function CoursePage() {
<div className="h-5 w-px bg-border" /> <div className="h-5 w-px bg-border" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-primary" /> <GraduationCap className="h-4 w-4 text-primary" />
<span className="font-medium truncate max-w-[300px]">{course.title}</span> <span className="font-medium truncate max-w-[280px]">{course.title}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Progress badge */}
<div className="hidden sm:flex items-center gap-2 mr-2 px-3 py-1.5 bg-muted rounded-full"> <div className="hidden sm:flex items-center gap-2 mr-2 px-3 py-1.5 bg-muted rounded-full">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-xs font-medium">{progressPercent}% пройдено</span> <span className="text-xs font-medium">{progressPercent}% пройдено</span>
</div> </div>
{/* Certificate button - show when course completed */}
{courseCompleted && ( {courseCompleted && (
<Button size="sm" variant="default" onClick={handleGetCertificate}> <Button size="sm" onClick={() => window.open(`/certificate/${id}`, '_blank')}>
<GraduationCap className="mr-1.5 h-3.5 w-3.5" />
Получить сертификат Получить сертификат
</Button> </Button>
)} )}
<Button size="sm" variant="outline" asChild>
{/* Edit/Delete - only for author */} <Link href={`/dashboard/courses/${id}/group`}>Группа курса</Link>
</Button>
{isAuthor && ( {isAuthor && (
<> <>
<Button size="sm" variant="outline" asChild> <Button size="sm" variant="outline" asChild>
@ -301,7 +393,10 @@ export default function CoursePage() {
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel> <AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={(e) => { e.preventDefault(); handleDelete(); }} onClick={(e) => {
e.preventDefault();
handleDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting} disabled={deleting}
> >
@ -316,7 +411,6 @@ export default function CoursePage() {
</div> </div>
<div className="relative flex flex-1 min-h-0"> <div className="relative flex flex-1 min-h-0">
{/* ─── Left sidebar: course navigation ─── */}
<div <div
className={cn( className={cn(
'border-r bg-muted/10 flex flex-col transition-all duration-300 ease-in-out', 'border-r bg-muted/10 flex flex-col transition-all duration-300 ease-in-out',
@ -325,7 +419,6 @@ export default function CoursePage() {
> >
{sidebarOpen && ( {sidebarOpen && (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Course progress */}
<div className="p-4 border-b bg-background"> <div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Прогресс курса</span> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Прогресс курса</span>
@ -333,46 +426,37 @@ export default function CoursePage() {
</div> </div>
<Progress value={progressPercent} className="h-2" /> <Progress value={progressPercent} className="h-2" />
</div> </div>
{/* Chapters & lessons */}
<div className="flex-1 overflow-auto py-2"> <div className="flex-1 overflow-auto py-2">
{course.chapters {course.chapters
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((chapter, chapterIdx) => { .map((chapter) => {
const isExpanded = expandedChapters.includes(chapter.id); const isExpanded = expandedChapters.includes(chapter.id);
const chapterLessons = chapter.lessons.sort((a, b) => a.order - b.order);
const chapterComplete = chapterLessons.every((l) => completedLessons.has(l.id));
const chapterStarted = chapterLessons.some((l) => completedLessons.has(l.id));
return ( return (
<div key={chapter.id} className="mb-1"> <div key={chapter.id} className="mb-1">
<button <button
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors" className="flex items-center gap-2 w-full px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
onClick={() => toggleChapter(chapter.id)} onClick={() =>
setExpandedChapters((prev) =>
prev.includes(chapter.id) ? prev.filter((id) => id !== chapter.id) : [...prev, chapter.id]
)
}
> >
<div className={cn( <BookOpen className="h-4 w-4 text-primary" />
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
chapterComplete
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: chapterStarted
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
)}>
{chapterComplete ? <CheckCircle2 className="h-4 w-4" /> : chapterIdx + 1}
</div>
<span className="flex-1 text-left font-medium truncate">{chapter.title}</span> <span className="flex-1 text-left font-medium truncate">{chapter.title}</span>
{isExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />} <ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', isExpanded && 'rotate-180')} />
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="ml-4 border-l-2 border-muted pl-2 mb-2"> <div className="ml-4 border-l-2 border-muted pl-2 mb-2">
{chapterLessons.map((lesson, lessonIdx) => { {chapter.lessons
.sort((a, b) => a.order - b.order)
.map((lesson) => {
const isActive = selectedLessonId === lesson.id; const isActive = selectedLessonId === lesson.id;
const isCompleted = completedLessons.has(lesson.id); const isDone = isLessonDone(lesson.id);
const globalIdx = flatLessons.findIndex((l) => l.id === lesson.id); const unlocked = isLessonUnlocked(lesson.id);
// Lesson is locked if sequential mode: all previous must be complete const isLocked = !unlocked;
// For now, don't lock (allow free navigation) const row = progressRows[lesson.id];
const isLocked = false; const quizOnly = row?.quizPassed && !row?.homeworkSubmitted;
return ( return (
<button <button
@ -388,21 +472,18 @@ export default function CoursePage() {
)} )}
onClick={() => !isLocked && setSelectedLessonId(lesson.id)} onClick={() => !isLocked && setSelectedLessonId(lesson.id)}
> >
{isCompleted ? ( {isDone ? (
<CheckCircle2 className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary-foreground' : 'text-green-500')} /> <CheckCircle2 className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary-foreground' : 'text-green-500')} />
) : isLocked ? ( ) : isLocked ? (
<Lock className="h-4 w-4 shrink-0" /> <Lock className="h-4 w-4 shrink-0" />
) : isActive ? ( ) : isActive ? (
<Play className="h-4 w-4 shrink-0 fill-current" /> <Play className="h-4 w-4 shrink-0 fill-current" />
) : quizOnly ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-yellow-500" />
) : ( ) : (
<Circle className="h-4 w-4 shrink-0" /> <BookOpen className="h-4 w-4 shrink-0" />
)} )}
<span className="truncate text-left">{lesson.title}</span> <span className="truncate text-left">{lesson.title}</span>
{lesson.durationMinutes && (
<span className={cn('ml-auto text-xs shrink-0', isActive ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
{lesson.durationMinutes} мин
</span>
)}
</button> </button>
); );
})} })}
@ -416,41 +497,31 @@ export default function CoursePage() {
)} )}
</div> </div>
{/* Sidebar toggle */}
<button <button
type="button" type="button"
className={cn( className="absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-all"
'absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-all',
)}
style={{ left: sidebarOpen ? '19.9rem' : 0 }} style={{ left: sidebarOpen ? '19.9rem' : 0 }}
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
> >
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} {sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button> </button>
{/* ─── Main content area ─── */}
<main className="flex-1 flex flex-col min-h-0"> <main className="flex-1 flex flex-col min-h-0">
{/* Lesson content */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-3xl mx-auto w-full px-8 py-10"> <div className="max-w-3xl mx-auto w-full px-8 py-10">
{/* Chapter & lesson header */} {activeLesson && (
{activeLessonMeta && (
<div className="mb-8"> <div className="mb-8">
<p className="text-sm text-primary font-medium mb-1"> <p className="text-sm text-primary font-medium mb-1">{activeLesson.chapterTitle}</p>
{activeLessonMeta.chapterTitle} <h1 className="text-3xl font-bold text-foreground">{activeLesson.title}</h1>
</p>
<h1 className="text-3xl font-bold text-foreground">
{activeLessonMeta.title}
</h1>
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground"> <div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<BookOpen className="h-4 w-4" /> <BookOpen className="h-4 w-4" />
Урок {currentLessonIndex + 1} из {totalLessons} Урок {currentLessonIndex + 1} из {totalLessons}
</span> </span>
{activeLessonMeta.durationMinutes && ( {activeLesson.durationMinutes && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
{activeLessonMeta.durationMinutes} мин {activeLesson.durationMinutes} мин
</span> </span>
)} )}
</div> </div>
@ -458,86 +529,182 @@ export default function CoursePage() {
</div> </div>
)} )}
{/* Content */}
{lessonContentLoading ? ( {lessonContentLoading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div> </div>
) : selectedLessonId ? ( ) : selectedLessonId ? (
<> <>
<LessonContentViewer <div className="mb-6 flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-2">
content={lessonContent} <Button
className="min-h-[400px]" size="sm"
/> variant={activeLessonPanel === 'content' ? 'default' : 'ghost'}
{!showQuiz && !completedLessons.has(selectedLessonId) && ( onClick={() => setActiveLessonPanel('content')}
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center"> >
<h3 className="font-semibold mb-2">Проверьте свои знания</h3> Контент
</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>
{activeLessonPanel === 'content' ? (
<LessonContentViewer content={lessonContent} className="min-h-[320px]" />
) : null}
{activeLessonPanel === 'quiz' ? (
<div className="space-y-4">
{!activeProgress?.quizPassed ? (
<div className="p-6 border rounded-xl bg-muted/20 text-center">
<h3 className="font-semibold mb-2">Шаг 1 из 2: тест</h3>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Пройдите тест, чтобы закрепить материал и получить сертификат Для открытия следующего урока пройдите тест.
</p> </p>
<Button onClick={handleStartQuiz}>Начать тест</Button> <Button onClick={handleStartQuiz} disabled={quizLoading}>
{quizLoading ? 'Загрузка теста...' : 'Начать тест'}
</Button>
</div>
) : (
<div className="p-4 rounded-xl border bg-emerald-50 text-emerald-800">
Тест уже пройден. Можно переходить к домашнему заданию.
</div> </div>
)} )}
{showQuiz && ( {showQuiz ? (
<LessonQuiz <LessonQuiz
courseId={id} courseId={id}
lessonId={selectedLessonId} lessonId={selectedLessonId}
questions={quizQuestions} questions={quizQuestions}
onComplete={handleQuizComplete} onComplete={handleQuizComplete}
/> />
) : null}
</div>
) : null}
{activeLessonPanel === 'homework' ? (
<div className="p-6 border rounded-xl bg-muted/20">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">Домашнее задание</h3>
{isAuthor ? (
<div className="flex items-center gap-2">
<select
value={homeworkType}
onChange={(e) => setHomeworkType(e.target.value as any)}
className="h-9 rounded-lg border bg-background px-2 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 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 mb-2">
{homework.homework?.title || 'Домашнее задание'}
</p>
<p className="text-sm mb-4">
{homework.homework?.description ||
'Опишите применение материала урока на практике и приведите минимум один пример.'}
</p>
<textarea
value={homeworkContent}
onChange={(e) => setHomeworkContent(e.target.value)}
className="w-full min-h-[180px] rounded-md border bg-background p-3 text-sm"
placeholder="Ваш ответ..."
disabled={Boolean(activeProgress?.homeworkSubmitted)}
/>
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Рекомендуется подробный ответ и примеры</p>
<Button
onClick={handleSubmitHomework}
disabled={homeworkSubmitting || Boolean(activeProgress?.homeworkSubmitted)}
>
{activeProgress?.homeworkSubmitted
? 'ДЗ отправлено'
: homeworkSubmitting
? 'Отправка...'
: 'Отправить ДЗ'}
</Button>
</div>
{homework.submission?.aiScore ? (
<div className="mt-3 rounded-md border bg-background p-3 text-sm">
<p className="font-medium">AI-предоценка: {homework.submission.aiScore}/5</p>
{homework.submission.aiFeedback ? (
<p className="text-muted-foreground mt-1">{homework.submission.aiFeedback}</p>
) : null}
</div>
) : null}
</>
)} )}
</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="flex flex-col items-center justify-center py-20 text-center"> <div className="text-muted-foreground">Выберите урок</div>
<BookOpen className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground">Выберите урок для начала обучения</p>
</div>
)} )}
</div> </div>
</div> </div>
{/* Bottom navigation */}
<div className="shrink-0 border-t bg-background px-6 py-3"> <div className="shrink-0 border-t bg-background px-6 py-3">
<div className="max-w-3xl mx-auto flex items-center justify-between"> <div className="max-w-3xl mx-auto flex items-center justify-between">
<Button <Button variant="outline" size="sm" onClick={goToPrevLesson} disabled={currentLessonIndex <= 0}>
variant="outline"
size="sm"
onClick={goToPrevLesson}
disabled={currentLessonIndex <= 0}
>
<ChevronLeft className="mr-1.5 h-4 w-4" /> <ChevronLeft className="mr-1.5 h-4 w-4" />
Предыдущий Предыдущий
</Button> </Button>
<div className="text-xs text-muted-foreground">
{selectedLessonId && isLessonDone(selectedLessonId)
? 'Урок завершён'
: 'Для следующего урока нужны тест + ДЗ'}
</div>
<Button <Button
variant="ghost"
size="sm" size="sm"
onClick={markComplete} onClick={goToNextLesson}
disabled={!selectedLessonId || completedLessons.has(selectedLessonId)} disabled={currentLessonIndex >= flatLessons.length - 1 || !selectedLessonId || !isLessonDone(selectedLessonId)}
className={cn(
completedLessons.has(selectedLessonId ?? '')
? 'text-green-600'
: 'text-muted-foreground hover:text-primary'
)}
> >
<CheckCircle2 className="mr-1.5 h-4 w-4" />
{completedLessons.has(selectedLessonId ?? '') ? 'Пройден' : 'Отметить пройденным'}
</Button>
{currentLessonIndex < flatLessons.length - 1 ? (
<Button size="sm" onClick={goToNextLesson}>
Следующий урок Следующий урок
<ChevronRight className="ml-1.5 h-4 w-4" /> <ChevronRight className="ml-1.5 h-4 w-4" />
</Button> </Button>
) : (
<div className="text-sm text-muted-foreground">
{courseCompleted ? 'Курс пройден!' : 'Последний урок'}
</div>
)}
</div> </div>
</div> </div>
</main> </main>
</div> </div>
<LessonChatPanel groupId={groupId} lessonId={selectedLessonId} userId={backendUser?.id || null} />
</div> </div>
); );
} }

View File

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

View File

@ -0,0 +1,132 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'next/navigation';
import { io, Socket } from 'socket.io-client';
import { Send, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
import { getWsBaseUrl } from '@/lib/ws';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
export default function InviteGroupPage() {
const params = useParams();
const groupId = params?.groupId as string;
const { backendUser } = useAuth();
const [messages, setMessages] = useState<any[]>([]);
const [members, setMembers] = useState<any[]>([]);
const [message, setMessage] = useState('');
const socketRef = useRef<Socket | null>(null);
const endRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!groupId) return;
(async () => {
await api.joinGroupByInvite(groupId).catch(() => null);
const [msgs, mbrs] = await Promise.all([
api.getGroupMessages(groupId).catch(() => []),
api.getGroupMembers(groupId).catch(() => []),
]);
setMessages(msgs);
setMembers(mbrs);
})();
}, [groupId]);
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', (msg: any) => setMessages((prev) => [...prev, msg]));
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [groupId]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const send = async () => {
if (!groupId || !message.trim()) return;
await api.sendGroupMessage(groupId, message.trim());
setMessage('');
};
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Группа курса</h1>
<p className="text-sm text-muted-foreground">Вы подключены по invite-ссылке</p>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<Card className="border-border/60">
<CardHeader className="border-b bg-muted/20">
<CardTitle>Чат участников</CardTitle>
</CardHeader>
<CardContent className="flex h-[460px] flex-col p-4">
<div className="flex-1 space-y-2 overflow-auto pr-2">
{messages.map((msg) => {
const own = msg.user?.id === backendUser?.id;
return (
<div key={msg.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[78%] rounded-2xl px-3 py-2 text-sm',
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md border bg-card'
)}
>
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Участник'}</p>
<p>{msg.content}</p>
</div>
</div>
);
})}
<div ref={endRef} />
</div>
<div className="mt-3 flex gap-2 border-t pt-3">
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
className="h-10 flex-1 rounded-lg border bg-background px-3 text-sm"
placeholder="Сообщение"
/>
<Button onClick={send}>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4" />
Участники ({members.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{members.map((member) => (
<div key={member.id} className="rounded-xl border p-2 text-sm">
<p className="font-medium">{member.user?.name || member.user?.email}</p>
<p className="text-xs text-muted-foreground">{member.role}</p>
</div>
))}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -64,7 +64,7 @@ export default function LearningPage() {
<GraduationCap className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" /> <GraduationCap className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
<p className="text-lg font-medium">Пока нет записей</p> <p className="text-lg font-medium">Пока нет записей</p>
<p className="text-muted-foreground mb-4">Найдите курсы в каталоге</p> <p className="text-muted-foreground mb-4">Найдите курсы в каталоге</p>
<Link href="/dashboard/catalog" className="text-primary hover:underline"> <Link href="/courses" className="text-primary hover:underline">
Открыть каталог Открыть каталог
</Link> </Link>
</div> </div>

View File

@ -1,8 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, BookOpen, Clock, TrendingUp, Loader2 } from 'lucide-react'; import { BookOpen, Clock3, FileText, Loader2, Plus, Send, Sparkles } 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 { CourseCard } from '@/components/dashboard/course-card'; import { CourseCard } from '@/components/dashboard/course-card';
@ -10,41 +10,40 @@ import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
interface Course { type Course = {
id: string; id: string;
title: string; title: string;
description: string | null; description: string | null;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING'; status:
| 'DRAFT'
| 'PUBLISHED'
| 'ARCHIVED'
| 'GENERATING'
| 'PENDING_MODERATION'
| 'PENDING_REVIEW'
| 'REJECTED'
| 'APPROVED';
chaptersCount: number; chaptersCount: number;
lessonsCount: number; lessonsCount: number;
updatedAt: string; updatedAt: string;
} };
export default function DashboardPage() { export default function DashboardPage() {
const { toast } = useToast(); const { toast } = useToast();
const { loading: authLoading, user } = useAuth(); const { loading: authLoading, user } = useAuth();
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
total: 0,
drafts: 0,
published: 0,
});
const loadCourses = async () => { const loadCourses = async () => {
setLoading(true); setLoading(true);
try { try {
const result = await api.getCourses(); const result = await api.getCourses({ limit: 100 });
setCourses(result.data); setCourses(result.data || []);
const total = result.data.length;
const drafts = result.data.filter((c: Course) => c.status === 'DRAFT').length;
const published = result.data.filter((c: Course) => c.status === 'PUBLISHED').length;
setStats({ total, drafts, published });
} catch (error: any) { } catch (error: any) {
if (error.message !== 'Unauthorized') { if (error.message !== 'Unauthorized') {
toast({ toast({
title: 'Ошибка загрузки', title: 'Ошибка загрузки',
description: 'Не удалось загрузить курсы', description: 'Не удалось загрузить ваши курсы',
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -60,17 +59,25 @@ export default function DashboardPage() {
return; return;
} }
loadCourses(); loadCourses();
}, [toast, authLoading, user]); }, [authLoading, user]);
const statsCards = [ const stats = useMemo(() => {
{ name: 'Всего курсов', value: stats.total.toString(), icon: BookOpen }, const drafts = courses.filter((course) => course.status === 'DRAFT' || course.status === 'REJECTED');
{ name: 'Черновики', value: stats.drafts.toString(), icon: Clock }, const published = courses.filter((course) => course.status === 'PUBLISHED');
{ name: 'Опубликовано', value: stats.published.toString(), icon: TrendingUp }, const pending = courses.filter(
]; (course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW'
);
return {
drafts,
published,
pending,
total: courses.length,
};
}, [courses]);
if (authLoading || loading) { if (authLoading || loading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex min-h-[400px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div> </div>
); );
@ -78,62 +85,119 @@ export default function DashboardPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} <section className="rounded-3xl border border-border/50 bg-gradient-to-br from-primary/10 via-background to-amber-100/40 p-6">
<div className="flex items-center 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">Мои курсы</h1> <h1 className="text-3xl font-bold tracking-tight">Кабинет автора</h1>
<p className="text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Управляйте своими курсами и создавайте новые Здесь только ваша авторская зона: черновики, курсы в проверке и опубликованные материалы.
</p> </p>
</div> </div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/">
<Sparkles className="mr-2 h-4 w-4" />
Открыть лендинг
</Link>
</Button>
<Button asChild> <Button asChild>
<Link href="/dashboard/courses/new"> <Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Создать курс Новый курс
</Link> </Link>
</Button> </Button>
</div> </div>
</div>
</section>
{/* Stats */} <section className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-3"> <Card className="border-border/60">
{statsCards.map((stat) => ( <CardHeader className="pb-2">
<Card key={stat.name}> <CardTitle className="text-sm">Черновики</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardDescription>Можно редактировать и дорабатывать</CardDescription>
<CardTitle className="text-sm font-medium">{stat.name}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex items-center justify-between">
<div className="text-2xl font-bold">{stat.value}</div> <span className="text-2xl font-bold">{stats.drafts.length}</span>
<FileText className="h-5 w-5 text-muted-foreground" />
</CardContent> </CardContent>
</Card> </Card>
))}
</div>
{/* Courses grid */} <Card className="border-border/60">
{courses.length > 0 ? ( <CardHeader className="pb-2">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <CardTitle className="text-sm">На модерации</CardTitle>
{courses.map((course) => ( <CardDescription>Ожидают решения модератора</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<span className="text-2xl font-bold">{stats.pending.length}</span>
<Send className="h-5 w-5 text-muted-foreground" />
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Опубликованные</CardTitle>
<CardDescription>Доступны пользователям в каталоге</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<span className="text-2xl font-bold">{stats.published.length}</span>
<BookOpen className="h-5 w-5 text-muted-foreground" />
</CardContent>
</Card>
</section>
<section className="space-y-4">
<div className="flex items-center gap-2">
<Clock3 className="h-4 w-4 text-muted-foreground" />
<h2 className="text-xl font-semibold">Черновики и отклонённые</h2>
</div>
{stats.drafts.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{stats.drafts.map((course) => (
<CourseCard key={course.id} course={course} onDeleted={loadCourses} /> <CourseCard key={course.id} course={course} onDeleted={loadCourses} />
))} ))}
</div> </div>
) : ( ) : (
<Card className="p-12 text-center"> <Card className="border-dashed">
<CardHeader> <CardContent className="p-10 text-center text-sm text-muted-foreground">
<CardTitle>Нет курсов</CardTitle> Нет черновиков. Создайте курс, чтобы начать работу.
<CardDescription>
Создайте свой первый курс с помощью AI
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" />
Создать курс
</Link>
</Button>
</CardContent> </CardContent>
</Card> </Card>
)} )}
</section>
<section className="space-y-4">
<h2 className="text-xl font-semibold">На проверке</h2>
{stats.pending.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{stats.pending.map((course) => (
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-sm text-muted-foreground">
Сейчас нет курсов в модерации.
</CardContent>
</Card>
)}
</section>
<section className="space-y-4">
<h2 className="text-xl font-semibold">Опубликованные</h2>
{stats.published.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{stats.published.map((course) => (
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-sm text-muted-foreground">
Пока нет опубликованных курсов.
</CardContent>
</Card>
)}
</section>
</div> </div>
); );
} }

View File

@ -0,0 +1,154 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { MessageCircle, Plus, Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
import { getWsBaseUrl } from '@/lib/ws';
export default function SupportPage() {
const [tickets, setTickets] = useState<any[]>([]);
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
const [messages, setMessages] = useState<any[]>([]);
const [ticketTitle, setTicketTitle] = useState('');
const [ticketMessage, setTicketMessage] = useState('');
const [newMessage, setNewMessage] = useState('');
const socketRef = useRef<Socket | null>(null);
const selectedTicket = useMemo(
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
[tickets, selectedTicketId]
);
const loadTickets = async () => {
const data = await api.getMySupportTickets().catch(() => []);
setTickets(data);
if (!selectedTicketId && data.length > 0) {
setSelectedTicketId(data[0].id);
}
};
useEffect(() => {
loadTickets();
}, []);
useEffect(() => {
if (!selectedTicketId) return;
api.getSupportTicketMessages(selectedTicketId).then((data) => setMessages(data)).catch(() => setMessages([]));
const token =
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
const socket = io(`${getWsBaseUrl()}/ws/support`, {
transports: ['websocket'],
auth: { token },
});
socketRef.current = socket;
socket.emit('support:join', { ticketId: selectedTicketId });
socket.on('support:new-message', (message: any) => {
setMessages((prev) => [...prev, message]);
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [selectedTicketId]);
const createTicket = async () => {
if (!ticketTitle.trim()) return;
await api.createSupportTicket({ title: ticketTitle.trim(), initialMessage: ticketMessage.trim() || undefined });
setTicketTitle('');
setTicketMessage('');
await loadTickets();
};
const sendMessage = async () => {
if (!selectedTicketId || !newMessage.trim()) return;
await api.sendSupportMessage(selectedTicketId, newMessage.trim());
setNewMessage('');
const latest = await api.getSupportTicketMessages(selectedTicketId).catch(() => []);
setMessages(latest);
};
return (
<div className="grid gap-4 lg:grid-cols-[320px_1fr]">
<Card>
<CardHeader>
<CardTitle>Тикеты поддержки</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<input
value={ticketTitle}
onChange={(e) => setTicketTitle(e.target.value)}
placeholder="Тема тикета"
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
<textarea
value={ticketMessage}
onChange={(e) => setTicketMessage(e.target.value)}
placeholder="Опишите проблему"
className="w-full min-h-[90px] rounded-md border bg-background p-3 text-sm"
/>
<Button onClick={createTicket} className="w-full">
<Plus className="mr-2 h-4 w-4" />
Создать тикет
</Button>
</div>
<div className="space-y-2">
{tickets.map((ticket) => (
<button
key={ticket.id}
className={`w-full rounded-md border px-3 py-2 text-left text-sm ${
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : ''
}`}
onClick={() => setSelectedTicketId(ticket.id)}
>
<p className="font-medium">{ticket.title}</p>
<p className="text-xs text-muted-foreground">Статус: {ticket.status}</p>
</button>
))}
</div>
</CardContent>
</Card>
<Card className="min-h-[520px]">
<CardHeader>
<CardTitle>{selectedTicket ? `Чат тикета: ${selectedTicket.title}` : 'Выберите тикет'}</CardTitle>
</CardHeader>
<CardContent className="flex h-[430px] flex-col">
<div className="flex-1 overflow-auto space-y-2 pr-2">
{messages.map((msg) => (
<div key={msg.id} className="rounded-md border p-2 text-sm">
<p className="font-medium">
{msg.user?.name || 'Пользователь'} {msg.isStaff ? '(Поддержка)' : ''}
</p>
<p>{msg.content}</p>
</div>
))}
{messages.length === 0 && (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<MessageCircle className="h-4 w-4 mr-2" />
Сообщений пока нет
</div>
)}
</div>
<div className="mt-3 flex gap-2">
<input
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Сообщение"
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
/>
<Button onClick={sendMessage} disabled={!selectedTicketId}>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { Sidebar } from '@/components/dashboard/sidebar'; import { Sidebar } from '@/components/dashboard/sidebar';
import { DashboardHeader } from '@/components/dashboard/header'; import { DashboardHeader } from '@/components/dashboard/header';
import { SupportWidget } from '@/components/dashboard/support-widget';
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
@ -13,6 +14,7 @@ export default function DashboardLayout({
<DashboardHeader /> <DashboardHeader />
<main className="flex-1 p-6">{children}</main> <main className="flex-1 p-6">{children}</main>
</div> </div>
<SupportWidget />
</div> </div>
); );
} }

View File

@ -0,0 +1,78 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Loader2, ShieldAlert, ShieldCheck } from 'lucide-react';
import { useAuth } from '@/contexts/auth-context';
import { cn } from '@/lib/utils';
const navigation = [
{ name: 'Курсы', href: '/admin' },
{ name: 'Тикеты поддержки', href: '/admin/support' },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { loading, backendUser } = useAuth();
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!isStaff) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="w-full max-w-md rounded-xl border bg-card p-6 text-center">
<ShieldAlert className="mx-auto mb-3 h-8 w-8 text-rose-500" />
<p className="text-base font-semibold">Нет доступа к Админ Панели</p>
<p className="mt-1 text-sm text-muted-foreground">Требуется роль администратора или модератора.</p>
<Link href="/dashboard" className="mt-4 inline-block text-sm text-primary hover:underline">
Вернуться в личный кабинет
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-muted/20">
<header className="border-b bg-background">
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<ShieldCheck className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-semibold">Админ Панель</p>
<p className="text-xs text-muted-foreground">Модерация и поддержка</p>
</div>
</div>
<div className="flex items-center gap-5">
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'text-sm font-medium',
pathname === item.href ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
{item.name}
</Link>
))}
<Link href="/dashboard" className="text-sm text-primary hover:underline">
Личный кабинет
</Link>
</div>
</div>
</header>
<main className="container py-6">{children}</main>
</div>
);
}

View File

@ -0,0 +1,474 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
CheckCircle2,
CreditCard,
ExternalLink,
Loader2,
MessageCircle,
Search,
Trash2,
Users,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast';
import { cn } from '@/lib/utils';
type ModerationCourse = {
id: string;
title: string;
status: string;
author?: { name?: string | null; email?: string };
moderationNote?: string | null;
updatedAt: string;
_count?: { chapters?: number; enrollments?: number; reviews?: number };
};
type AdminUser = {
id: string;
email: string;
name?: string | null;
role: 'USER' | 'MODERATOR' | 'ADMIN';
subscriptionTier?: string;
};
type AdminPayment = {
id: string;
amount: string | number;
currency: string;
status: string;
mode: 'DEV' | 'PROD';
provider: 'STRIPE' | 'YOOMONEY';
eventCode?: string | null;
createdAt: string;
user?: { name?: string | null; email?: string };
course?: { id: string; title: string };
};
const statusFilters = [
{ value: '', label: 'Все статусы' },
{ value: 'PENDING_MODERATION', label: 'На проверке' },
{ value: 'PUBLISHED', label: 'Опубликованные' },
{ value: 'REJECTED', label: 'Отклонённые' },
{ value: 'DRAFT', label: 'Черновики' },
];
const badgeMap: Record<string, string> = {
PENDING_REVIEW: 'bg-amber-100 text-amber-900',
PENDING_MODERATION: 'bg-amber-100 text-amber-900',
PUBLISHED: 'bg-green-100 text-green-900',
REJECTED: 'bg-rose-100 text-rose-900',
DRAFT: 'bg-slate-100 text-slate-900',
ARCHIVED: 'bg-slate-200 text-slate-900',
};
export default function AdminPage() {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<'courses' | 'users' | 'payments'>('courses');
const [courses, setCourses] = useState<ModerationCourse[]>([]);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [noteDraft, setNoteDraft] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
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 () => {
setLoading(true);
try {
const requestedStatus = status === 'PENDING_REVIEW' ? 'PENDING_MODERATION' : status;
const data = await api.getModerationCourses({ status: requestedStatus || undefined, search: search || undefined });
setCourses(data || []);
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить курсы', variant: 'destructive' });
setCourses([]);
} finally {
setLoading(false);
}
};
const loadUsers = async () => {
setUsersLoading(true);
try {
const data = await api.getAdminUsers({ search: usersSearch || undefined });
setAdminUsers(data || []);
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить пользователей', variant: 'destructive' });
setAdminUsers([]);
} finally {
setUsersLoading(false);
}
};
const loadPayments = async () => {
setPaymentsLoading(true);
try {
const data = await api.getAdminPayments({
search: paymentSearch || undefined,
mode: (paymentMode || undefined) as any,
});
setPayments(data || []);
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить платежи', variant: 'destructive' });
setPayments([]);
} finally {
setPaymentsLoading(false);
}
};
useEffect(() => {
loadCourses();
}, [status]);
useEffect(() => {
if (activeTab === 'users') {
loadUsers();
}
if (activeTab === 'payments') {
loadPayments();
}
}, [activeTab]);
const stats = useMemo(() => {
return {
total: courses.length,
pending: courses.filter((course) => course.status === 'PENDING_MODERATION' || course.status === 'PENDING_REVIEW').length,
published: courses.filter((course) => course.status === 'PUBLISHED').length,
};
}, [courses]);
const approve = async (courseId: string) => {
setActingId(courseId);
try {
await api.approveModerationCourse(courseId, noteDraft[courseId] || undefined);
toast({ title: 'Курс опубликован', description: 'Курс прошёл модерацию' });
await loadCourses();
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
} finally {
setActingId(null);
}
};
const reject = async (courseId: string) => {
setActingId(courseId);
try {
await api.rejectModerationCourse(courseId, noteDraft[courseId] || 'Нужны доработки');
toast({ title: 'Курс отклонён', description: 'Автор увидит причину в курсе' });
await loadCourses();
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
} finally {
setActingId(null);
}
};
const removeCourse = async (courseId: string) => {
setActingId(courseId);
try {
await api.deleteModerationCourse(courseId);
toast({ title: 'Курс удалён', description: 'Курс удалён администратором' });
await loadCourses();
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
} finally {
setActingId(null);
}
};
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 (
<div className="space-y-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>
<h1 className="text-3xl font-bold tracking-tight">Админ Панель</h1>
<p className="mt-1 text-sm text-muted-foreground">
Модерация курсов, поддержка, управление пользователями и платежами.
</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin/support">
<MessageCircle className="mr-2 h-4 w-4" />
Тикеты поддержки
</Link>
</Button>
</div>
</section>
<section className="flex flex-wrap gap-2">
<Button variant={activeTab === 'courses' ? 'default' : 'outline'} onClick={() => setActiveTab('courses')}>
Курсы
</Button>
<Button variant={activeTab === 'users' ? 'default' : 'outline'} onClick={() => setActiveTab('users')}>
<Users className="mr-2 h-4 w-4" />
Пользователи
</Button>
<Button variant={activeTab === 'payments' ? 'default' : 'outline'} onClick={() => setActiveTab('payments')}>
<CreditCard className="mr-2 h-4 w-4" />
Платежи
</Button>
</section>
{activeTab === 'courses' ? (
<>
<section className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Всего в выдаче</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{stats.total}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Ожидают модерации</CardTitle>
</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>
</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>
</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
size="sm"
variant="destructive"
onClick={() => removeCourse(course.id)}
disabled={actingId === course.id}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить курс
</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}
</CardContent>
</Card>
))}
{!paymentsLoading && payments.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-sm text-muted-foreground">Платежи не найдены.</CardContent>
</Card>
) : null}
</section>
) : null}
</div>
);
}

View File

@ -0,0 +1,188 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { io, Socket } from 'socket.io-client';
import { ChevronLeft, Loader2, Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
import { getWsBaseUrl } from '@/lib/ws';
import { cn } from '@/lib/utils';
export default function AdminSupportPage() {
const [tickets, setTickets] = useState<any[]>([]);
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
const [messages, setMessages] = useState<any[]>([]);
const [status, setStatus] = useState('in_progress');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const socketRef = useRef<Socket | null>(null);
const selected = useMemo(
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
[tickets, selectedTicketId]
);
const loadTickets = async () => {
setLoading(true);
const data = await api.getAdminSupportTickets().catch(() => []);
setTickets(data);
if (!selectedTicketId && data.length > 0) {
setSelectedTicketId(data[0].id);
}
setLoading(false);
};
useEffect(() => {
loadTickets();
}, []);
useEffect(() => {
if (!selectedTicketId) return;
api
.getAdminSupportTicketMessages(selectedTicketId)
.then((data) => setMessages(data))
.catch(() => setMessages([]));
const token =
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
const socket = io(`${getWsBaseUrl()}/ws/support`, {
transports: ['websocket'],
auth: { token },
});
socketRef.current = socket;
socket.emit('support:join', { ticketId: selectedTicketId });
socket.on('support:new-message', (msg: any) => {
setMessages((prev) => [...prev, msg]);
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [selectedTicketId]);
useEffect(() => {
if (!selected) return;
setStatus(selected.status || 'in_progress');
}, [selected?.id]);
const sendReply = async () => {
if (!selectedTicketId || !message.trim()) return;
setSending(true);
await api.sendAdminSupportMessage(selectedTicketId, message.trim());
setMessage('');
const list = await api.getAdminSupportTicketMessages(selectedTicketId).catch(() => []);
setMessages(list);
await loadTickets();
setSending(false);
};
const updateStatus = async () => {
if (!selectedTicketId) return;
setSending(true);
await api.updateAdminSupportTicketStatus(selectedTicketId, status);
await loadTickets();
setSending(false);
};
if (loading) {
return (
<div className="flex min-h-[400px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Тикеты поддержки</h1>
<Button variant="outline" asChild>
<Link href="/admin">
<ChevronLeft className="mr-2 h-4 w-4" />
К модерации
</Link>
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
<Card>
<CardHeader>
<CardTitle>Очередь тикетов</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{tickets.map((ticket) => (
<button
key={ticket.id}
className={cn(
'w-full rounded-xl border px-3 py-2 text-left text-sm transition',
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
)}
onClick={() => setSelectedTicketId(ticket.id)}
>
<p className="font-medium">{ticket.title}</p>
<p className="text-xs text-muted-foreground">
{ticket.user?.name || ticket.user?.email} {ticket.status}
</p>
</button>
))}
</CardContent>
</Card>
<Card className="min-h-[560px]">
<CardHeader>
<CardTitle>{selected ? `Тикет: ${selected.title}` : 'Выберите тикет'}</CardTitle>
</CardHeader>
<CardContent className="flex h-[470px] flex-col">
<div className="mb-3 flex items-center gap-2">
<select
className="h-9 rounded-lg border bg-background px-3 text-sm"
value={status}
onChange={(e) => setStatus(e.target.value)}
disabled={!selectedTicketId}
>
<option value="open">open</option>
<option value="in_progress">in_progress</option>
<option value="resolved">resolved</option>
<option value="closed">closed</option>
</select>
<Button variant="outline" onClick={updateStatus} disabled={!selectedTicketId || sending}>
Обновить статус
</Button>
</div>
<div className="flex-1 space-y-2 overflow-auto pr-2">
{messages.map((msg) => (
<div key={msg.id} className={cn('flex', msg.isStaff ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[75%] rounded-2xl px-3 py-2 text-sm',
msg.isStaff ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Пользователь'}</p>
<p>{msg.content}</p>
</div>
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Ответ поддержки"
className="h-10 flex-1 rounded-lg border bg-background px-3 text-sm"
/>
<Button onClick={sendReply} disabled={!selectedTicketId || sending}>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/landing/header';
import { Footer } from '@/components/landing/footer';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
export default function CooperationPage() {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [form, setForm] = useState({
organization: '',
contactName: '',
email: '',
phone: '',
role: '',
organizationType: '',
message: '',
});
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
setSuccess(null);
try {
const result = await api.submitCooperationRequest({
organization: form.organization,
contactName: form.contactName,
email: form.email,
phone: form.phone || undefined,
role: form.role || undefined,
organizationType: form.organizationType || undefined,
message: form.message,
});
setSuccess(result?.status === 'stored_and_sent' ? 'Заявка отправлена. Мы свяжемся с вами.' : 'Заявка сохранена. Мы свяжемся с вами по почте.');
setForm({
organization: '',
contactName: '',
email: '',
phone: '',
role: '',
organizationType: '',
message: '',
});
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container py-10 space-y-6">
<section className="rounded-3xl border border-border/50 bg-gradient-to-br from-primary/10 via-background to-primary/5 p-6">
<h1 className="text-3xl font-bold">Сотрудничество</h1>
<p className="mt-2 text-muted-foreground max-w-3xl">
Предоставляем платформу для вузов, школ, колледжей и компаний по договорённости:
запуск внутренних академий, каталогов курсов, трекинг прогресса и поддержка авторов.
</p>
</section>
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle>Что можем предоставить</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>1. White-label платформу с вашей айдентикой.</p>
<p>2. Инструменты для авторов и методистов.</p>
<p>3. Проверку контента, модерацию и аналитику обучения.</p>
<p>4. Корпоративные группы, чаты и домашние задания.</p>
<p>5. Интеграцию с процессами вашей организации.</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Оставить заявку</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-3">
<input
value={form.organization}
onChange={(e) => setForm((prev) => ({ ...prev, organization: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Организация"
required
/>
<input
value={form.contactName}
onChange={(e) => setForm((prev) => ({ ...prev, contactName: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Контактное лицо"
required
/>
<input
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Email"
required
/>
<input
value={form.phone}
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
placeholder="Телефон (необязательно)"
/>
<textarea
value={form.message}
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
className="min-h-[140px] w-full rounded-lg border bg-background p-3 text-sm"
placeholder="Опишите задачу и масштаб внедрения"
required
/>
<Button className="w-full" disabled={loading}>
{loading ? 'Отправка...' : 'Отправить заявку'}
</Button>
{success ? <p className="text-xs text-emerald-600">{success}</p> : null}
</form>
</CardContent>
</Card>
</div>
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,278 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, BookOpen, Check, ChevronDown, Clock, Loader2, Shield, Star, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { useToast } from '@/components/ui/use-toast';
import { cn } from '@/lib/utils';
import { Header } from '@/components/landing/header';
import { Footer } from '@/components/landing/footer';
export default function PublicCoursePage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const { user } = useAuth();
const id = params?.id as string;
const [course, setCourse] = useState<any>(null);
const [reviews, setReviews] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [enrolling, setEnrolling] = useState(false);
const [enrolled, setEnrolled] = useState(false);
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
const isDevPayment = searchParams?.get('devPayment') === '1';
useEffect(() => {
if (!id) return;
(async () => {
setLoading(true);
try {
const [courseData, reviewsData] = await Promise.all([
api.getPublicCourse(id),
api.getCourseReviews(id).catch(() => ({ data: [] })),
]);
setCourse(courseData);
setReviews(reviewsData.data || []);
if (user) {
const enrollments = await api.getMyEnrollments().catch(() => []);
setEnrolled(enrollments.some((item: any) => item.course.id === id));
}
} catch {
setCourse(null);
} finally {
setLoading(false);
}
})();
}, [id, user]);
const handleEnroll = async () => {
if (!id || enrolling) return;
if (!user) {
router.push('/login');
return;
}
setEnrolling(true);
try {
if (course?.price) {
const session = await api.checkoutCourse(id);
if (session?.url) {
window.location.href = session.url;
return;
}
} else {
await api.enrollInCourse(id);
toast({ title: 'Готово', description: 'Вы записались на курс' });
router.push(`/dashboard/courses/${id}`);
}
} catch (error: any) {
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
} finally {
setEnrolling(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex-1 flex min-h-[420px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<Footer />
</div>
);
}
if (!course) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="container py-20 text-center flex-1">
<p className="text-muted-foreground">Курс не найден</p>
<Link href="/courses" className="mt-3 inline-block text-sm text-primary hover:underline">
Вернуться в Курсы
</Link>
</div>
<Footer />
</div>
);
}
const totalLessons = course.chapters.reduce((acc: number, chapter: any) => acc + chapter.lessons.length, 0);
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="container max-w-6xl space-y-6 py-8 flex-1">
<Button variant="ghost" size="sm" asChild>
<Link href="/courses">
<ArrowLeft className="mr-2 h-4 w-4" />
Назад к курсам
</Link>
</Button>
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-5 md:col-span-2">
<div className="aspect-video overflow-hidden rounded-2xl bg-gradient-to-br from-amber-100 via-background to-cyan-100">
{course.coverImage ? (
<img src={course.coverImage} alt={course.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<BookOpen className="h-12 w-12 text-primary/40" />
</div>
)}
</div>
<div>
<div className="mb-2 flex flex-wrap items-center gap-2">
{course.isVerified ? (
<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" />
Проверен автором
<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>
) : null}
{course.difficulty ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{course.difficulty === 'beginner'
? 'Начинающий'
: course.difficulty === 'intermediate'
? 'Средний'
: 'Продвинутый'}
</span>
) : null}
</div>
<h1 className="text-3xl font-bold tracking-tight">{course.title}</h1>
<p className="mt-2 text-muted-foreground">{course.description}</p>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Users className="h-4 w-4" />
{course._count.enrollments} студентов
</span>
<span className="inline-flex items-center gap-1">
<Star className="h-4 w-4" />
{course.averageRating ? `${course.averageRating.toFixed(1)} (${course._count.reviews})` : 'Без рейтинга'}
</span>
<span>{totalLessons} уроков</span>
{course.estimatedHours ? (
<span className="inline-flex items-center gap-1">
<Clock className="h-4 w-4" />
{course.estimatedHours}ч
</span>
) : null}
</div>
<Card>
<CardContent className="p-5">
<h2 className="mb-4 text-lg font-semibold">Содержание курса</h2>
<div className="space-y-2">
{course.chapters.map((chapter: any) => {
const expanded = expandedChapters.includes(chapter.id);
return (
<div key={chapter.id} className="overflow-hidden rounded-xl border">
<button
onClick={() =>
setExpandedChapters((prev) =>
prev.includes(chapter.id) ? prev.filter((id: string) => id !== chapter.id) : [...prev, chapter.id]
)
}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50"
>
<p className="font-medium">{chapter.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{chapter.lessons.length} уроков</span>
<ChevronDown className={cn('h-4 w-4 transition', expanded && 'rotate-180')} />
</div>
</button>
{expanded ? (
<div className="border-t bg-muted/20 p-2">
{chapter.lessons.map((lesson: any) => (
<div key={lesson.id} className="flex items-center gap-2 px-2 py-2 text-sm">
<Check className="h-3.5 w-3.5 text-muted-foreground" />
<span>{lesson.title}</span>
{lesson.durationMinutes ? (
<span className="ml-auto text-xs text-muted-foreground">{lesson.durationMinutes} мин</span>
) : null}
</div>
))}
</div>
) : null}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
<Card className="h-fit md:sticky md:top-6">
<CardContent className="space-y-4 p-5">
<p className="text-3xl font-bold">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</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 ? (
<Button className="w-full" asChild>
<Link href={`/dashboard/courses/${id}`}>Перейти к обучению</Link>
</Button>
) : (
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{course.price ? 'Купить курс' : 'Записаться'}
</Button>
)}
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-600" />
<span>{course.chapters.length} глав</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-600" />
<span>{totalLessons} уроков</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-600" />
<span>Сертификат по окончании</span>
</div>
</div>
</CardContent>
</Card>
</div>
{reviews.length ? (
<Card>
<CardContent className="space-y-4 p-5">
<h2 className="text-lg font-semibold">Отзывы</h2>
{reviews.map((review: any) => (
<div key={review.id} className="border-b pb-3 last:border-b-0">
<p className="text-sm font-medium">{review.user.name || 'Пользователь'}</p>
<p className="text-xs text-muted-foreground">Оценка: {review.rating}/5</p>
{review.title ? <p className="mt-1 text-sm font-medium">{review.title}</p> : null}
{review.content ? <p className="mt-1 text-sm text-muted-foreground">{review.content}</p> : null}
</div>
))}
</CardContent>
</Card>
) : null}
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,185 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Search, Star, Users, BookOpen, Shield, Loader2 } from 'lucide-react';
import { Header } from '@/components/landing/header';
import { Footer } from '@/components/landing/footer';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
interface CatalogCourse {
id: string;
title: string;
description: string | null;
coverImage: string | null;
difficulty: string | null;
price: number | null;
currency: string;
isVerified: boolean;
averageRating: number | null;
enrollmentCount: number;
reviewCount: number;
chaptersCount: number;
lessonsCount: number;
author: { id: string; name: string | null; avatarUrl: string | null };
}
const difficultyLabels: Record<string, { label: string; color: string }> = {
beginner: { label: 'Начинающий', color: 'text-green-600 bg-green-50 dark:bg-green-900/30' },
intermediate: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30' },
advanced: { label: 'Продвинутый', color: 'text-red-600 bg-red-50 dark:bg-red-900/30' },
};
export default function CoursesPage() {
const [courses, setCourses] = useState<CatalogCourse[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [difficulty, setDifficulty] = useState('');
const loadCourses = async () => {
setLoading(true);
try {
const result = await api.getCatalog({
search: searchQuery || undefined,
difficulty: difficulty || undefined,
});
setCourses(result.data);
} catch {
setCourses([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCourses();
}, [difficulty]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
loadCourses();
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container py-10 space-y-6">
<div>
<h1 className="text-3xl font-bold">Курсы</h1>
<p className="text-muted-foreground mt-1">Каталог доступных курсов для обучения</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Поиск курсов..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<Button type="submit" size="sm">Найти</Button>
</form>
<div className="flex gap-2">
{['', 'beginner', 'intermediate', 'advanced'].map((d) => (
<Button
key={d}
variant={difficulty === d ? 'default' : 'outline'}
size="sm"
onClick={() => setDifficulty(d)}
>
{d ? difficultyLabels[d]?.label : 'Все'}
</Button>
))}
</div>
</div>
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : courses.length === 0 ? (
<div className="text-center py-20">
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground">Пока нет опубликованных курсов</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => (
<Link key={course.id} href={`/courses/${course.id}`}>
<Card className="group overflow-hidden transition-all hover:shadow-lg hover:-translate-y-0.5 cursor-pointer h-full">
<div className="aspect-video bg-gradient-to-br from-primary/20 to-primary/5 relative overflow-hidden">
{course.coverImage ? (
<img src={course.coverImage} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<BookOpen className="h-10 w-10 text-primary/30" />
</div>
)}
{course.isVerified && (
<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" />
Проверен автором
<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>
)}
{course.difficulty && difficultyLabels[course.difficulty] && (
<div className={cn(
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-xs font-medium',
difficultyLabels[course.difficulty].color
)}>
{difficultyLabels[course.difficulty].label}
</div>
)}
</div>
<CardContent className="p-4">
<h3 className="font-semibold line-clamp-1 group-hover:text-primary transition-colors">
{course.title}
</h3>
{course.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">{course.description}</p>
)}
<p className="text-xs text-muted-foreground mt-3">
{course.author.name || 'Автор'}
</p>
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
{course.averageRating && (
<span className="flex items-center gap-0.5 text-yellow-600 font-medium">
<Star className="h-3.5 w-3.5 fill-current" />
{course.averageRating.toFixed(1)}
</span>
)}
<span className="flex items-center gap-0.5">
<Users className="h-3.5 w-3.5" />
{course.enrollmentCount}
</span>
<span>{course.lessonsCount} уроков</span>
</div>
<div className="mt-3 pt-3 border-t">
<span className="font-bold text-lg">
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,128 @@
'use client';
import { FormEvent, useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader2, Lock, Mail, ShieldCheck } from 'lucide-react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/contexts/auth-context';
import { useToast } from '@/components/ui/use-toast';
export default function StaffLoginPage() {
const router = useRouter();
const { toast } = useToast();
const { signIn, backendUser, loading } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [awaitingRole, setAwaitingRole] = useState(false);
useEffect(() => {
if (!awaitingRole || loading) return;
if (backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR') {
router.push('/admin');
return;
}
toast({
title: 'Нет доступа',
description: 'Этот вход доступен только администраторам и модераторам',
variant: 'destructive',
});
router.push('/dashboard');
}, [awaitingRole, loading, backendUser, router, toast]);
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (submitting) return;
setSubmitting(true);
const { error } = await signIn(email, password);
if (error) {
toast({
title: 'Ошибка входа',
description: error.message || 'Проверьте логин и пароль',
variant: 'destructive',
});
setSubmitting(false);
return;
}
setAwaitingRole(true);
setSubmitting(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-background to-amber-50/40">
<div className="container flex min-h-screen items-center justify-center py-8">
<Card className="w-full max-w-md border-border/60 shadow-xl">
<CardHeader className="text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<ShieldCheck className="h-6 w-6" />
</div>
<CardTitle>Вход для staff</CardTitle>
<CardDescription>Отдельная авторизация для админов и модераторов</CardDescription>
</CardHeader>
<form onSubmit={onSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="staff-email">Email</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="staff-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
placeholder="admin@company.com"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="staff-password">Пароль</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="staff-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button type="submit" className="w-full" disabled={submitting || awaitingRole}>
{submitting || awaitingRole ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Вход...
</>
) : (
'Войти в staff'
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Обычный вход для пользователей: <Link href="/login" className="text-primary hover:underline">/login</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
</div>
);
}

View File

@ -33,7 +33,15 @@ interface CourseCardProps {
id: string; id: string;
title: string; title: string;
description: string | null; description: string | null;
status: 'DRAFT' | 'GENERATING' | 'PUBLISHED' | 'ARCHIVED'; status:
| 'DRAFT'
| 'GENERATING'
| 'PENDING_MODERATION'
| 'PENDING_REVIEW'
| 'APPROVED'
| 'PUBLISHED'
| 'REJECTED'
| 'ARCHIVED';
chaptersCount: number; chaptersCount: number;
lessonsCount: number; lessonsCount: number;
updatedAt: string; updatedAt: string;
@ -55,6 +63,22 @@ const statusConfig = {
label: 'Опубликован', label: 'Опубликован',
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
}, },
PENDING_REVIEW: {
label: 'На модерации',
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: {
label: 'Отклонён',
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
},
ARCHIVED: { ARCHIVED: {
label: 'Архив', label: 'Архив',
className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200', className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Send, MessageCircle } from 'lucide-react'; import { MessageCircle, Send } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -25,15 +25,17 @@ export function CourseChat({ groupId, userId, onSendMessage, messages }: CourseC
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const groupedMessages = useMemo(() => messages, [messages]);
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [groupedMessages]);
const handleSend = async () => { const handleSend = async () => {
if (!newMessage.trim() || sending) return; if (!newMessage.trim() || sending) return;
setSending(true); setSending(true);
try { try {
await onSendMessage(newMessage); await onSendMessage(newMessage.trim());
setNewMessage(''); setNewMessage('');
} finally { } finally {
setSending(false); setSending(false);
@ -41,44 +43,60 @@ export function CourseChat({ groupId, userId, onSendMessage, messages }: CourseC
}; };
return ( return (
<Card className="flex flex-col h-[500px]"> <Card className="flex h-[560px] flex-col overflow-hidden border-border/60">
<CardHeader className="pb-3"> <CardHeader className="border-b bg-muted/25 pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center justify-between text-base">
<span className="flex items-center gap-2">
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
Чат курса Чат курса
</span>
<span className="text-xs font-medium text-muted-foreground">Группа {groupId.slice(0, 6)}</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 p-4">
<div className="flex-1 overflow-auto space-y-3 mb-3"> <CardContent className="flex min-h-0 flex-1 flex-col p-4">
{messages.map((msg) => { <div className="mb-3 flex-1 space-y-3 overflow-auto pr-2">
{groupedMessages.map((msg) => {
const isOwn = msg.user.id === userId; const isOwn = msg.user.id === userId;
return ( return (
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}> <div key={msg.id} className={cn('flex', isOwn ? 'justify-end' : 'justify-start')}>
<div className="h-8 w-8 shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary"> <div className={cn('max-w-[80%] space-y-1', isOwn ? 'items-end' : 'items-start')}>
{msg.user.name?.[0] || 'U'} <p className="text-xs text-muted-foreground">{msg.user.name || 'Участник'}</p>
</div> <div
<div className={cn('flex flex-col gap-1 max-w-[70%]', isOwn && 'items-end')}> className={cn(
<span className="text-xs text-muted-foreground">{msg.user.name || 'Аноним'}</span> 'rounded-2xl px-3 py-2 text-sm shadow-sm',
<div className={cn('rounded-lg px-3 py-2 text-sm', isOwn ? 'bg-primary text-primary-foreground' : 'bg-muted')}> isOwn
? 'rounded-br-md bg-primary text-primary-foreground'
: 'rounded-bl-md border bg-card'
)}
>
{msg.content} {msg.content}
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
{groupedMessages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Сообщений пока нет. Начните обсуждение.
</div>
) : null}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
<div className="flex gap-2">
<div className="flex gap-2 border-t pt-3">
<input <input
type="text" type="text"
value={newMessage} value={newMessage}
onChange={(e) => setNewMessage(e.target.value)} onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()} onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder="Написать сообщение..." placeholder="Написать сообщение..."
className="flex-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
disabled={sending} disabled={sending}
/> />
<Button size="sm" onClick={handleSend} disabled={sending || !newMessage.trim()}> <Button size="sm" className="h-10 rounded-xl px-4" onClick={handleSend} disabled={sending || !newMessage.trim()}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
</div> </div>

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Bell, Menu, LogOut, Settings, CreditCard } from 'lucide-react'; import { Bell, Menu, LogOut, Settings, CreditCard, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { import {
@ -15,7 +15,8 @@ import {
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
export function DashboardHeader() { export function DashboardHeader() {
const { user, signOut } = useAuth(); const { user, signOut, backendUser } = useAuth();
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
const initials = user?.user_metadata?.full_name const initials = user?.user_metadata?.full_name
?.split(' ') ?.split(' ')
@ -24,7 +25,7 @@ export function DashboardHeader() {
.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U'; .toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U';
return ( return (
<header className="flex h-16 items-center justify-between border-b px-6"> <header className="flex h-16 items-center justify-between border-b border-border/60 bg-background/80 px-6 backdrop-blur">
{/* Mobile menu button */} {/* Mobile menu button */}
<Button variant="ghost" size="icon" className="lg:hidden"> <Button variant="ghost" size="icon" className="lg:hidden">
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
@ -36,6 +37,14 @@ export function DashboardHeader() {
{/* Right side */} {/* Right side */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{isStaff ? (
<Button variant="outline" size="sm" asChild>
<Link href="/admin">
<ShieldCheck className="mr-2 h-4 w-4" />
Админ Панель
</Link>
</Button>
) : null}
{/* Notifications */} {/* Notifications */}
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />

View File

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

View File

@ -3,7 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CheckCircle2, XCircle, Trophy } from 'lucide-react'; import { CheckCircle2, XCircle, Trophy, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface QuizQuestion { interface QuizQuestion {
@ -13,36 +13,50 @@ interface QuizQuestion {
correctAnswer: number; correctAnswer: number;
} }
interface QuizSubmitResult {
score: number;
passed: boolean;
correctAnswers?: number;
totalQuestions?: number;
}
interface LessonQuizProps { interface LessonQuizProps {
courseId: string; courseId: string;
lessonId: string; lessonId: string;
questions: QuizQuestion[]; questions: QuizQuestion[];
onComplete: (score: number) => void; onComplete: (answers: number[]) => Promise<QuizSubmitResult>;
} }
export function LessonQuiz({ courseId, lessonId, questions, onComplete }: LessonQuizProps) { export function LessonQuiz({ questions, onComplete }: LessonQuizProps) {
const [currentQuestion, setCurrentQuestion] = useState(0); const [currentQuestion, setCurrentQuestion] = useState(0);
const [answers, setAnswers] = useState<number[]>([]); const [answers, setAnswers] = useState<number[]>([]);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null); const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [serverResult, setServerResult] = useState<QuizSubmitResult | null>(null);
const handleSelectAnswer = (optionIndex: number) => { const handleSelectAnswer = (optionIndex: number) => {
setSelectedAnswer(optionIndex); setSelectedAnswer(optionIndex);
}; };
const handleNext = () => { const handleNext = async () => {
if (selectedAnswer === null) return; if (selectedAnswer === null || submitting) return;
const newAnswers = [...answers, selectedAnswer]; const newAnswers = [...answers, selectedAnswer];
setAnswers(newAnswers); setAnswers(newAnswers);
if (currentQuestion < questions.length - 1) { if (currentQuestion < questions.length - 1) {
setCurrentQuestion(currentQuestion + 1); setCurrentQuestion(currentQuestion + 1);
setSelectedAnswer(null); setSelectedAnswer(null);
} else { return;
const correct = newAnswers.filter((ans, idx) => ans === questions[idx].correctAnswer).length; }
const score = Math.round((correct / questions.length) * 100);
setSubmitting(true);
try {
const result = await onComplete(newAnswers);
setServerResult(result);
setShowResults(true); setShowResults(true);
onComplete(score); } finally {
setSubmitting(false);
} }
}; };
@ -51,37 +65,43 @@ export function LessonQuiz({ courseId, lessonId, questions, onComplete }: Lesson
} }
if (showResults) { if (showResults) {
const correct = answers.filter((ans, idx) => ans === questions[idx].correctAnswer).length; const score = serverResult?.score ?? 0;
const score = Math.round((correct / questions.length) * 100); const passed = Boolean(serverResult?.passed);
const passed = score >= 70; const correct =
serverResult?.correctAnswers ??
answers.filter((ans, idx) => ans === questions[idx].correctAnswer).length;
const total = serverResult?.totalQuestions ?? questions.length;
return ( return (
<Card className="mt-8"> <Card className="mt-8">
<CardContent className="p-8 text-center"> <CardContent className="p-8 text-center">
<div className={cn( <div
className={cn(
'mx-auto h-16 w-16 rounded-full flex items-center justify-center mb-4', 'mx-auto h-16 w-16 rounded-full flex items-center justify-center mb-4',
passed ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900' passed ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900'
)}> )}
>
{passed ? ( {passed ? (
<Trophy className="h-8 w-8 text-green-600 dark:text-green-400" /> <Trophy className="h-8 w-8 text-green-600 dark:text-green-400" />
) : ( ) : (
<CheckCircle2 className="h-8 w-8 text-yellow-600 dark:text-yellow-400" /> <CheckCircle2 className="h-8 w-8 text-yellow-600 dark:text-yellow-400" />
)} )}
</div> </div>
<h3 className="text-xl font-bold mb-2"> <h3 className="text-xl font-bold mb-2">{passed ? 'Тест пройден' : 'Тест не пройден'}</h3>
{passed ? 'Отлично!' : 'Неплохо!'}
</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Ваш результат: {correct} из {questions.length} ({score}%) Ваш результат: {correct} из {total} ({score}%)
</p> </p>
<div className="space-y-2 max-w-md mx-auto"> <div className="space-y-2 max-w-md mx-auto">
{questions.map((q, idx) => { {questions.map((q, idx) => {
const isCorrect = answers[idx] === q.correctAnswer; const isCorrect = answers[idx] === q.correctAnswer;
return ( return (
<div key={q.id} className={cn( <div
key={q.id}
className={cn(
'flex items-center gap-2 p-2 rounded text-sm', 'flex items-center gap-2 p-2 rounded text-sm',
isCorrect ? 'bg-green-50 dark:bg-green-900/30' : 'bg-red-50 dark:bg-red-900/30' isCorrect ? 'bg-green-50 dark:bg-green-900/30' : 'bg-red-50 dark:bg-red-900/30'
)}> )}
>
{isCorrect ? ( {isCorrect ? (
<CheckCircle2 className="h-4 w-4 text-green-600" /> <CheckCircle2 className="h-4 w-4 text-green-600" />
) : ( ) : (
@ -102,9 +122,7 @@ export function LessonQuiz({ courseId, lessonId, questions, onComplete }: Lesson
return ( return (
<Card className="mt-8"> <Card className="mt-8">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>Тест по уроку ({currentQuestion + 1}/{questions.length})</CardTitle>
Тест по уроку ({currentQuestion + 1}/{questions.length})
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="font-medium">{question.question}</p> <p className="font-medium">{question.question}</p>
@ -115,21 +133,24 @@ export function LessonQuiz({ courseId, lessonId, questions, onComplete }: Lesson
onClick={() => handleSelectAnswer(idx)} onClick={() => handleSelectAnswer(idx)}
className={cn( className={cn(
'p-3 rounded-lg border text-left transition-colors', 'p-3 rounded-lg border text-left transition-colors',
selectedAnswer === idx selectedAnswer === idx ? 'border-primary bg-primary/5' : 'hover:bg-muted'
? 'border-primary bg-primary/5'
: 'hover:bg-muted'
)} )}
> >
{option} {option}
</button> </button>
))} ))}
</div> </div>
<Button <Button onClick={handleNext} disabled={selectedAnswer === null || submitting} className="w-full">
onClick={handleNext} {submitting ? (
disabled={selectedAnswer === null} <>
className="w-full" <Loader2 className="mr-2 h-4 w-4 animate-spin" />
> Проверка...
{currentQuestion < questions.length - 1 ? 'Следующий вопрос' : 'Завершить тест'} </>
) : currentQuestion < questions.length - 1 ? (
'Следующий вопрос'
) : (
'Завершить тест'
)}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -5,30 +5,38 @@ import { usePathname } from 'next/navigation';
import { import {
Sparkles, Sparkles,
LayoutDashboard, LayoutDashboard,
BookOpen,
GraduationCap, GraduationCap,
Compass,
Settings, Settings,
CreditCard, CreditCard,
Plus, Plus,
LifeBuoy,
Globe2,
ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/auth-context';
const navigation = [ const navigation = [
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard, exact: true }, { name: 'Мои курсы', href: '/dashboard', icon: LayoutDashboard, exact: true },
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen, exact: true },
{ name: 'Каталог', href: '/dashboard/catalog', icon: Compass },
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap }, { name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
{ name: 'Курсы', href: '/courses', icon: Globe2, external: true },
]; ];
const bottomNavigation = [ const bottomNavigation = [
{ name: 'Поддержка', href: '/dashboard/support', icon: LifeBuoy },
{ name: 'Настройки', href: '/dashboard/settings', icon: Settings }, { name: 'Настройки', href: '/dashboard/settings', icon: Settings },
{ name: 'Подписка', href: '/dashboard/billing', icon: CreditCard }, { name: 'Подписка', href: '/dashboard/billing', icon: CreditCard },
]; ];
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { backendUser } = useAuth();
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
const effectiveBottomNavigation = isStaff
? [{ name: 'Админ Панель', href: '/admin', icon: ShieldCheck }, ...bottomNavigation]
: bottomNavigation;
return ( return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:border-r lg:bg-muted/30"> <aside className="hidden lg:flex lg:flex-col lg:w-64 lg:border-r lg:bg-muted/30">
@ -78,7 +86,7 @@ export function Sidebar() {
{/* Bottom navigation */} {/* Bottom navigation */}
<nav className="p-4 border-t space-y-1"> <nav className="p-4 border-t space-y-1">
{bottomNavigation.map((item) => { {effectiveBottomNavigation.map((item) => {
const isActive = pathname === item.href; const isActive = pathname === item.href;
return ( return (
<Link <Link

View File

@ -0,0 +1,273 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { ChevronLeft, LifeBuoy, MessageCircle, Minus, Plus, 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 Ticket = {
id: string;
title: string;
status: string;
updatedAt: string;
};
type TicketMessage = {
id: string;
content: string;
isStaff: boolean;
createdAt: string;
user?: { id?: string; name?: string | null };
};
export function SupportWidget() {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
const [messages, setMessages] = useState<TicketMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [ticketTitle, setTicketTitle] = useState('');
const [ticketText, setTicketText] = useState('');
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const socketRef = useRef<Socket | null>(null);
const selectedTicket = useMemo(
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
[tickets, selectedTicketId]
);
const loadTickets = async () => {
setLoading(true);
try {
const data = await api.getMySupportTickets().catch(() => []);
setTickets(data || []);
if (!selectedTicketId && data?.length) {
setSelectedTicketId(data[0].id);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!open) return;
loadTickets();
}, [open]);
useEffect(() => {
if (!open || !selectedTicketId) return;
api
.getSupportTicketMessages(selectedTicketId)
.then((data) => setMessages(data || []))
.catch(() => setMessages([]));
const token =
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
const socket = io(`${getWsBaseUrl()}/ws/support`, {
transports: ['websocket'],
auth: { token },
});
socketRef.current = socket;
socket.emit('support:join', { ticketId: selectedTicketId });
socket.on('support:new-message', (msg: TicketMessage) => {
setMessages((prev) => [...prev, msg]);
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [open, selectedTicketId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, selectedTicketId]);
const createTicket = async () => {
if (!ticketTitle.trim()) return;
setSending(true);
try {
await api.createSupportTicket({
title: ticketTitle.trim(),
initialMessage: ticketText.trim() || undefined,
});
setTicketTitle('');
setTicketText('');
setCreating(false);
await loadTickets();
} finally {
setSending(false);
}
};
const sendMessage = async () => {
if (!selectedTicketId || !newMessage.trim()) return;
setSending(true);
try {
await api.sendSupportMessage(selectedTicketId, newMessage.trim());
setNewMessage('');
const latest = await api.getSupportTicketMessages(selectedTicketId).catch(() => []);
setMessages(latest || []);
await loadTickets();
} finally {
setSending(false);
}
};
return (
<div className="fixed bottom-4 right-4 z-40">
{open ? (
<div className="w-[360px] rounded-2xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-primary/15 p-1.5 text-primary">
<LifeBuoy className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold">Поддержка</p>
<p className="text-xs text-muted-foreground">Тикеты и ответы</p>
</div>
</div>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => setOpen(false)}>
<Minus className="h-4 w-4" />
</Button>
</div>
{!selectedTicketId || creating ? (
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Новый тикет</p>
{!creating ? (
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
<Plus className="mr-1 h-3 w-3" />
Создать
</Button>
) : (
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>
Назад
</Button>
)}
</div>
{creating ? (
<div className="space-y-2">
<input
value={ticketTitle}
onChange={(e) => setTicketTitle(e.target.value)}
placeholder="Тема тикета"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
<textarea
value={ticketText}
onChange={(e) => setTicketText(e.target.value)}
placeholder="Опишите вопрос"
className="min-h-[110px] w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
<Button size="sm" className="w-full" disabled={sending} onClick={createTicket}>
Отправить тикет
</Button>
</div>
) : null}
<div className="border-t pt-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Мои тикеты
</p>
<div className="max-h-[260px] space-y-2 overflow-auto pr-1">
{loading ? <p className="text-xs text-muted-foreground">Загрузка...</p> : null}
{tickets.map((ticket) => (
<button
key={ticket.id}
onClick={() => setSelectedTicketId(ticket.id)}
className="w-full rounded-xl border px-3 py-2 text-left transition hover:bg-muted/60"
>
<p className="line-clamp-1 text-sm font-medium">{ticket.title}</p>
<p className="mt-1 text-xs text-muted-foreground">Статус: {ticket.status}</p>
</button>
))}
{!loading && tickets.length === 0 ? (
<p className="text-xs text-muted-foreground">Пока нет тикетов</p>
) : null}
</div>
</div>
</div>
) : (
<div className="flex h-[500px] flex-col">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSelectedTicketId(null)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{selectedTicket?.title}</p>
<p className="text-xs text-muted-foreground">Статус: {selectedTicket?.status}</p>
</div>
</div>
<div className="flex-1 space-y-2 overflow-auto p-3">
{messages.map((msg) => (
<div
key={msg.id}
className={cn('flex', msg.isStaff ? 'justify-start' : 'justify-end')}
>
<div
className={cn(
'max-w-[80%] rounded-2xl px-3 py-2 text-sm',
msg.isStaff ? 'bg-muted text-foreground' : 'bg-primary text-primary-foreground'
)}
>
<p>{msg.content}</p>
</div>
</div>
))}
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
<MessageCircle className="mr-2 h-4 w-4" />
Сообщений пока нет
</div>
) : null}
<div ref={messagesEndRef} />
</div>
<div className="border-t p-3">
<div className="flex gap-2">
<input
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Ваш ответ"
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
/>
<Button size="icon" className="h-9 w-9" onClick={sendMessage} disabled={sending}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>
) : (
<Button
size="icon"
className="h-12 w-12 rounded-full shadow-lg"
onClick={() => setOpen(true)}
aria-label="Открыть поддержку"
>
<LifeBuoy className="h-5 w-5" />
</Button>
)}
</div>
);
}

View File

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

View File

@ -9,10 +9,12 @@ import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
const navigation = [ const navigation = [
{ name: 'Возможности', href: '#features' }, { name: 'Курсы', href: '/courses' },
{ name: 'Как это работает', href: '#how-it-works' }, { name: 'Сотрудничество', href: '/cooperation' },
{ name: 'Тарифы', href: '#pricing' }, { name: 'Возможности', href: '/#features' },
{ name: 'FAQ', href: '#faq' }, { name: 'Как это работает', href: '/#how-it-works' },
{ name: 'Тарифы', href: '/#pricing' },
{ name: 'FAQ', href: '/#faq' },
]; ];
export function Header() { export function Header() {

View File

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

View File

@ -12,6 +12,7 @@ interface BackendUser {
name: string | null; name: string | null;
avatarUrl: string | null; avatarUrl: string | null;
subscriptionTier: string; subscriptionTier: string;
role?: string;
} }
interface AuthContextType { interface AuthContextType {

View File

@ -183,6 +183,13 @@ class ApiClient {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`); return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`);
} }
async generateLessonHomework(courseId: string, lessonId: string, type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB') {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/homework/generate`, {
method: 'POST',
body: JSON.stringify({ type }),
});
}
// Generation // Generation
async startGeneration(prompt: string) { async startGeneration(prompt: string) {
return this.request<{ id: string; status: string; progress: number }>('/generation/start', { return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
@ -254,6 +261,10 @@ class ApiClient {
return this.request<any>(`/catalog/${id}/submit`, { method: 'POST' }); return this.request<any>(`/catalog/${id}/submit`, { method: 'POST' });
} }
async checkoutCourse(id: string) {
return this.request<{ url: string }>(`/catalog/${id}/checkout`, { method: 'POST' });
}
async toggleCourseVerification(id: string) { async toggleCourseVerification(id: string) {
return this.request<any>(`/catalog/${id}/verify`, { method: 'PATCH' }); return this.request<any>(`/catalog/${id}/verify`, { method: 'PATCH' });
} }
@ -275,10 +286,26 @@ class ApiClient {
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/complete`, { method: 'POST' }); return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/complete`, { method: 'POST' });
} }
async submitQuizScore(courseId: string, lessonId: string, score: number) { async submitQuizAnswers(courseId: string, lessonId: string, answers: number[]) {
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/quiz`, { return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/quiz`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ score }), body: JSON.stringify({ answers }),
});
}
async getLessonHomework(courseId: string, lessonId: string) {
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`);
}
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`, {
method: 'POST',
body: JSON.stringify(payload),
}); });
} }
@ -305,6 +332,223 @@ class ApiClient {
); );
} }
async reviewHomeworkSubmission(
courseId: string,
submissionId: string,
data: { teacherScore: number; teacherFeedback?: string }
) {
return this.request<any>(`/courses/${courseId}/homework-submissions/${submissionId}/review`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async getHomeworkSubmissions(courseId: string) {
return this.request<any[]>(`/courses/${courseId}/homework-submissions`);
}
// Groups
async getDefaultCourseGroup(courseId: string) {
return this.request<any>(`/groups/course/${courseId}/default`);
}
async getGroupMessages(groupId: string, lessonId?: string) {
const query = lessonId ? `?lessonId=${encodeURIComponent(lessonId)}` : '';
return this.request<any[]>(`/groups/${groupId}/messages${query}`);
}
async getGroupMembers(groupId: string) {
return this.request<any[]>(`/groups/${groupId}/members`);
}
async sendGroupMessage(groupId: string, content: string, lessonId?: string) {
return this.request<any>(`/groups/${groupId}/messages`, {
method: 'POST',
body: JSON.stringify({ content, lessonId }),
});
}
async createGroupInviteLink(groupId: string) {
return this.request<{ inviteUrl: string }>(`/groups/${groupId}/invite-link`, {
method: 'POST',
});
}
async joinGroupByInvite(groupId: string) {
return this.request<any>(`/groups/join/${groupId}`, { method: 'POST' });
}
// Support
async createSupportTicket(data: { title: string; initialMessage?: string; priority?: string }) {
return this.request<any>('/support/tickets', {
method: 'POST',
body: JSON.stringify(data),
});
}
async getMySupportTickets() {
return this.request<any[]>('/support/tickets');
}
async getSupportTicketMessages(id: string) {
return this.request<any[]>(`/support/tickets/${id}/messages`);
}
async sendSupportMessage(id: string, content: string) {
return this.request<any>(`/support/tickets/${id}/messages`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
async getAdminSupportTickets() {
return this.request<any[]>('/support/admin/tickets');
}
async getAdminSupportTicketMessages(id: string) {
return this.request<any[]>(`/support/admin/tickets/${id}/messages`);
}
async sendAdminSupportMessage(id: string, content: string) {
return this.request<any>(`/support/admin/tickets/${id}/messages`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
async updateAdminSupportTicketStatus(id: string, status: string) {
return this.request<any>(`/support/admin/tickets/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
});
}
// Moderation reviews
async hideReview(reviewId: string) {
return this.request<any>(`/moderation/reviews/${reviewId}/hide`, { method: 'POST' });
}
async unhideReview(reviewId: string) {
return this.request<any>(`/moderation/reviews/${reviewId}/unhide`, { method: 'POST' });
}
async getModerationCourses(params?: { status?: string; search?: string }) {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.search) searchParams.set('search', params.search);
const query = searchParams.toString();
return this.request<any[]>(`/moderation/courses${query ? `?${query}` : ''}`);
}
async getPendingModerationCourses() {
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) {
return this.request<any>(`/moderation/${courseId}/approve`, {
method: 'POST',
body: JSON.stringify({ note }),
});
}
async rejectModerationCourse(courseId: string, reason: string) {
return this.request<any>(`/moderation/${courseId}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
}
async deleteModerationCourse(courseId: string) {
return this.request<void>(`/moderation/${courseId}`, {
method: 'DELETE',
});
}
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 });

21
apps/web/src/lib/ws.ts Normal file
View File

@ -0,0 +1,21 @@
export function getWsBaseUrl(): string {
if (process.env.NEXT_PUBLIC_API_ORIGIN) {
return process.env.NEXT_PUBLIC_API_ORIGIN;
}
if (typeof window === 'undefined') {
return 'http://localhost:3125';
}
const { protocol, hostname, port } = window.location;
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `${protocol}//${hostname}:3125`;
}
// In production-like environments backend can be served behind same host
// or exposed on a dedicated API origin via NEXT_PUBLIC_API_ORIGIN.
if (port === '3080') {
return `${protocol}//${hostname}:3125`;
}
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}

View File

@ -62,8 +62,8 @@ export async function middleware(request: NextRequest) {
data: { session }, data: { session },
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
// Protect dashboard routes // Protect dashboard and admin routes
if (request.nextUrl.pathname.startsWith('/dashboard')) { if (request.nextUrl.pathname.startsWith('/dashboard') || request.nextUrl.pathname.startsWith('/admin')) {
if (!session) { if (!session) {
return NextResponse.redirect(new URL('/login', request.url)); return NextResponse.redirect(new URL('/login', request.url));
} }
@ -80,5 +80,5 @@ export async function middleware(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ['/dashboard/:path*', '/login', '/register'], matcher: ['/dashboard/:path*', '/admin/:path*', '/login', '/register'],
}; };

View File

@ -44,9 +44,12 @@ model User {
generations CourseGeneration[] generations CourseGeneration[]
groupMembers GroupMember[] groupMembers GroupMember[]
groupMessages GroupMessage[] groupMessages GroupMessage[]
uploadedSourceFiles CourseSourceFile[]
homeworkSubmissions HomeworkSubmission[] homeworkSubmissions HomeworkSubmission[]
supportTickets SupportTicket[] supportTickets SupportTicket[]
ticketMessages TicketMessage[] ticketMessages TicketMessage[]
statusChanges CourseStatusHistory[] @relation("StatusChangedBy")
cooperationRequests CooperationRequest[]
@@map("users") @@map("users")
} }
@ -129,7 +132,9 @@ model Subscription {
enum CourseStatus { enum CourseStatus {
DRAFT DRAFT
GENERATING GENERATING
PENDING_MODERATION
PENDING_REVIEW PENDING_REVIEW
APPROVED
PUBLISHED PUBLISHED
REJECTED REJECTED
ARCHIVED ARCHIVED
@ -189,6 +194,8 @@ model Course {
reviews Review[] reviews Review[]
generation CourseGeneration? generation CourseGeneration?
groups CourseGroup[] groups CourseGroup[]
statusHistory CourseStatusHistory[]
sourceFiles CourseSourceFile[]
// Vector embedding for semantic search // Vector embedding for semantic search
embedding Unsupported("vector(1536)")? embedding Unsupported("vector(1536)")?
@ -242,6 +249,7 @@ model Lesson {
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade) chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
homework Homework[] homework Homework[]
quiz Quiz? quiz Quiz?
groupMessages GroupMessage[]
// Vector embedding for semantic search // Vector embedding for semantic search
embedding Unsupported("vector(1536)")? embedding Unsupported("vector(1536)")?
@ -348,6 +356,16 @@ model Category {
@@map("categories") @@map("categories")
} }
enum PaymentMode {
DEV
PROD
}
enum PaymentProvider {
STRIPE
YOOMONEY
}
model Purchase { model Purchase {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
@ -357,6 +375,10 @@ model Purchase {
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
currency String @default("USD") currency String @default("USD")
stripePaymentId String? @map("stripe_payment_id") stripePaymentId String? @map("stripe_payment_id")
provider PaymentProvider @default(STRIPE)
mode PaymentMode @default(PROD)
eventCode String? @map("event_code")
metadata Json?
// Status // Status
status String @default("completed") // pending, completed, refunded status String @default("completed") // pending, completed, refunded
@ -440,6 +462,10 @@ model LessonProgress {
completedAt DateTime? @map("completed_at") completedAt DateTime? @map("completed_at")
quizScore Int? @map("quiz_score") // 0-100 quizScore Int? @map("quiz_score") // 0-100
quizPassed Boolean @default(false) @map("quiz_passed")
quizPassedAt DateTime? @map("quiz_passed_at")
homeworkSubmitted Boolean @default(false) @map("homework_submitted")
homeworkSubmittedAt DateTime? @map("homework_submitted_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -461,6 +487,7 @@ model CourseGroup {
courseId String @map("course_id") courseId String @map("course_id")
name String name String
description String? @db.Text description String? @db.Text
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -492,26 +519,86 @@ 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
// ============================================ // ============================================
model Homework { model Homework {
id String @id @default(uuid()) id String @id @default(uuid())
lessonId String @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")
@ -524,19 +611,39 @@ model Homework {
@@map("homework") @@map("homework")
} }
enum HomeworkReviewStatus {
SUBMITTED
AI_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") // 0-100 aiScore Int? @map("ai_score") // 1-5
aiFeedback String? @db.Text @map("ai_feedback") aiFeedback String? @db.Text @map("ai_feedback")
// Teacher grading // Teacher grading
teacherScore Int? @map("teacher_score") // 0-100 teacherScore Int? @map("teacher_score") // 1-5
teacherFeedback String? @db.Text @map("teacher_feedback") teacherFeedback String? @db.Text @map("teacher_feedback")
reviewStatus HomeworkReviewStatus @default(SUBMITTED) @map("review_status")
submittedAt DateTime @default(now()) @map("submitted_at") submittedAt DateTime @default(now()) @map("submitted_at")
gradedAt DateTime? @map("graded_at") gradedAt DateTime? @map("graded_at")
@ -585,3 +692,24 @@ model TicketMessage {
@@index([ticketId]) @@index([ticketId])
@@map("ticket_messages") @@map("ticket_messages")
} }
model CooperationRequest {
id String @id @default(uuid())
organization String
contactName String @map("contact_name")
email String
phone String?
role String?
organizationType String? @map("organization_type")
message String @db.Text
status String @default("new")
source String? @default("landing")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@map("cooperation_requests")
}

View File

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

297
pnpm-lock.yaml generated
View File

@ -74,7 +74,7 @@ importers:
version: link:../../packages/shared version: link:../../packages/shared
'@nestjs/bullmq': '@nestjs/bullmq':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.67.3) version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.67.3)
'@nestjs/common': '@nestjs/common':
specifier: ^10.3.0 specifier: ^10.3.0
version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -83,7 +83,7 @@ importers:
version: 3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) version: 3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.3.0 specifier: ^10.3.0
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt': '@nestjs/jwt':
specifier: ^10.2.0 specifier: ^10.2.0
version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)) version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))
@ -93,9 +93,15 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.3.0 specifier: ^10.3.0
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/platform-socket.io':
specifier: ^10.3.0
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)
'@nestjs/swagger': '@nestjs/swagger':
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
'@nestjs/websockets':
specifier: ^10.3.0
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@supabase/supabase-js': '@supabase/supabase-js':
specifier: ^2.39.0 specifier: ^2.39.0
version: 2.95.0 version: 2.95.0
@ -117,18 +123,27 @@ 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
rxjs: rxjs:
specifier: ^7.8.1 specifier: ^7.8.1
version: 7.8.2 version: 7.8.2
socket.io:
specifier: ^4.8.1
version: 4.8.3
stripe: stripe:
specifier: ^14.14.0 specifier: ^14.14.0
version: 14.25.0 version: 14.25.0
@ -141,7 +156,7 @@ importers:
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
'@nestjs/testing': '@nestjs/testing':
specifier: ^10.3.0 specifier: ^10.3.0
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22))
'@types/express': '@types/express':
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.25 version: 4.17.25
@ -151,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))
@ -295,6 +316,9 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.50.0 specifier: ^7.50.0
version: 7.71.1(react@18.3.1) version: 7.71.1(react@18.3.1)
socket.io-client:
specifier: ^4.8.1
version: 4.8.3
tailwind-merge: tailwind-merge:
specifier: ^2.2.1 specifier: ^2.2.1
version: 2.6.1 version: 2.6.1
@ -893,6 +917,13 @@ packages:
'@nestjs/common': ^10.0.0 '@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0 '@nestjs/core': ^10.0.0
'@nestjs/platform-socket.io@10.4.22':
resolution: {integrity: sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==}
peerDependencies:
'@nestjs/common': ^10.0.0
'@nestjs/websockets': ^10.0.0
rxjs: ^7.1.0
'@nestjs/schematics@10.2.3': '@nestjs/schematics@10.2.3':
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
peerDependencies: peerDependencies:
@ -928,6 +959,18 @@ packages:
'@nestjs/platform-express': '@nestjs/platform-express':
optional: true optional: true
'@nestjs/websockets@10.4.22':
resolution: {integrity: sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==}
peerDependencies:
'@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0
'@nestjs/platform-socket.io': ^10.0.0
reflect-metadata: ^0.1.12 || ^0.2.0
rxjs: ^7.1.0
peerDependenciesMeta:
'@nestjs/platform-socket.io':
optional: true
'@next/env@14.1.0': '@next/env@14.1.0':
resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==} resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==}
@ -1597,6 +1640,9 @@ packages:
'@sinonjs/fake-timers@10.3.0': '@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@supabase/auth-js@2.95.0': '@supabase/auth-js@2.95.0':
resolution: {integrity: sha512-n8EyMn8JL6aaUA8krQcFUGEd+/Qwlu/49dvSi68nHsAMMENGKixE67Pf43mo9iR42FH30Hcm9jACGflQi43e3w==} resolution: {integrity: sha512-n8EyMn8JL6aaUA8krQcFUGEd+/Qwlu/49dvSi68nHsAMMENGKixE67Pf43mo9iR42FH30Hcm9jACGflQi43e3w==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@ -1827,6 +1873,9 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/d3-array@3.2.2': '@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@ -1992,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==}
@ -2001,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==}
@ -2508,6 +2563,10 @@ packages:
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
base64id@2.0.0:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
baseline-browser-mapping@2.9.19: baseline-browser-mapping@2.9.19:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true hasBin: true
@ -2996,6 +3055,15 @@ packages:
supports-color: supports-color:
optional: true optional: true
debug@4.3.7:
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -3135,6 +3203,17 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
engine.io-client@6.6.4:
resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==}
engine.io-parser@5.2.3:
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
engines: {node: '>=10.0.0'}
engine.io@6.6.5:
resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==}
engines: {node: '>=10.2.0'}
enhanced-resolve@5.19.0: enhanced-resolve@5.19.0:
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -4440,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}
@ -4459,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'}
@ -4632,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==}
@ -5120,6 +5210,25 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
socket.io-adapter@2.5.6:
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
socket.io-client@4.8.3:
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
engines: {node: '>=10.0.0'}
socket.io-parser@4.2.5:
resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==}
engines: {node: '>=10.0.0'}
socket.io@4.8.1:
resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==}
engines: {node: '>=10.2.0'}
socket.io@4.8.3:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -5757,6 +5866,18 @@ packages:
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.19.0: ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -5769,6 +5890,10 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
xmlhttprequest-ssl@2.1.2:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@ -6401,17 +6526,17 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
'@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))': '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies: dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1 tslib: 2.8.1
'@nestjs/bullmq@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.67.3)': '@nestjs/bullmq@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.67.3)':
dependencies: dependencies:
'@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
bullmq: 5.67.3 bullmq: 5.67.3
tslib: 2.8.1 tslib: 2.8.1
@ -6463,7 +6588,7 @@ snapshots:
lodash: 4.17.21 lodash: 4.17.21
rxjs: 7.8.2 rxjs: 7.8.2
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': '@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies: dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxtjs/opencollective': 0.3.2 '@nuxtjs/opencollective': 0.3.2
@ -6476,6 +6601,7 @@ snapshots:
uid: 2.0.2 uid: 2.0.2
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/websockets': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@ -6501,7 +6627,7 @@ snapshots:
'@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
dependencies: dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
body-parser: 1.20.4 body-parser: 1.20.4
cors: 2.8.5 cors: 2.8.5
express: 4.22.1 express: 4.22.1
@ -6510,6 +6636,18 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/platform-socket.io@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/websockets': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
rxjs: 7.8.2
socket.io: 4.8.1
tslib: 2.8.1
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)':
dependencies: dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0) '@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@ -6532,11 +6670,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@nestjs/swagger@7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': '@nestjs/swagger@7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)':
dependencies: dependencies:
'@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc': 0.15.1
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
js-yaml: 4.1.0 js-yaml: 4.1.0
lodash: 4.17.21 lodash: 4.17.21
@ -6547,14 +6685,26 @@ snapshots:
class-transformer: 0.5.1 class-transformer: 0.5.1
class-validator: 0.14.3 class-validator: 0.14.3
'@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22))': '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22))':
dependencies: dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/websockets@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
iterare: 1.2.1
object-hash: 3.0.0
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
optionalDependencies:
'@nestjs/platform-socket.io': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)
'@next/env@14.1.0': {} '@next/env@14.1.0': {}
'@next/eslint-plugin-next@14.1.0': '@next/eslint-plugin-next@14.1.0':
@ -7217,6 +7367,8 @@ snapshots:
dependencies: dependencies:
'@sinonjs/commons': 3.0.1 '@sinonjs/commons': 3.0.1
'@socket.io/component-emitter@3.1.2': {}
'@supabase/auth-js@2.95.0': '@supabase/auth-js@2.95.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -7496,6 +7648,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.32 '@types/node': 20.19.32
'@types/cors@2.8.19':
dependencies:
'@types/node': 20.19.32
'@types/d3-array@3.2.2': {} '@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6': '@types/d3-axis@3.0.6':
@ -7701,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
@ -7715,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': {}
@ -8291,6 +8455,8 @@ snapshots:
base64-js@1.5.1: {} base64-js@1.5.1: {}
base64id@2.0.0: {}
baseline-browser-mapping@2.9.19: {} baseline-browser-mapping@2.9.19: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
@ -8820,6 +8986,10 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
debug@4.3.7:
dependencies:
ms: 2.1.3
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@ -8923,6 +9093,36 @@ snapshots:
encodeurl@2.0.0: {} encodeurl@2.0.0: {}
engine.io-client@6.6.4:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.18.3
xmlhttprequest-ssl: 2.1.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
engine.io-parser@5.2.3: {}
engine.io@6.6.5:
dependencies:
'@types/cors': 2.8.19
'@types/node': 20.19.32
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.5
debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
enhanced-resolve@5.19.0: enhanced-resolve@5.19.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -10671,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
@ -10684,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:
@ -10865,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: {}
@ -11410,6 +11618,61 @@ snapshots:
slash@3.0.0: {} slash@3.0.0: {}
socket.io-adapter@2.5.6:
dependencies:
debug: 4.4.3
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
engine.io-client: 6.6.4
socket.io-parser: 4.2.5
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
socket.io-parser@4.2.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
socket.io@4.8.1:
dependencies:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.5
debug: 4.3.7
engine.io: 6.6.5
socket.io-adapter: 2.5.6
socket.io-parser: 4.2.5
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
socket.io@4.8.3:
dependencies:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.5
debug: 4.4.3
engine.io: 6.6.5
socket.io-adapter: 2.5.6
socket.io-parser: 4.2.5
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-support@0.5.13: source-map-support@0.5.13:
@ -12113,8 +12376,12 @@ snapshots:
imurmurhash: 0.1.4 imurmurhash: 0.1.4
signal-exit: 3.0.7 signal-exit: 3.0.7
ws@8.18.3: {}
ws@8.19.0: {} ws@8.19.0: {}
xmlhttprequest-ssl@2.1.2: {}
xtend@4.0.2: {} xtend@4.0.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}