your message
This commit is contained in:
@ -43,6 +43,6 @@ import { UsersModule } from '../users/users.module';
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SupabaseService, JwtAuthGuard],
|
||||
exports: [AuthService, SupabaseService, JwtAuthGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -55,6 +55,7 @@ export class AuthService {
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
subscriptionTier: user.subscriptionTier,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
15
apps/api/src/courses/dto/review-homework.dto.ts
Normal file
15
apps/api/src/courses/dto/review-homework.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ReviewHomeworkDto {
|
||||
@ApiProperty({ description: 'Teacher score from 1 to 5', minimum: 1, maximum: 5 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
teacherScore: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Teacher feedback' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teacherFeedback?: string;
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
MinLength,
|
||||
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;
|
||||
}
|
||||
|
||||
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal file
22
apps/api/src/enrollment/dto/create-review.dto.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateReviewDto {
|
||||
@ApiProperty({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Review title' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Review content' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
content?: string;
|
||||
}
|
||||
14
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal file
14
apps/api/src/enrollment/dto/submit-homework.dto.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SubmitHomeworkDto {
|
||||
@ApiProperty({
|
||||
description: 'Written homework answer',
|
||||
minLength: 50,
|
||||
maxLength: 20000,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(50)
|
||||
@MaxLength(20000)
|
||||
content: string;
|
||||
}
|
||||
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal file
15
apps/api/src/enrollment/dto/submit-quiz.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class SubmitQuizDto {
|
||||
@ApiProperty({
|
||||
description: 'Selected option index for each quiz question',
|
||||
type: [Number],
|
||||
example: [0, 2, 1],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
answers: number[];
|
||||
}
|
||||
@ -12,6 +12,9 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { EnrollmentService } from './enrollment.service';
|
||||
import { 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);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
113
apps/api/src/groups/groups.gateway.ts
Normal file
113
apps/api/src/groups/groups.gateway.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { SupabaseService } from '../auth/supabase.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { GroupsService } from './groups.service';
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/ws/course-groups',
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class GroupsGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private authService: AuthService,
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService,
|
||||
private groupsService: GroupsService
|
||||
) {}
|
||||
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
try {
|
||||
const user = await this.resolveUser(client);
|
||||
if (!user) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
client.data.user = user;
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('groups:join')
|
||||
async joinGroup(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { groupId: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.groupId) return { ok: false };
|
||||
|
||||
const canJoin = await this.groupsService.isMember(body.groupId, user.id);
|
||||
if (!canJoin) {
|
||||
await this.groupsService.joinByInvite(body.groupId, user.id);
|
||||
}
|
||||
await client.join(this.room(body.groupId));
|
||||
const messages = await this.groupsService.getGroupMessages(body.groupId, user.id);
|
||||
return { ok: true, messages };
|
||||
}
|
||||
|
||||
@SubscribeMessage('groups:send')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { groupId: string; content: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.groupId || !body?.content?.trim()) return { ok: false };
|
||||
|
||||
const message = await this.groupsService.sendMessage(body.groupId, user.id, body.content.trim());
|
||||
this.server.to(this.room(body.groupId)).emit('groups:new-message', message);
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
private room(groupId: string): string {
|
||||
return `group:${groupId}`;
|
||||
}
|
||||
|
||||
private async resolveUser(client: Socket) {
|
||||
const authToken =
|
||||
(client.handshake.auth?.token as string | undefined) ||
|
||||
((client.handshake.headers.authorization as string | undefined)?.replace(/^Bearer\s+/i, '')) ||
|
||||
undefined;
|
||||
if (!authToken) return null;
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; email: string; supabaseId: string }>(authToken);
|
||||
const user = await this.authService.validateJwtPayload(payload);
|
||||
if (user) return user;
|
||||
} catch {
|
||||
// Fallback to Supabase access token
|
||||
}
|
||||
|
||||
const supabaseUser = await this.supabaseService.verifyToken(authToken);
|
||||
if (!supabaseUser) return null;
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
if (!user) {
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name:
|
||||
supabaseUser.user_metadata?.full_name ||
|
||||
supabaseUser.user_metadata?.name ||
|
||||
null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { 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 {}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
125
apps/api/src/support/support.gateway.ts
Normal file
125
apps/api/src/support/support.gateway.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { SupabaseService } from '../auth/supabase.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/ws/support',
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class SupportGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private authService: AuthService,
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService,
|
||||
private supportService: SupportService
|
||||
) {}
|
||||
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
try {
|
||||
const user = await this.resolveUser(client);
|
||||
if (!user) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
client.data.user = user;
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('support:join')
|
||||
async joinTicket(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { ticketId: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.ticketId) return { ok: false };
|
||||
|
||||
const canAccess = await this.supportService.canAccessTicket(body.ticketId, user.id);
|
||||
if (!canAccess) return { ok: false };
|
||||
|
||||
await client.join(this.room(body.ticketId));
|
||||
const messages =
|
||||
(await this.supportService.getTicketMessagesForStaff(body.ticketId, user.id).catch(async () =>
|
||||
this.supportService.getTicketMessages(body.ticketId, user.id)
|
||||
)) || [];
|
||||
return { ok: true, messages };
|
||||
}
|
||||
|
||||
@SubscribeMessage('support:send')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { ticketId: string; content: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.ticketId || !body?.content?.trim()) return { ok: false };
|
||||
|
||||
const canAccess = await this.supportService.canAccessTicket(body.ticketId, user.id);
|
||||
if (!canAccess) return { ok: false };
|
||||
|
||||
const message =
|
||||
(await this.supportService.sendStaffMessage(body.ticketId, user.id, body.content.trim()).catch(async () =>
|
||||
this.supportService.sendMessage(body.ticketId, user.id, body.content.trim())
|
||||
)) || null;
|
||||
|
||||
if (message) {
|
||||
this.server.to(this.room(body.ticketId)).emit('support:new-message', message);
|
||||
return { ok: true, message };
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
private room(ticketId: string): string {
|
||||
return `ticket:${ticketId}`;
|
||||
}
|
||||
|
||||
private async resolveUser(client: Socket) {
|
||||
const authToken =
|
||||
(client.handshake.auth?.token as string | undefined) ||
|
||||
((client.handshake.headers.authorization as string | undefined)?.replace(/^Bearer\s+/i, '')) ||
|
||||
undefined;
|
||||
if (!authToken) return null;
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; email: string; supabaseId: string }>(authToken);
|
||||
const user = await this.authService.validateJwtPayload(payload);
|
||||
if (user) return user;
|
||||
} catch {
|
||||
// Fallback to Supabase access token
|
||||
}
|
||||
|
||||
const supabaseUser = await this.supabaseService.verifyToken(authToken);
|
||||
if (!supabaseUser) return null;
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
if (!user) {
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name:
|
||||
supabaseUser.user_metadata?.full_name ||
|
||||
supabaseUser.user_metadata?.name ||
|
||||
null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { 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 {}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user