your message

This commit is contained in:
root
2026-02-06 14:53:52 +00:00
parent c809d049fe
commit 3d488f22b7
47 changed files with 3127 additions and 425 deletions

View File

@ -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": {

View File

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

View File

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

View File

@ -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<any> {
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<any> {
return this.catalogService.toggleVerification(id, user.id);
}
@Post(':id/checkout')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Create one-time checkout session for paid course' })
async checkoutCourse(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.catalogService.createCourseCheckout(id, user.id);
}
}

View File

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

View File

@ -1,10 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { 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<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({
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<any> {
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<any> {
return this.paymentsService.createCourseCheckoutSession(userId, courseId);
}
async toggleVerification(courseId: string, userId: string): Promise<any> {

View File

@ -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<any> {
return this.coursesService.updateStatus(id, user.id, status);
}
@Post(':id/homework-submissions/:submissionId/review')
@ApiOperation({ summary: 'Review homework submission as course author' })
async reviewHomework(
@Param('id') id: string,
@Param('submissionId') submissionId: string,
@CurrentUser() user: User,
@Body() dto: ReviewHomeworkDto
): Promise<any> {
return this.coursesService.reviewHomeworkSubmission(id, submissionId, user.id, dto);
}
@Get(':id/homework-submissions')
@ApiOperation({ summary: 'Get homework submissions for course author review' })
async getHomeworkSubmissions(
@Param('id') id: string,
@CurrentUser() user: User
): Promise<any> {
return this.coursesService.getHomeworkSubmissionsForAuthor(id, user.id);
}
}

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
MinLength,
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;
}

View File

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

View File

@ -0,0 +1,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;
}

View File

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

View File

@ -12,6 +12,9 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { EnrollmentService } from './enrollment.service';
import { 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<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')
@ -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<any> {
return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content);

View File

@ -1,20 +1,47 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { HomeworkReviewStatus } from '@coursecraft/database';
const QUIZ_PASS_THRESHOLD = 70;
@Injectable()
export class EnrollmentService {
constructor(private prisma: PrismaService) {}
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({
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<any> {
@ -34,10 +61,56 @@ export class EnrollmentService {
}
async completeLesson(userId: string, courseId: string, lessonId: string): Promise<any> {
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<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({
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<any> {
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<any> {
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<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 } },
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<any> {
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<any> {
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<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([
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<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> {
@ -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 };
}
}

View File

@ -16,8 +16,18 @@ export class GroupsController {
}
@Post(':groupId/members')
async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string): Promise<any> {
return this.groupsService.addMember(groupId, userId);
async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string, @CurrentUser() user: User): Promise<any> {
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')
@ -29,4 +39,14 @@ export class GroupsController {
async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
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);
}
}

View File

@ -0,0 +1,113 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { AuthService } from '../auth/auth.service';
import { SupabaseService } from '../auth/supabase.service';
import { UsersService } from '../users/users.service';
import { GroupsService } from './groups.service';
@WebSocketGateway({
namespace: '/ws/course-groups',
cors: {
origin: true,
credentials: true,
},
})
export class GroupsGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
private jwtService: JwtService,
private authService: AuthService,
private supabaseService: SupabaseService,
private usersService: UsersService,
private groupsService: GroupsService
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const user = await this.resolveUser(client);
if (!user) {
client.disconnect();
return;
}
client.data.user = user;
} catch {
client.disconnect();
}
}
@SubscribeMessage('groups:join')
async joinGroup(
@ConnectedSocket() client: Socket,
@MessageBody() body: { groupId: string }
) {
const user = client.data.user;
if (!user || !body?.groupId) return { ok: false };
const canJoin = await this.groupsService.isMember(body.groupId, user.id);
if (!canJoin) {
await this.groupsService.joinByInvite(body.groupId, user.id);
}
await client.join(this.room(body.groupId));
const messages = await this.groupsService.getGroupMessages(body.groupId, user.id);
return { ok: true, messages };
}
@SubscribeMessage('groups:send')
async sendMessage(
@ConnectedSocket() client: Socket,
@MessageBody() body: { groupId: string; content: string }
) {
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;
}
}

View File

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

View File

@ -14,35 +14,171 @@ export class GroupsService {
});
}
async addMember(groupId: string, userId: string, role = 'student'): Promise<any> {
return this.prisma.groupMember.create({
data: { groupId, userId, role },
async ensureDefaultGroup(courseId: string): Promise<any> {
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<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> {
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<any> {
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<any> {
const group = await this.prisma.courseGroup.findUnique({
where: { id: groupId },
include: { course: { select: { authorId: true } } },
});
if (!group) throw new NotFoundException('Group not found');
if (group.course.authorId !== userId) {
throw new ForbiddenException('Only course author can create invite links');
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3080';
return {
groupId,
inviteUrl: `${appUrl}/dashboard/groups/${groupId}`,
};
}
async joinByInvite(groupId: string, userId: string): Promise<any> {
const group = await this.prisma.courseGroup.findUnique({
where: { id: groupId },
include: {
course: {
select: { id: true, authorId: true, price: true },
},
},
});
if (!group) throw new NotFoundException('Group not found');
const hasEnrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: group.course.id } },
});
const hasPurchase = await this.prisma.purchase.findUnique({
where: { userId_courseId: { userId, courseId: group.course.id } },
});
if (!hasEnrollment && !hasPurchase && group.course.authorId !== userId) {
throw new ForbiddenException('Enroll or purchase course first');
}
return this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId, userId } },
create: { groupId, userId, role: group.course.authorId === userId ? 'teacher' : 'student' },
update: {},
});
}
async isMember(groupId: string, userId: string): Promise<boolean> {
const member = await this.prisma.groupMember.findUnique({
where: { groupId_userId: { groupId, userId } },
select: { id: true },
});
return Boolean(member);
}
private async assertCanReadGroup(groupId: string, userId: string): Promise<void> {
const group = await this.prisma.courseGroup.findUnique({
where: { id: groupId },
include: {
course: {
select: { id: true, authorId: true },
},
},
});
if (!group) {
throw new NotFoundException('Group not found');
}
if (group.course.authorId === userId) return;
const member = await this.prisma.groupMember.findUnique({
where: { groupId_userId: { groupId, userId } },
select: { id: true },
});
if (member) return;
const enrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: group.course.id } },
select: { id: true },
});
if (enrollment) {
await this.prisma.groupMember.upsert({
where: { groupId_userId: { groupId, userId } },
create: { groupId, userId, role: 'student' },
update: {},
});
return;
}
throw new ForbiddenException('No access to this group');
}
}

View File

@ -7,7 +7,7 @@ import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/all-exceptions.filter';
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());

View File

@ -24,4 +24,14 @@ export class ModerationController {
async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise<any> {
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,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 {}

View File

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