diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..52d8f7c --- /dev/null +++ b/IMPLEMENTATION.md @@ -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 `. + +### 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. diff --git a/apps/ai-service/src/pipeline/course-generation.pipeline.ts b/apps/ai-service/src/pipeline/course-generation.pipeline.ts index eeff37c..63aef5e 100644 --- a/apps/ai-service/src/pipeline/course-generation.pipeline.ts +++ b/apps/ai-service/src/pipeline/course-generation.pipeline.ts @@ -235,6 +235,13 @@ export class CourseGenerationPipeline { difficulty: outline.difficulty, estimatedHours: outline.estimatedTotalHours, tags: outline.tags, + groups: { + create: { + name: 'Основная группа', + description: 'Обсуждение курса и вопросы преподавателю', + isDefault: true, + }, + }, chapters: { create: outline.chapters.map((chapter, chapterIndex) => ({ title: chapter.title, diff --git a/apps/api/package.json b/apps/api/package.json index 8755cd5..a528e57 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,7 +24,9 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", + "@nestjs/platform-socket.io": "^10.3.0", "@nestjs/swagger": "^7.3.0", + "@nestjs/websockets": "^10.3.0", "@supabase/supabase-js": "^2.39.0", "bullmq": "^5.1.0", "class-transformer": "^0.5.1", @@ -36,6 +38,7 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "stripe": "^14.14.0" }, "devDependencies": { diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 00721d6..c602ff2 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -43,6 +43,6 @@ import { UsersModule } from '../users/users.module'; useClass: JwtAuthGuard, }, ], - exports: [AuthService, SupabaseService, JwtAuthGuard], + exports: [AuthService, SupabaseService, JwtAuthGuard, JwtModule], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index f7fb9f3..1485cf5 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -55,6 +55,7 @@ export class AuthService { name: user.name, avatarUrl: user.avatarUrl, subscriptionTier: user.subscriptionTier, + role: user.role, }, }; } diff --git a/apps/api/src/catalog/catalog.controller.ts b/apps/api/src/catalog/catalog.controller.ts index 33b0774..df99da4 100644 --- a/apps/api/src/catalog/catalog.controller.ts +++ b/apps/api/src/catalog/catalog.controller.ts @@ -32,9 +32,9 @@ export class CatalogController { @Post(':id/submit') @ApiBearerAuth() @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 { - return this.catalogService.publishCourse(id, user.id); + return this.catalogService.submitForReview(id, user.id); } @Patch(':id/verify') @@ -43,4 +43,12 @@ export class CatalogController { async toggleVerify(@Param('id') id: string, @CurrentUser() user: User): Promise { 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 { + return this.catalogService.createCourseCheckout(id, user.id); + } } diff --git a/apps/api/src/catalog/catalog.module.ts b/apps/api/src/catalog/catalog.module.ts index 161cdd6..e302506 100644 --- a/apps/api/src/catalog/catalog.module.ts +++ b/apps/api/src/catalog/catalog.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { CatalogController } from './catalog.controller'; import { CatalogService } from './catalog.service'; +import { PaymentsModule } from '../payments/payments.module'; @Module({ + imports: [PaymentsModule], controllers: [CatalogController], providers: [CatalogService], exports: [CatalogService], diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts index 5953d3f..953b3dd 100644 --- a/apps/api/src/catalog/catalog.service.ts +++ b/apps/api/src/catalog/catalog.service.ts @@ -1,10 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.service'; import { CourseStatus } from '@coursecraft/database'; +import { PaymentsService } from '../payments/payments.service'; @Injectable() export class CatalogService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private paymentsService: PaymentsService + ) {} async getPublishedCourses(options?: { page?: number; @@ -92,21 +96,25 @@ export class CatalogService { } async submitForReview(courseId: string, userId: string): Promise { + 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({ - where: { id: courseId, authorId: userId }, - data: { status: CourseStatus.PENDING_REVIEW }, + where: { id: courseId }, + data: { + status: CourseStatus.PENDING_REVIEW, + isPublished: false, + }, }); } - async publishCourse(courseId: string, userId: string): Promise { - return this.prisma.course.update({ - where: { id: courseId, authorId: userId }, - data: { - status: CourseStatus.PUBLISHED, - isPublished: true, - publishedAt: new Date(), - }, - }); + async createCourseCheckout(courseId: string, userId: string): Promise { + return this.paymentsService.createCourseCheckoutSession(userId, courseId); } async toggleVerification(courseId: string, userId: string): Promise { diff --git a/apps/api/src/courses/courses.controller.ts b/apps/api/src/courses/courses.controller.ts index f53d01e..79ac2e5 100644 --- a/apps/api/src/courses/courses.controller.ts +++ b/apps/api/src/courses/courses.controller.ts @@ -16,6 +16,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { User, CourseStatus } from '@coursecraft/database'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; +import { ReviewHomeworkDto } from './dto/review-homework.dto'; @ApiTags('courses') @Controller('courses') @@ -75,4 +76,24 @@ export class CoursesController { ): Promise { 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 { + 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 { + return this.coursesService.getHomeworkSubmissionsForAuthor(id, user.id); + } } diff --git a/apps/api/src/courses/courses.service.ts b/apps/api/src/courses/courses.service.ts index 447c6c4..ee98a37 100644 --- a/apps/api/src/courses/courses.service.ts +++ b/apps/api/src/courses/courses.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; 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 { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; @@ -19,6 +19,13 @@ export class CoursesService { description: dto.description, slug, status: CourseStatus.DRAFT, + groups: { + create: { + name: 'Основная группа', + description: 'Обсуждение курса и вопросы преподавателю', + isDefault: true, + }, + }, }, include: { chapters: { @@ -117,6 +124,7 @@ export class CoursesService { orderBy: { order: 'asc' }, }, category: true, + groups: true, }, }); } @@ -167,6 +175,12 @@ export class CoursesService { estimatedHours: dto.estimatedHours, metaTitle: dto.metaTitle, metaDescription: dto.metaDescription, + ...(dto.price !== undefined + ? { + price: dto.price > 0 ? new Prisma.Decimal(dto.price) : null, + } + : {}), + ...(dto.currency ? { currency: dto.currency.toUpperCase() } : {}), }, include: { chapters: { @@ -208,12 +222,12 @@ export class CoursesService { throw new ForbiddenException('You can only edit your own courses'); } - const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status }; - - if (status === CourseStatus.PUBLISHED && !course.publishedAt) { - updateData.publishedAt = new Date(); + if (status === CourseStatus.PUBLISHED) { + throw new ForbiddenException('Course can be published only by moderation'); } + const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status }; + return this.prisma.course.update({ where: { id }, data: updateData, @@ -228,4 +242,91 @@ export class CoursesService { 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' }], + }); + } } diff --git a/apps/api/src/courses/dto/review-homework.dto.ts b/apps/api/src/courses/dto/review-homework.dto.ts new file mode 100644 index 0000000..6d84621 --- /dev/null +++ b/apps/api/src/courses/dto/review-homework.dto.ts @@ -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; +} diff --git a/apps/api/src/courses/dto/update-course.dto.ts b/apps/api/src/courses/dto/update-course.dto.ts index 101b5cf..cf34dee 100644 --- a/apps/api/src/courses/dto/update-course.dto.ts +++ b/apps/api/src/courses/dto/update-course.dto.ts @@ -8,6 +8,7 @@ import { MinLength, MaxLength, Min, + IsIn, } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { CourseStatus } from '@coursecraft/database'; @@ -68,4 +69,17 @@ export class UpdateCourseDto { @IsString() @MaxLength(300) 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; } diff --git a/apps/api/src/enrollment/dto/create-review.dto.ts b/apps/api/src/enrollment/dto/create-review.dto.ts new file mode 100644 index 0000000..f5234eb --- /dev/null +++ b/apps/api/src/enrollment/dto/create-review.dto.ts @@ -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; +} diff --git a/apps/api/src/enrollment/dto/submit-homework.dto.ts b/apps/api/src/enrollment/dto/submit-homework.dto.ts new file mode 100644 index 0000000..db8689d --- /dev/null +++ b/apps/api/src/enrollment/dto/submit-homework.dto.ts @@ -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; +} diff --git a/apps/api/src/enrollment/dto/submit-quiz.dto.ts b/apps/api/src/enrollment/dto/submit-quiz.dto.ts new file mode 100644 index 0000000..5971737 --- /dev/null +++ b/apps/api/src/enrollment/dto/submit-quiz.dto.ts @@ -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[]; +} diff --git a/apps/api/src/enrollment/enrollment.controller.ts b/apps/api/src/enrollment/enrollment.controller.ts index daa967b..758e915 100644 --- a/apps/api/src/enrollment/enrollment.controller.ts +++ b/apps/api/src/enrollment/enrollment.controller.ts @@ -12,6 +12,9 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { EnrollmentService } from './enrollment.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; 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') @Controller('enrollment') @@ -55,10 +58,32 @@ export class EnrollmentController { async submitQuiz( @Param('courseId') courseId: string, @Param('lessonId') lessonId: string, - @Body('score') score: number, + @Body() dto: SubmitQuizDto, @CurrentUser() user: User, ): Promise { - 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 { + 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 { + return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto.content); } @Post(':courseId/review') @@ -66,7 +91,7 @@ export class EnrollmentController { @ApiOperation({ summary: 'Leave a review' }) async createReview( @Param('courseId') courseId: string, - @Body() body: { rating: number; title?: string; content?: string }, + @Body() body: CreateReviewDto, @CurrentUser() user: User, ): Promise { return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content); diff --git a/apps/api/src/enrollment/enrollment.service.ts b/apps/api/src/enrollment/enrollment.service.ts index 32bc951..26c9adc 100644 --- a/apps/api/src/enrollment/enrollment.service.ts +++ b/apps/api/src/enrollment/enrollment.service.ts @@ -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 { HomeworkReviewStatus } from '@coursecraft/database'; + +const QUIZ_PASS_THRESHOLD = 70; @Injectable() export class EnrollmentService { constructor(private prisma: PrismaService) {} async enroll(userId: string, courseId: string): Promise { + 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({ where: { userId_courseId: { userId, courseId } }, }); if (existing) throw new ConflictException('Already enrolled'); - return this.prisma.enrollment.create({ + const enrollment = await this.prisma.enrollment.create({ data: { userId, courseId }, include: { course: { select: { id: true, title: true, slug: true } } }, }); + + await this.addUserToDefaultCourseGroup(courseId, userId); + return enrollment; } async getUserEnrollments(userId: string): Promise { @@ -34,10 +61,56 @@ export class EnrollmentService { } async completeLesson(userId: string, courseId: string, lessonId: string): Promise { - const enrollment = await this.prisma.enrollment.findUnique({ - where: { userId_courseId: { userId, courseId } }, + const enrollment = await this.requireEnrollment(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 { + 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({ where: { userId_lessonId: { userId, lessonId } }, @@ -45,78 +118,219 @@ export class EnrollmentService { userId, enrollmentId: enrollment.id, lessonId, - completedAt: new Date(), + quizScore: score, + quizPassed: finalPassed, + quizPassedAt: finalPassed ? new Date() : null, + completedAt: completedAt || null, }, update: { - completedAt: new Date(), + quizScore: score, + quizPassed: finalPassed, + quizPassedAt: finalPassed ? existing?.quizPassedAt || new Date() : null, + completedAt: completedAt || null, }, }); 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 { - const enrollment = await this.prisma.enrollment.findUnique({ - where: { userId_courseId: { userId, courseId } }, - }); - if (!enrollment) throw new NotFoundException('Not enrolled in this course'); + async getHomework(userId: string, courseId: string, lessonId: string): Promise { + await this.requireEnrollment(userId, courseId); + await this.assertLessonUnlocked(userId, courseId, lessonId); - 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 { + 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 } }, create: { userId, enrollmentId: enrollment.id, 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(), }, - update: { quizScore: score }, }); + + await this.recalculateProgress(enrollment.id, courseId); + return submission; } async getProgress(userId: string, courseId: string): Promise { return this.prisma.enrollment.findUnique({ where: { userId_courseId: { userId, courseId } }, - include: { lessons: true }, + include: { + lessons: true, + }, }); } async createReview(userId: string, courseId: string, rating: number, title?: string, content?: string): Promise { + await this.requireEnrollment(userId, courseId); + const review = await this.prisma.review.upsert({ where: { userId_courseId: { userId, courseId } }, - create: { userId, courseId, rating, title, content }, - update: { rating, title, content }, + create: { userId, courseId, rating, title, content, isApproved: true }, + update: { rating, title, content, isApproved: true }, }); - // Recalculate avg rating - 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 }, - }); - } - + await this.recalculateAverageRating(courseId); return review; } async getCourseReviews(courseId: string, page = 1, limit = 20): Promise { - 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([ this.prisma.review.findMany({ where: { courseId, isApproved: true }, include: { user: { select: { id: true, name: true, avatarUrl: true } } }, orderBy: { createdAt: 'desc' }, skip, - take: limit, + take: safeLimit, }), 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 { + 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 { + 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 { + 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 { @@ -124,7 +338,11 @@ export class EnrollmentService { where: { chapter: { courseId } }, }); 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; @@ -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 }; + } } diff --git a/apps/api/src/groups/groups.controller.ts b/apps/api/src/groups/groups.controller.ts index 2191121..0ca3611 100644 --- a/apps/api/src/groups/groups.controller.ts +++ b/apps/api/src/groups/groups.controller.ts @@ -16,8 +16,18 @@ export class GroupsController { } @Post(':groupId/members') - async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string): Promise { - return this.groupsService.addMember(groupId, userId); + async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string, @CurrentUser() user: User): Promise { + return this.groupsService.addMember(groupId, user.id, userId); + } + + @Get('course/:courseId/default') + async getDefaultGroup(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + return this.groupsService.getDefaultGroup(courseId, user.id); + } + + @Get(':groupId/members') + async getMembers(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise { + return this.groupsService.getGroupMembers(groupId, user.id); } @Get(':groupId/messages') @@ -29,4 +39,14 @@ export class GroupsController { async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise { return this.groupsService.sendMessage(groupId, user.id, content); } + + @Post(':groupId/invite-link') + async createInviteLink(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise { + return this.groupsService.createInviteLink(groupId, user.id); + } + + @Post('join/:groupId') + async joinByInvite(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise { + return this.groupsService.joinByInvite(groupId, user.id); + } } diff --git a/apps/api/src/groups/groups.gateway.ts b/apps/api/src/groups/groups.gateway.ts new file mode 100644 index 0000000..d7929ee --- /dev/null +++ b/apps/api/src/groups/groups.gateway.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/groups/groups.module.ts b/apps/api/src/groups/groups.module.ts index fcde5d8..8170f76 100644 --- a/apps/api/src/groups/groups.module.ts +++ b/apps/api/src/groups/groups.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { GroupsController } from './groups.controller'; import { GroupsService } from './groups.service'; +import { GroupsGateway } from './groups.gateway'; +import { UsersModule } from '../users/users.module'; @Module({ + imports: [UsersModule], controllers: [GroupsController], - providers: [GroupsService], + providers: [GroupsService, GroupsGateway], exports: [GroupsService], }) export class GroupsModule {} diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index a585c6f..33dcb8f 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -14,35 +14,171 @@ export class GroupsService { }); } - async addMember(groupId: string, userId: string, role = 'student'): Promise { - return this.prisma.groupMember.create({ - data: { groupId, userId, role }, + async ensureDefaultGroup(courseId: string): Promise { + 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, + }, + }); + } + + async getDefaultGroup(courseId: string, userId: string): Promise { + 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 { + 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 { + 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 { - const member = await this.prisma.groupMember.findUnique({ - where: { groupId_userId: { groupId, userId } }, - }); - if (!member) throw new ForbiddenException('Not a member of this group'); + await this.assertCanReadGroup(groupId, userId); return this.prisma.groupMessage.findMany({ where: { groupId }, include: { user: { select: { id: true, name: true, avatarUrl: true } } }, orderBy: { createdAt: 'asc' }, - take: 100, + take: 200, }); } async sendMessage(groupId: string, userId: string, content: string): Promise { - const member = await this.prisma.groupMember.findUnique({ - where: { groupId_userId: { groupId, userId } }, - }); - if (!member) throw new ForbiddenException('Not a member of this group'); + await this.assertCanReadGroup(groupId, userId); return this.prisma.groupMessage.create({ data: { groupId, userId, content }, include: { user: { select: { id: true, name: true, avatarUrl: true } } }, }); } + + async createInviteLink(groupId: string, userId: string): Promise { + 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 { + 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 { + 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 { + 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'); + } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5b02507..284a8a0 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,7 +7,7 @@ import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/all-exceptions.filter'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { rawBody: true }); const configService = app.get(ConfigService); app.useGlobalFilters(new AllExceptionsFilter()); diff --git a/apps/api/src/moderation/moderation.controller.ts b/apps/api/src/moderation/moderation.controller.ts index 11a2efb..0373c07 100644 --- a/apps/api/src/moderation/moderation.controller.ts +++ b/apps/api/src/moderation/moderation.controller.ts @@ -24,4 +24,14 @@ export class ModerationController { async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise { return this.moderationService.rejectCourse(user.id, courseId, reason); } + + @Post('reviews/:reviewId/hide') + async hideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise { + return this.moderationService.hideReview(user.id, reviewId); + } + + @Post('reviews/:reviewId/unhide') + async unhideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise { + return this.moderationService.unhideReview(user.id, reviewId); + } } diff --git a/apps/api/src/moderation/moderation.service.ts b/apps/api/src/moderation/moderation.service.ts index 65ad092..cd69eca 100644 --- a/apps/api/src/moderation/moderation.service.ts +++ b/apps/api/src/moderation/moderation.service.ts @@ -28,6 +28,17 @@ export class ModerationService { 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({ where: { id: courseId }, data: { @@ -46,6 +57,17 @@ export class ModerationService { 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({ where: { id: courseId }, data: { @@ -55,4 +77,42 @@ export class ModerationService { }, }); } + + async hideReview(userId: string, reviewId: string): Promise { + 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 { + 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 { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { + throw new ForbiddenException('Moderators only'); + } + } + + private async recalculateAverageRating(courseId: string): Promise { + const result = await this.prisma.review.aggregate({ + where: { courseId, isApproved: true }, + _avg: { rating: true }, + }); + await this.prisma.course.update({ + where: { id: courseId }, + data: { averageRating: result._avg.rating ?? null }, + }); + } } diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts index 6296415..bbe3779 100644 --- a/apps/api/src/payments/payments.service.ts +++ b/apps/api/src/payments/payments.service.ts @@ -28,34 +28,7 @@ export class PaymentsService { } async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') { - const user = await this.prisma.user.findUnique({ - 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, - }, - }); - } + const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId); // Get price ID for tier const priceId = @@ -83,6 +56,57 @@ export class PaymentsService { 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('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) { const subscription = await this.prisma.subscription.findUnique({ where: { userId }, @@ -107,8 +131,8 @@ export class PaymentsService { case 'checkout.session.completed': await this.handleCheckoutCompleted(event.data.object as { customer: string; - subscription: string; - metadata: { userId: string; tier: string }; + subscription?: string; + metadata: { userId?: string; tier?: string; type?: string; courseId?: string }; }); break; @@ -133,10 +157,26 @@ export class PaymentsService { private async handleCheckoutCompleted(session: { customer: string; - subscription: string; - metadata: { userId: string; tier: string }; + subscription?: string; + metadata: { userId?: string; tier?: string; type?: string; courseId?: string }; }) { 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 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: { id: string; customer: string; diff --git a/apps/api/src/payments/stripe.service.ts b/apps/api/src/payments/stripe.service.ts index fbe56b1..8dae0ed 100644 --- a/apps/api/src/payments/stripe.service.ts +++ b/apps/api/src/payments/stripe.service.ts @@ -45,6 +45,38 @@ export class StripeService { }); } + async createOneTimeCheckoutSession(params: { + customerId: string; + successUrl: string; + cancelUrl: string; + metadata?: Record; + currency: string; + unitAmount: number; + productName: string; + productDescription?: string; + }): Promise { + 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 { return this.stripe.billingPortal.sessions.create({ customer: customerId, diff --git a/apps/api/src/support/support.controller.ts b/apps/api/src/support/support.controller.ts index 8946cea..e349eef 100644 --- a/apps/api/src/support/support.controller.ts +++ b/apps/api/src/support/support.controller.ts @@ -11,8 +11,11 @@ export class SupportController { constructor(private supportService: SupportService) {} @Post('tickets') - async createTicket(@Body('title') title: string, @CurrentUser() user: User): Promise { - return this.supportService.createTicket(user.id, title); + async createTicket( + @Body() body: { title: string; initialMessage?: string; priority?: string }, + @CurrentUser() user: User + ): Promise { + return this.supportService.createTicket(user.id, body.title, body.initialMessage, body.priority); } @Get('tickets') @@ -29,4 +32,24 @@ export class SupportController { async sendMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise { return this.supportService.sendMessage(id, user.id, content); } + + @Get('admin/tickets') + async getAllTickets(@CurrentUser() user: User): Promise { + return this.supportService.getAllTickets(user.id); + } + + @Get('admin/tickets/:id/messages') + async getMessagesForStaff(@Param('id') id: string, @CurrentUser() user: User): Promise { + 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 { + 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 { + return this.supportService.updateTicketStatus(id, user.id, status); + } } diff --git a/apps/api/src/support/support.gateway.ts b/apps/api/src/support/support.gateway.ts new file mode 100644 index 0000000..934b4d2 --- /dev/null +++ b/apps/api/src/support/support.gateway.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/support/support.module.ts b/apps/api/src/support/support.module.ts index 8e4bed0..e084338 100644 --- a/apps/api/src/support/support.module.ts +++ b/apps/api/src/support/support.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { SupportController } from './support.controller'; import { SupportService } from './support.service'; +import { SupportGateway } from './support.gateway'; +import { UsersModule } from '../users/users.module'; @Module({ + imports: [UsersModule], controllers: [SupportController], - providers: [SupportService], + providers: [SupportService, SupportGateway], exports: [SupportService], }) export class SupportModule {} diff --git a/apps/api/src/support/support.service.ts b/apps/api/src/support/support.service.ts index 483535e..5914fa0 100644 --- a/apps/api/src/support/support.service.ts +++ b/apps/api/src/support/support.service.ts @@ -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'; @Injectable() export class SupportService { constructor(private prisma: PrismaService) {} - async createTicket(userId: string, title: string): Promise { - return this.prisma.supportTicket.create({ - data: { userId, title }, - include: { messages: { include: { user: { select: { name: true } } } } }, + async createTicket( + userId: string, + title: string, + initialMessage?: string, + priority: string = 'normal' + ): Promise { + 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 { 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({ where: { ticketId }, @@ -32,9 +58,104 @@ export class SupportService { } async sendMessage(ticketId: string, userId: string, content: string): Promise { - 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 }, - 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } + } } diff --git a/apps/web/package.json b/apps/web/package.json index 6ca58a4..7f8d2e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.50.0", + "socket.io-client": "^4.8.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4", diff --git a/apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx b/apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx index b99f02a..b8bd9e4 100644 --- a/apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx +++ b/apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx @@ -80,7 +80,7 @@ export default function CertificatePage() {
diff --git a/apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx b/apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx new file mode 100644 index 0000000..bb9e592 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx @@ -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([]); + const [selectedTicketId, setSelectedTicketId] = useState(null); + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState('in_progress'); + const [message, setMessage] = useState(''); + const socketRef = useRef(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 ( +
+ + + Админ: тикеты поддержки + + + {tickets.map((ticket) => ( + + ))} + + + + + + {selected ? `Тикет: ${selected.title}` : 'Выберите тикет'} + + +
+ + +
+ +
+ {messages.map((msg) => ( +
+

+ {msg.user?.name || 'Пользователь'} {msg.isStaff ? '(Поддержка)' : ''} +

+

{msg.content}

+
+ ))} +
+ +
+ setMessage(e.target.value)} + placeholder="Ответ поддержки" + className="flex-1 rounded-md border bg-background px-3 py-2 text-sm" + /> + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx b/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx index 432e97c..7571d9c 100644 --- a/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx @@ -35,6 +35,10 @@ export default function PublicCoursePage() { const [enrolling, setEnrolling] = useState(false); const [enrolled, setEnrolled] = useState(false); const [expandedChapters, setExpandedChapters] = useState([]); + const [submittingReview, setSubmittingReview] = useState(false); + const [reviewRating, setReviewRating] = useState(5); + const [reviewTitle, setReviewTitle] = useState(''); + const [reviewContent, setReviewContent] = useState(''); useEffect(() => { if (!id) return; @@ -65,9 +69,17 @@ export default function PublicCoursePage() { if (!id || enrolling) return; setEnrolling(true); try { - await api.enrollInCourse(id); - toast({ title: 'Успех', description: 'Вы записались на курс' }); - router.push(`/dashboard/courses/${id}`); + if (course?.price) { + const session = await api.checkoutCourse(id); + if (session?.url) { + window.location.href = session.url; + return; + } + } else { + await api.enrollInCourse(id); + toast({ title: 'Успех', description: 'Вы записались на курс' }); + router.push(`/dashboard/courses/${id}`); + } } catch (e: any) { toast({ title: 'Ошибка', description: e.message, variant: 'destructive' }); } 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) { return (
@@ -190,7 +223,7 @@ export default function PublicCoursePage() { ) : ( )}
@@ -300,6 +333,43 @@ export default function PublicCoursePage() { )} + + {enrolled && ( + + +

Оцените курс

+
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ setReviewTitle(e.target.value)} + placeholder="Заголовок отзыва" + className="w-full rounded-md border bg-background px-3 py-2 text-sm" + /> +