your message
This commit is contained in:
165
IMPLEMENTATION.md
Normal file
165
IMPLEMENTATION.md
Normal 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). При не указанном объёме — средний/длинный (5–7 глав, 4–6 уроков в главе, не менее 25 уроков).
|
||||||
|
- **generateLessonContent** — контент урока в формате TipTap JSON (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, mermaid). Промпт требует полный, подробный материал (1000–1500+ слов), примеры и пояснения, минимум 1–2 примера/кода на урок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
@ -36,6 +38,7 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.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": {
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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,25 @@ export class CatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async submitForReview(courseId: string, userId: string): Promise<any> {
|
async submitForReview(courseId: string, userId: string): Promise<any> {
|
||||||
|
const course = await this.prisma.course.findUnique({ where: { id: courseId } });
|
||||||
|
if (!course) {
|
||||||
|
throw new NotFoundException('Course not found');
|
||||||
|
}
|
||||||
|
if (course.authorId !== userId) {
|
||||||
|
throw new ForbiddenException('Only course author can submit for moderation');
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.course.update({
|
return this.prisma.course.update({
|
||||||
where: { id: courseId, authorId: userId },
|
where: { id: courseId },
|
||||||
data: { status: CourseStatus.PENDING_REVIEW },
|
data: {
|
||||||
|
status: CourseStatus.PENDING_REVIEW,
|
||||||
|
isPublished: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishCourse(courseId: string, userId: string): Promise<any> {
|
async createCourseCheckout(courseId: string, userId: string): Promise<any> {
|
||||||
return this.prisma.course.update({
|
return this.paymentsService.createCourseCheckoutSession(userId, courseId);
|
||||||
where: { id: courseId, authorId: userId },
|
|
||||||
data: {
|
|
||||||
status: CourseStatus.PUBLISHED,
|
|
||||||
isPublished: true,
|
|
||||||
publishedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleVerification(courseId: string, userId: string): Promise<any> {
|
async toggleVerification(courseId: string, userId: string): Promise<any> {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
@ -19,6 +19,13 @@ export class CoursesService {
|
|||||||
description: dto.description,
|
description: dto.description,
|
||||||
slug,
|
slug,
|
||||||
status: CourseStatus.DRAFT,
|
status: CourseStatus.DRAFT,
|
||||||
|
groups: {
|
||||||
|
create: {
|
||||||
|
name: 'Основная группа',
|
||||||
|
description: 'Обсуждение курса и вопросы преподавателю',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
chapters: {
|
chapters: {
|
||||||
@ -117,6 +124,7 @@ export class CoursesService {
|
|||||||
orderBy: { order: 'asc' },
|
orderBy: { order: 'asc' },
|
||||||
},
|
},
|
||||||
category: true,
|
category: true,
|
||||||
|
groups: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -167,6 +175,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,12 +222,12 @@ 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) {
|
||||||
|
throw new ForbiddenException('Course can be published only by moderation');
|
||||||
if (status === CourseStatus.PUBLISHED && !course.publishedAt) {
|
|
||||||
updateData.publishedAt = new Date();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status };
|
||||||
|
|
||||||
return this.prisma.course.update({
|
return this.prisma.course.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
@ -228,4 +242,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' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
apps/api/src/courses/dto/review-homework.dto.ts
Normal file
15
apps/api/src/courses/dto/review-homework.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal file
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
14
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal file
14
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SubmitHomeworkDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Written homework answer',
|
||||||
|
minLength: 50,
|
||||||
|
maxLength: 20000,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(50)
|
||||||
|
@MaxLength(20000)
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal file
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
@ -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.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
|
|||||||
@ -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 } 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,219 @@ 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, content: 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 aiResult = this.gradeHomeworkWithAI(content);
|
||||||
|
|
||||||
|
const submission = await this.prisma.homeworkSubmission.upsert({
|
||||||
|
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
|
||||||
|
create: {
|
||||||
|
homeworkId: homework.id,
|
||||||
|
userId,
|
||||||
|
content,
|
||||||
|
aiScore: aiResult.score,
|
||||||
|
aiFeedback: aiResult.feedback,
|
||||||
|
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
content,
|
||||||
|
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 +338,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 +355,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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,18 @@ 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')
|
||||||
@ -29,4 +39,14 @@ export class GroupsController {
|
|||||||
async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
|
async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
|
||||||
return this.groupsService.sendMessage(groupId, user.id, content);
|
return this.groupsService.sendMessage(groupId, user.id, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
apps/api/src/groups/groups.gateway.ts
Normal file
113
apps/api/src/groups/groups.gateway.ts
Normal 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 }
|
||||||
|
) {
|
||||||
|
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());
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [UsersModule],
|
||||||
controllers: [GroupsController],
|
controllers: [GroupsController],
|
||||||
providers: [GroupsService],
|
providers: [GroupsService, GroupsGateway],
|
||||||
exports: [GroupsService],
|
exports: [GroupsService],
|
||||||
})
|
})
|
||||||
export class GroupsModule {}
|
export class GroupsModule {}
|
||||||
|
|||||||
@ -14,35 +14,171 @@ export class GroupsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 getDefaultGroup(courseId: string, userId: string): Promise<any> {
|
||||||
|
const group = await this.ensureDefaultGroup(courseId);
|
||||||
|
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 (!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): Promise<any> {
|
async getGroupMessages(groupId: string, userId: 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.findMany({
|
return this.prisma.groupMessage.findMany({
|
||||||
where: { groupId },
|
where: { groupId },
|
||||||
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): 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 },
|
||||||
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -24,4 +24,14 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,17 @@ export class ModerationService {
|
|||||||
throw new ForbiddenException('Moderators only');
|
throw new ForbiddenException('Moderators only');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const course = await this.prisma.course.findUnique({
|
||||||
|
where: { id: courseId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
if (!course) {
|
||||||
|
throw new ForbiddenException('Course not found');
|
||||||
|
}
|
||||||
|
if (course.status !== CourseStatus.PENDING_REVIEW) {
|
||||||
|
throw new ForbiddenException('Only courses pending review can be approved');
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.course.update({
|
return this.prisma.course.update({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
data: {
|
data: {
|
||||||
@ -46,6 +57,17 @@ export class ModerationService {
|
|||||||
throw new ForbiddenException('Moderators only');
|
throw new ForbiddenException('Moderators only');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const course = await this.prisma.course.findUnique({
|
||||||
|
where: { id: courseId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
if (!course) {
|
||||||
|
throw new ForbiddenException('Course not found');
|
||||||
|
}
|
||||||
|
if (course.status !== CourseStatus.PENDING_REVIEW) {
|
||||||
|
throw new ForbiddenException('Only courses pending review can be rejected');
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.course.update({
|
return this.prisma.course.update({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
data: {
|
data: {
|
||||||
@ -55,4 +77,42 @@ export class ModerationService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async hideReview(userId: string, reviewId: string): Promise<any> {
|
||||||
|
await this.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.assertStaff(userId);
|
||||||
|
const review = await this.prisma.review.update({
|
||||||
|
where: { id: reviewId },
|
||||||
|
data: { isApproved: true },
|
||||||
|
});
|
||||||
|
await this.recalculateAverageRating(review.courseId);
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStaff(userId: string): Promise<void> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
||||||
|
throw new ForbiddenException('Moderators only');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recalculateAverageRating(courseId: string): Promise<void> {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,57 @@ 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.price) {
|
||||||
|
throw new Error('Course is free, checkout is not required');
|
||||||
|
}
|
||||||
|
if (existingPurchase?.status === 'completed') {
|
||||||
|
throw new Error('Course is already purchased');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
|
||||||
|
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
|
||||||
|
const 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 };
|
||||||
|
}
|
||||||
|
|
||||||
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 +131,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 +157,26 @@ 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 || '',
|
||||||
|
});
|
||||||
|
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 +201,95 @@ export class PaymentsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleCoursePurchaseCompleted(params: { userId: string; courseId: 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',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: 'completed',
|
||||||
|
amount: course.price,
|
||||||
|
currency: course.currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
apps/api/src/support/support.gateway.ts
Normal file
125
apps/api/src/support/support.gateway.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [UsersModule],
|
||||||
controllers: [SupportController],
|
controllers: [SupportController],
|
||||||
providers: [SupportService],
|
providers: [SupportService, SupportGateway],
|
||||||
exports: [SupportService],
|
exports: [SupportService],
|
||||||
})
|
})
|
||||||
export class SupportModule {}
|
export class SupportModule {}
|
||||||
|
|||||||
@ -1,14 +1,40 @@
|
|||||||
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';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupportService {
|
export class SupportService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
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 +48,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 +58,104 @@ 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> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
if (!user || (user.role !== UserRole.ADMIN && user.role !== UserRole.MODERATOR)) {
|
||||||
|
throw new ForbiddenException('Staff access only');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
147
apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx
Normal file
147
apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { 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 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 socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||||
|
[tickets, selectedTicketId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadTickets = async () => {
|
||||||
|
const data = await api.getAdminSupportTickets().catch(() => []);
|
||||||
|
setTickets(data);
|
||||||
|
if (!selectedTicketId && data.length > 0) {
|
||||||
|
setSelectedTicketId(data[0].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const sendReply = async () => {
|
||||||
|
if (!selectedTicketId || !message.trim()) return;
|
||||||
|
await api.sendAdminSupportMessage(selectedTicketId, message.trim());
|
||||||
|
setMessage('');
|
||||||
|
const list = await api.getAdminSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||||
|
setMessages(list);
|
||||||
|
await loadTickets();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = async () => {
|
||||||
|
if (!selectedTicketId) return;
|
||||||
|
await api.updateAdminSupportTicketStatus(selectedTicketId, status);
|
||||||
|
await loadTickets();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={`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.user?.name || ticket.user?.email} • {ticket.status}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-h-[520px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{selected ? `Тикет: ${selected.title}` : 'Выберите тикет'}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex h-[430px] flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-background px-3 py-2 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}>
|
||||||
|
Обновить статус
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Ответ поддержки"
|
||||||
|
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<Button onClick={sendReply} disabled={!selectedTicketId}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -35,6 +35,10 @@ export default function PublicCoursePage() {
|
|||||||
const [enrolling, setEnrolling] = useState(false);
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
const [enrolled, setEnrolled] = useState(false);
|
const [enrolled, setEnrolled] = useState(false);
|
||||||
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||||
|
const [submittingReview, setSubmittingReview] = useState(false);
|
||||||
|
const [reviewRating, setReviewRating] = useState(5);
|
||||||
|
const [reviewTitle, setReviewTitle] = useState('');
|
||||||
|
const [reviewContent, setReviewContent] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@ -65,9 +69,17 @@ export default function PublicCoursePage() {
|
|||||||
if (!id || enrolling) return;
|
if (!id || enrolling) return;
|
||||||
setEnrolling(true);
|
setEnrolling(true);
|
||||||
try {
|
try {
|
||||||
await api.enrollInCourse(id);
|
if (course?.price) {
|
||||||
toast({ title: 'Успех', description: 'Вы записались на курс' });
|
const session = await api.checkoutCourse(id);
|
||||||
router.push(`/dashboard/courses/${id}`);
|
if (session?.url) {
|
||||||
|
window.location.href = session.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await api.enrollInCourse(id);
|
||||||
|
toast({ title: 'Успех', description: 'Вы записались на курс' });
|
||||||
|
router.push(`/dashboard/courses/${id}`);
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||||
} finally {
|
} finally {
|
||||||
@ -75,6 +87,27 @@ export default function PublicCoursePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitReview = async () => {
|
||||||
|
if (!id || !enrolled || submittingReview) return;
|
||||||
|
setSubmittingReview(true);
|
||||||
|
try {
|
||||||
|
await api.createReview(id, {
|
||||||
|
rating: reviewRating,
|
||||||
|
title: reviewTitle || undefined,
|
||||||
|
content: reviewContent || undefined,
|
||||||
|
});
|
||||||
|
const reviewsData = await api.getCourseReviews(id).catch(() => ({ data: [] }));
|
||||||
|
setReviews(reviewsData.data || []);
|
||||||
|
setReviewTitle('');
|
||||||
|
setReviewContent('');
|
||||||
|
toast({ title: 'Спасибо!', description: 'Ваш отзыв опубликован' });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setSubmittingReview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@ -190,7 +223,7 @@ export default function PublicCoursePage() {
|
|||||||
) : (
|
) : (
|
||||||
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
|
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
|
||||||
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
{course.price ? 'Купить курс' : 'Записаться'}
|
{course.price ? 'Купить курс' : 'Добавить в библиотеку'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
@ -300,6 +333,43 @@ export default function PublicCoursePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{enrolled && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Оцените курс</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<button key={i} onClick={() => setReviewRating(i + 1)} className="p-1">
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5',
|
||||||
|
i < reviewRating ? 'text-yellow-500 fill-current' : 'text-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={reviewTitle}
|
||||||
|
onChange={(e) => setReviewTitle(e.target.value)}
|
||||||
|
placeholder="Заголовок отзыва"
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={reviewContent}
|
||||||
|
onChange={(e) => setReviewContent(e.target.value)}
|
||||||
|
placeholder="Что вам понравилось в курсе?"
|
||||||
|
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSubmitReview} disabled={submittingReview}>
|
||||||
|
{submittingReview ? 'Публикация...' : 'Оставить отзыв'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,17 @@ 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_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
|
||||||
|
moderationNote?: string | null;
|
||||||
|
chapters: Chapter[];
|
||||||
|
};
|
||||||
|
|
||||||
const emptyDoc = { type: 'doc', content: [] };
|
const emptyDoc = { type: 'doc', content: [] };
|
||||||
|
|
||||||
@ -37,6 +47,12 @@ export default function CourseEditPage() {
|
|||||||
const [readOnly, setReadOnly] = useState(false);
|
const [readOnly, setReadOnly] = 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 [courseTitle, setCourseTitle] = useState('');
|
||||||
|
const [courseDescription, setCourseDescription] = useState('');
|
||||||
|
const [courseCover, setCourseCover] = useState('');
|
||||||
|
const [coursePrice, setCoursePrice] = useState('');
|
||||||
|
const [courseCurrency, setCourseCurrency] = useState('USD');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!courseId || authLoading) return;
|
if (!courseId || authLoading) return;
|
||||||
@ -48,6 +64,11 @@ export default function CourseEditPage() {
|
|||||||
const data = await api.getCourse(courseId);
|
const data = await api.getCourse(courseId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCourse(data);
|
setCourse(data);
|
||||||
|
setCourseTitle(data.title || '');
|
||||||
|
setCourseDescription(data.description || '');
|
||||||
|
setCourseCover(data.coverImage || '');
|
||||||
|
setCoursePrice(data.price ? String(data.price) : '');
|
||||||
|
setCourseCurrency(data.currency || 'USD');
|
||||||
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) {
|
||||||
@ -121,7 +142,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' });
|
||||||
@ -130,6 +151,25 @@ export default function CourseEditPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
toast({ title: 'Сохранено', description: 'Настройки курса обновлены' });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setSavingMeta(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleVerify = async () => {
|
const handleToggleVerify = async () => {
|
||||||
if (!courseId || verifying) return;
|
if (!courseId || verifying) return;
|
||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
@ -198,9 +238,13 @@ export default function CourseEditPage() {
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<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">
|
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
|
||||||
<h2 className="font-medium truncate">
|
<div>
|
||||||
{activeLessonMeta?.title ?? 'Выберите урок'}
|
<h2 className="font-medium truncate">{activeLessonMeta?.title ?? 'Выберите урок'}</h2>
|
||||||
</h2>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Статус курса: {course.status}
|
||||||
|
{course.moderationNote ? ` • Заметка: ${course.moderationNote}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<>
|
<>
|
||||||
@ -235,13 +279,57 @@ export default function CourseEditPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="default" onClick={handlePublish} disabled={publishing}>
|
<Button size="sm" variant="default" onClick={handlePublish} disabled={publishing}>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
{publishing ? 'Публикация...' : 'Опубликовать'}
|
{publishing ? 'Отправка...' : 'Отправить на модерацию'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b px-4 py-3 bg-muted/20">
|
||||||
|
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<input
|
||||||
|
value={courseTitle}
|
||||||
|
onChange={(e) => setCourseTitle(e.target.value)}
|
||||||
|
className="rounded-md border bg-background px-3 py-2 text-sm lg:col-span-2"
|
||||||
|
placeholder="Название курса"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={courseCover}
|
||||||
|
onChange={(e) => setCourseCover(e.target.value)}
|
||||||
|
className="rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="URL превью"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={coursePrice}
|
||||||
|
onChange={(e) => setCoursePrice(e.target.value)}
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Цена"
|
||||||
|
/>
|
||||||
|
<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}>
|
||||||
|
{savingMeta ? 'Сохранение...' : 'Сохранить карточку'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={courseDescription}
|
||||||
|
onChange={(e) => setCourseDescription(e.target.value)}
|
||||||
|
className="mt-2 w-full rounded-md border bg-background p-3 text-sm"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Описание курса"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6">
|
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6">
|
||||||
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
|
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
|
||||||
{contentLoading ? (
|
{contentLoading ? (
|
||||||
|
|||||||
@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { Link as LinkIcon, 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 { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
|
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 socketRef = useRef<Socket | 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]);
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!group?.id || !message.trim()) return;
|
||||||
|
await api.sendGroupMessage(group.id, message.trim());
|
||||||
|
setMessage('');
|
||||||
|
const latest = await api.getGroupMessages(group.id).catch(() => []);
|
||||||
|
setMessages(latest);
|
||||||
|
};
|
||||||
|
|
||||||
|
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-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Группа курса</h1>
|
||||||
|
<p className="text-muted-foreground">Чат участников и проверка домашних заданий</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||||
|
<Card className="min-h-[460px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{group?.name || 'Основная группа'}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex h-[400px] 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 || 'Участник'}</p>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Сообщение в чат"
|
||||||
|
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<Button onClick={sendMessage}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Участники ({members.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div key={member.id} className="rounded-md 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="pt-2 border-t space-y-2">
|
||||||
|
<Button variant="outline" onClick={createInvite} className="w-full">
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" />
|
||||||
|
Создать invite-ссылку
|
||||||
|
</Button>
|
||||||
|
{inviteLink ? <p className="text-xs break-all text-muted-foreground">{inviteLink}</p> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthor && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Проверка домашних заданий</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{submissions.map((submission) => (
|
||||||
|
<div key={submission.id} className="rounded-md border p-3 space-y-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{submission.user?.name || submission.user?.email} • {submission.homework?.lesson?.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{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 items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-background px-2 py-1 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="flex-1 rounded-md border bg-background px-3 py-1 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)}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{submissions.length === 0 ? <p className="text-sm text-muted-foreground">Пока нет отправленных ДЗ</p> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,23 +1,21 @@
|
|||||||
'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,
|
||||||
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 +30,162 @@ 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 { 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 } | 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('');
|
||||||
|
|
||||||
// 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),
|
setCourse(courseData);
|
||||||
]);
|
setExpandedChapters((courseData.chapters || []).map((ch: Chapter) => ch.id));
|
||||||
if (!cancelled) {
|
|
||||||
setCourse(courseData);
|
const map = await refreshProgress(id);
|
||||||
setEnrollmentProgress(progressData);
|
|
||||||
if (progressData?.lessons) {
|
const ordered = (courseData.chapters || [])
|
||||||
const completed: Set<string> = new Set(
|
.sort((a: Chapter, b: Chapter) => a.order - b.order)
|
||||||
progressData.lessons
|
.flatMap((chapter: Chapter) =>
|
||||||
.filter((l: any) => l.completedAt)
|
(chapter.lessons || [])
|
||||||
.map((l: any) => String(l.lessonId))
|
.sort((a: Lesson, b: Lesson) => a.order - b.order)
|
||||||
);
|
.map((lesson: Lesson) => lesson.id)
|
||||||
setCompletedLessons(completed);
|
);
|
||||||
}
|
const firstUnlocked = ordered.find((lessonId: string) => {
|
||||||
const first = courseData.chapters?.[0]?.lessons?.[0];
|
if (isAuthor) return true;
|
||||||
if (first) setSelectedLessonId(first.id);
|
const idx = ordered.indexOf(lessonId);
|
||||||
setExpandedChapters(courseData.chapters.map((ch: Chapter) => ch.id));
|
if (idx <= 0) return true;
|
||||||
}
|
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 +193,43 @@ export default function CoursePage() {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLessonContentLoading(true);
|
setLessonContentLoading(true);
|
||||||
|
setShowQuiz(false);
|
||||||
|
setQuizQuestions([]);
|
||||||
|
|
||||||
(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 +242,86 @@ 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, homeworkContent.trim());
|
||||||
markComplete();
|
setHomework((prev) => ({ ...prev, submission }));
|
||||||
} catch {
|
await refreshProgress(id);
|
||||||
markComplete();
|
} finally {
|
||||||
}
|
setHomeworkSubmitting(false);
|
||||||
};
|
|
||||||
|
|
||||||
const handleGetCertificate = () => {
|
|
||||||
if (!id) return;
|
|
||||||
window.open(`/certificate/${id}`, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
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 +333,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 +372,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 +390,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 +398,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,79 +405,67 @@ 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
|
||||||
const isActive = selectedLessonId === lesson.id;
|
.sort((a, b) => a.order - b.order)
|
||||||
const isCompleted = completedLessons.has(lesson.id);
|
.map((lesson) => {
|
||||||
const globalIdx = flatLessons.findIndex((l) => l.id === lesson.id);
|
const isActive = selectedLessonId === lesson.id;
|
||||||
// Lesson is locked if sequential mode: all previous must be complete
|
const isDone = isLessonDone(lesson.id);
|
||||||
// For now, don't lock (allow free navigation)
|
const unlocked = isLessonUnlocked(lesson.id);
|
||||||
const isLocked = false;
|
const isLocked = !unlocked;
|
||||||
|
const row = progressRows[lesson.id];
|
||||||
|
const quizOnly = row?.quizPassed && !row?.homeworkSubmitted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={lesson.id}
|
key={lesson.id}
|
||||||
disabled={isLocked}
|
disabled={isLocked}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2.5 w-full rounded-lg px-3 py-2 text-sm transition-all',
|
'flex items-center gap-2.5 w-full rounded-lg px-3 py-2 text-sm transition-all',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
: isLocked
|
: isLocked
|
||||||
? 'opacity-40 cursor-not-allowed'
|
? 'opacity-40 cursor-not-allowed'
|
||||||
: 'hover:bg-muted/80 text-foreground/80'
|
: 'hover:bg-muted/80 text-foreground/80'
|
||||||
)}
|
)}
|
||||||
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 ? (
|
||||||
<Circle className="h-4 w-4 shrink-0" />
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-yellow-500" />
|
||||||
)}
|
) : (
|
||||||
<span className="truncate text-left">{lesson.title}</span>
|
<BookOpen className="h-4 w-4 shrink-0" />
|
||||||
{lesson.durationMinutes && (
|
)}
|
||||||
<span className={cn('ml-auto text-xs shrink-0', isActive ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
|
<span className="truncate text-left">{lesson.title}</span>
|
||||||
{lesson.durationMinutes} мин
|
</button>
|
||||||
</span>
|
);
|
||||||
)}
|
})}
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -416,41 +476,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,26 +508,26 @@ 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
|
<LessonContentViewer content={lessonContent} className="min-h-[320px]" />
|
||||||
content={lessonContent}
|
|
||||||
className="min-h-[400px]"
|
{!activeProgress?.quizPassed && (
|
||||||
/>
|
|
||||||
{!showQuiz && !completedLessons.has(selectedLessonId) && (
|
|
||||||
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center">
|
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center">
|
||||||
<h3 className="font-semibold mb-2">Проверьте свои знания</h3>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showQuiz && (
|
{showQuiz && (
|
||||||
<LessonQuiz
|
<LessonQuiz
|
||||||
courseId={id}
|
courseId={id}
|
||||||
@ -486,54 +536,79 @@ export default function CoursePage() {
|
|||||||
onComplete={handleQuizComplete}
|
onComplete={handleQuizComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeProgress?.quizPassed && (
|
||||||
|
<div className="mt-8 p-6 border rounded-xl bg-muted/20">
|
||||||
|
<h3 className="font-semibold mb-2">Шаг 2 из 2: письменное домашнее задание</h3>
|
||||||
|
{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">Минимум 50 символов</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<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 ?? '') ? 'Пройден' : 'Отметить пройденным'}
|
<ChevronRight className="ml-1.5 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{currentLessonIndex < flatLessons.length - 1 ? (
|
|
||||||
<Button size="sm" onClick={goToNextLesson}>
|
|
||||||
Следующий урок
|
|
||||||
<ChevronRight className="ml-1.5 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{courseCompleted ? 'Курс пройден!' : 'Последний урок'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
100
apps/web/src/app/(dashboard)/dashboard/groups/[groupId]/page.tsx
Normal file
100
apps/web/src/app/(dashboard)/dashboard/groups/[groupId]/page.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { 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 InviteGroupPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const groupId = params?.groupId as string;
|
||||||
|
const [messages, setMessages] = useState<any[]>([]);
|
||||||
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const socketRef = useRef<Socket | 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]);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!groupId || !message.trim()) return;
|
||||||
|
await api.sendGroupMessage(groupId, message.trim());
|
||||||
|
setMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||||
|
<Card className="min-h-[460px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Групповой чат курса</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex h-[400px] 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 || 'Участник'}</p>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Сообщение"
|
||||||
|
/>
|
||||||
|
<Button onClick={send}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Участники ({members.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div key={member.id} className="rounded-md 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/web/src/app/(dashboard)/dashboard/support/page.tsx
Normal file
154
apps/web/src/app/(dashboard)/dashboard/support/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -33,7 +33,7 @@ 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_REVIEW' | 'PUBLISHED' | 'REJECTED' | 'ARCHIVED';
|
||||||
chaptersCount: number;
|
chaptersCount: number;
|
||||||
lessonsCount: number;
|
lessonsCount: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@ -55,6 +55,14 @@ 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',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
|||||||
@ -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
|
||||||
'mx-auto h-16 w-16 rounded-full flex items-center justify-center mb-4',
|
className={cn(
|
||||||
passed ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
'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 ? (
|
{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
|
||||||
'flex items-center gap-2 p-2 rounded text-sm',
|
key={q.id}
|
||||||
isCorrect ? 'bg-green-50 dark:bg-green-900/30' : 'bg-red-50 dark:bg-red-900/30'
|
className={cn(
|
||||||
)}>
|
'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 ? (
|
{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>
|
||||||
|
|||||||
@ -11,15 +11,18 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Plus,
|
Plus,
|
||||||
|
LifeBuoy,
|
||||||
} 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', icon: BookOpen, exact: true },
|
||||||
{ name: 'Каталог', href: '/dashboard/catalog', icon: Compass },
|
{ name: 'Курсы', href: '/dashboard/catalog', icon: Compass },
|
||||||
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
|
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
|
||||||
|
{ name: 'Поддержка', href: '/dashboard/support', icon: LifeBuoy },
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomNavigation = [
|
const bottomNavigation = [
|
||||||
@ -29,6 +32,12 @@ const bottomNavigation = [
|
|||||||
|
|
||||||
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: '/dashboard/admin/support', icon: LifeBuoy }, ...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 +87,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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -254,6 +254,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 +279,21 @@ 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, content: string) {
|
||||||
|
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,6 +320,105 @@ 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) {
|
||||||
|
return this.request<any[]>(`/groups/${groupId}/messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembers(groupId: string) {
|
||||||
|
return this.request<any[]>(`/groups/${groupId}/members`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendGroupMessage(groupId: string, content: string) {
|
||||||
|
return this.request<any>(`/groups/${groupId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
// 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
21
apps/web/src/lib/ws.ts
Normal 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}` : ''}`;
|
||||||
|
}
|
||||||
@ -440,6 +440,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 +465,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")
|
||||||
@ -509,7 +514,7 @@ model GroupMessage {
|
|||||||
|
|
||||||
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
|
||||||
dueDate DateTime? @map("due_date")
|
dueDate DateTime? @map("due_date")
|
||||||
@ -524,6 +529,12 @@ model Homework {
|
|||||||
@@map("homework")
|
@@map("homework")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum HomeworkReviewStatus {
|
||||||
|
SUBMITTED
|
||||||
|
AI_REVIEWED
|
||||||
|
TEACHER_REVIEWED
|
||||||
|
}
|
||||||
|
|
||||||
model HomeworkSubmission {
|
model HomeworkSubmission {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
homeworkId String @map("homework_id")
|
homeworkId String @map("homework_id")
|
||||||
@ -531,12 +542,13 @@ model HomeworkSubmission {
|
|||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
|
||||||
// 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")
|
||||||
|
|||||||
252
pnpm-lock.yaml
generated
252
pnpm-lock.yaml
generated
@ -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
|
||||||
@ -129,6 +135,9 @@ importers:
|
|||||||
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 +150,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
|
||||||
@ -295,6 +304,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 +905,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 +947,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 +1628,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 +1861,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==}
|
||||||
|
|
||||||
@ -2508,6 +2545,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 +3037,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 +3185,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'}
|
||||||
@ -5120,6 +5181,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 +5837,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 +5861,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 +6497,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 +6559,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 +6572,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 +6598,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 +6607,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 +6641,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 +6656,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 +7338,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 +7619,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':
|
||||||
@ -8291,6 +8418,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 +8949,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 +9056,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
|
||||||
@ -11410,6 +11573,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 +12331,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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user