feat: phase1 platform upgrade with moderation, dev payments, admin panel and landing updates
This commit is contained in:
43
apps/api/src/admin/admin.controller.ts
Normal file
43
apps/api/src/admin/admin.controller.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { PaymentMode, PaymentProvider, User, UserRole } from '@coursecraft/database';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
@ApiBearerAuth()
|
||||
export class AdminController {
|
||||
constructor(private adminService: AdminService) {}
|
||||
|
||||
@Get('users')
|
||||
async getUsers(
|
||||
@CurrentUser() user: User,
|
||||
@Query('search') search?: string,
|
||||
@Query('role') role?: UserRole,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.adminService.getUsers(user.id, { search, role, limit });
|
||||
}
|
||||
|
||||
@Patch('users/:id/role')
|
||||
async updateUserRole(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') targetUserId: string,
|
||||
@Body('role') role: UserRole,
|
||||
) {
|
||||
return this.adminService.updateUserRole(user.id, targetUserId, role);
|
||||
}
|
||||
|
||||
@Get('payments')
|
||||
async getPayments(
|
||||
@CurrentUser() user: User,
|
||||
@Query('mode') mode?: PaymentMode,
|
||||
@Query('provider') provider?: PaymentProvider,
|
||||
@Query('status') status?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<any> {
|
||||
return this.adminService.getPayments(user.id, { mode, provider, status, search, limit });
|
||||
}
|
||||
}
|
||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AccessModule, UsersModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
64
apps/api/src/admin/admin.service.ts
Normal file
64
apps/api/src/admin/admin.service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PaymentMode, PaymentProvider, UserRole } from '@coursecraft/database';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async getUsers(adminUserId: string, options?: { search?: string; role?: UserRole; limit?: number }) {
|
||||
await this.access.assertAdmin(adminUserId);
|
||||
return this.usersService.listUsers(options);
|
||||
}
|
||||
|
||||
async updateUserRole(adminUserId: string, targetUserId: string, role: UserRole) {
|
||||
await this.access.assertAdmin(adminUserId);
|
||||
return this.usersService.updateRole(targetUserId, role);
|
||||
}
|
||||
|
||||
async getPayments(
|
||||
adminUserId: string,
|
||||
filters?: {
|
||||
mode?: PaymentMode;
|
||||
provider?: PaymentProvider;
|
||||
status?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<any> {
|
||||
await this.access.assertAdmin(adminUserId);
|
||||
const limit = Math.min(300, Math.max(1, filters?.limit || 150));
|
||||
const where: any = {};
|
||||
if (filters?.mode) where.mode = filters.mode;
|
||||
if (filters?.provider) where.provider = filters.provider;
|
||||
if (filters?.status) where.status = filters.status;
|
||||
if (filters?.search?.trim()) {
|
||||
const term = filters.search.trim();
|
||||
where.OR = [
|
||||
{ user: { email: { contains: term, mode: 'insensitive' } } },
|
||||
{ user: { name: { contains: term, mode: 'insensitive' } } },
|
||||
{ course: { title: { contains: term, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
return this.prisma.purchase.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, name: true, avatarUrl: true },
|
||||
},
|
||||
course: {
|
||||
select: { id: true, title: true, slug: true, authorId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,8 @@ import { GenerationModule } from './generation/generation.module';
|
||||
import { PaymentsModule } from './payments/payments.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { PrismaModule } from './common/prisma/prisma.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { CooperationModule } from './cooperation/cooperation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -53,6 +55,8 @@ import { PrismaModule } from './common/prisma/prisma.module';
|
||||
GenerationModule,
|
||||
PaymentsModule,
|
||||
SearchModule,
|
||||
AdminModule,
|
||||
CooperationModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -104,13 +104,26 @@ export class CatalogService {
|
||||
throw new ForbiddenException('Only course author can submit for moderation');
|
||||
}
|
||||
|
||||
return this.prisma.course.update({
|
||||
const fromStatus = course.status;
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
status: CourseStatus.PENDING_REVIEW,
|
||||
status: CourseStatus.PENDING_MODERATION,
|
||||
isPublished: false,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId,
|
||||
fromStatus,
|
||||
toStatus: CourseStatus.PENDING_MODERATION,
|
||||
changedById: userId,
|
||||
note: 'Submitted for moderation',
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async createCourseCheckout(courseId: string, userId: string): Promise<any> {
|
||||
|
||||
11
apps/api/src/common/access/access.module.ts
Normal file
11
apps/api/src/common/access/access.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AccessService],
|
||||
exports: [AccessService],
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
||||
61
apps/api/src/common/access/access.service.ts
Normal file
61
apps/api/src/common/access/access.service.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { UserRole } from '@coursecraft/database';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getUserRole(userId: string): Promise<UserRole> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return user.role;
|
||||
}
|
||||
|
||||
async assertStaff(userId: string): Promise<void> {
|
||||
const role = await this.getUserRole(userId);
|
||||
if (role !== UserRole.MODERATOR && role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Staff access only');
|
||||
}
|
||||
}
|
||||
|
||||
async assertAdmin(userId: string): Promise<void> {
|
||||
const role = await this.getUserRole(userId);
|
||||
if (role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Admin access only');
|
||||
}
|
||||
}
|
||||
|
||||
async assertCourseOwner(courseId: string, userId: string): Promise<void> {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.authorId !== userId) {
|
||||
throw new ForbiddenException('Only course author can perform this action');
|
||||
}
|
||||
}
|
||||
|
||||
async assertCourseOwnerOrStaff(courseId: string, userId: string): Promise<void> {
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.authorId === userId) {
|
||||
return;
|
||||
}
|
||||
await this.assertStaff(userId);
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/api/src/common/course-status.ts
Normal file
11
apps/api/src/common/course-status.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { CourseStatus } from '@coursecraft/database';
|
||||
|
||||
export const COURSE_PENDING_STATUSES: CourseStatus[] = [
|
||||
CourseStatus.PENDING_MODERATION,
|
||||
CourseStatus.PENDING_REVIEW, // backward compatibility
|
||||
];
|
||||
|
||||
export function isPendingModeration(status: CourseStatus): boolean {
|
||||
return COURSE_PENDING_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
19
apps/api/src/cooperation/cooperation.controller.ts
Normal file
19
apps/api/src/cooperation/cooperation.controller.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { CooperationService } from './cooperation.service';
|
||||
import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto';
|
||||
|
||||
@ApiTags('cooperation')
|
||||
@Controller('cooperation')
|
||||
export class CooperationController {
|
||||
constructor(private cooperationService: CooperationService) {}
|
||||
|
||||
@Public()
|
||||
@Post('requests')
|
||||
@ApiOperation({ summary: 'Create cooperation request from landing page' })
|
||||
async createRequest(@Body() dto: CreateCooperationRequestDto) {
|
||||
return this.cooperationService.createRequest(dto);
|
||||
}
|
||||
}
|
||||
|
||||
10
apps/api/src/cooperation/cooperation.module.ts
Normal file
10
apps/api/src/cooperation/cooperation.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CooperationController } from './cooperation.controller';
|
||||
import { CooperationService } from './cooperation.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CooperationController],
|
||||
providers: [CooperationService],
|
||||
})
|
||||
export class CooperationModule {}
|
||||
|
||||
98
apps/api/src/cooperation/cooperation.service.ts
Normal file
98
apps/api/src/cooperation/cooperation.service.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { CreateCooperationRequestDto } from './dto/create-cooperation-request.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CooperationService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async createRequest(dto: CreateCooperationRequestDto) {
|
||||
const created = await this.prisma.cooperationRequest.create({
|
||||
data: {
|
||||
organization: dto.organization,
|
||||
contactName: dto.contactName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
role: dto.role,
|
||||
organizationType: dto.organizationType,
|
||||
message: dto.message,
|
||||
source: 'landing',
|
||||
status: 'new',
|
||||
},
|
||||
});
|
||||
|
||||
const emailError = await this.trySendEmail(created).catch((error) => error as Error);
|
||||
if (emailError) {
|
||||
await this.prisma.cooperationRequest.update({
|
||||
where: { id: created.id },
|
||||
data: {
|
||||
status: 'stored_email_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
status: emailError ? 'stored_email_failed' : 'stored_and_sent',
|
||||
};
|
||||
}
|
||||
|
||||
private async trySendEmail(request: {
|
||||
id: string;
|
||||
organization: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
role: string | null;
|
||||
organizationType: string | null;
|
||||
message: string;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
const host = this.config.get<string>('SMTP_HOST');
|
||||
const portRaw = this.config.get<string>('SMTP_PORT');
|
||||
const user = this.config.get<string>('SMTP_USER');
|
||||
const pass = this.config.get<string>('SMTP_PASS');
|
||||
const secureRaw = this.config.get<string>('SMTP_SECURE');
|
||||
const to = this.config.get<string>('COOPERATION_EMAIL_TO') || 'exbytestudios@gmail.com';
|
||||
const from = this.config.get<string>('COOPERATION_EMAIL_FROM') || user;
|
||||
|
||||
if (!host || !portRaw || !user || !pass || !from) {
|
||||
throw new Error('SMTP is not configured');
|
||||
}
|
||||
|
||||
const nodemailer = await import('nodemailer');
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: Number(portRaw),
|
||||
secure: String(secureRaw || '').toLowerCase() === 'true',
|
||||
auth: { user, pass },
|
||||
});
|
||||
|
||||
const text = [
|
||||
'Новая заявка на сотрудничество (CourseCraft)',
|
||||
`ID: ${request.id}`,
|
||||
`Организация: ${request.organization}`,
|
||||
`Контакт: ${request.contactName}`,
|
||||
`Email: ${request.email}`,
|
||||
`Телефон: ${request.phone || '—'}`,
|
||||
`Роль: ${request.role || '—'}`,
|
||||
`Тип организации: ${request.organizationType || '—'}`,
|
||||
`Создано: ${request.createdAt.toISOString()}`,
|
||||
'',
|
||||
'Сообщение:',
|
||||
request.message,
|
||||
].join('\n');
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: `[CourseCraft] Cooperation request: ${request.organization}`,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateCooperationRequestDto {
|
||||
@ApiProperty({ example: 'Tech College #17' })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(160)
|
||||
organization: string;
|
||||
|
||||
@ApiProperty({ example: 'Иван Петров' })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
contactName: string;
|
||||
|
||||
@ApiProperty({ example: 'partner@example.edu' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+7 900 000-00-00' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Руководитель цифрового обучения' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
role?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'college' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
organizationType?: string;
|
||||
|
||||
@ApiProperty({ example: 'Хотим пилот на 300 студентов и интеграцию с LMS.' })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
}
|
||||
|
||||
38
apps/api/src/courses/course-sources.controller.ts
Normal file
38
apps/api/src/courses/course-sources.controller.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
import { CourseSourcesService } from './course-sources.service';
|
||||
|
||||
@ApiTags('course-sources')
|
||||
@Controller('courses/:courseId/sources')
|
||||
@ApiBearerAuth()
|
||||
export class CourseSourcesController {
|
||||
constructor(private sourcesService: CourseSourcesService) {}
|
||||
|
||||
@Post('upload')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 25 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async upload(
|
||||
@Param('courseId') courseId: string,
|
||||
@UploadedFile() file: any,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.sourcesService.uploadSource(courseId, user.id, file);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.sourcesService.getSources(courseId, user.id);
|
||||
}
|
||||
|
||||
@Get('outline-hints')
|
||||
async getOutlineHints(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.sourcesService.buildOutlineHints(courseId, user.id);
|
||||
}
|
||||
}
|
||||
158
apps/api/src/courses/course-sources.service.ts
Normal file
158
apps/api/src/courses/course-sources.service.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CourseSourceParseStatus, CourseSourceType } from '@coursecraft/database';
|
||||
import { basename, extname, join } from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import pdfParse from 'pdf-parse';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
|
||||
@Injectable()
|
||||
export class CourseSourcesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async uploadSource(courseId: string, userId: string, file: any): Promise<any> {
|
||||
if (!file) {
|
||||
throw new NotFoundException('File is required');
|
||||
}
|
||||
|
||||
await this.access.assertCourseOwner(courseId, userId);
|
||||
|
||||
const sourceType = this.resolveSourceType(file.originalname, file.mimetype);
|
||||
const storageDir = join('/tmp', 'coursecraft_uploads', courseId);
|
||||
await fs.mkdir(storageDir, { recursive: true });
|
||||
const timestamp = Date.now();
|
||||
const safeName = `${timestamp}-${basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const storagePath = join(storageDir, safeName);
|
||||
|
||||
await fs.writeFile(storagePath, file.buffer);
|
||||
|
||||
let parseStatus: CourseSourceParseStatus = CourseSourceParseStatus.SKIPPED;
|
||||
let extractedText: string | null = null;
|
||||
let extractedMeta: any = null;
|
||||
|
||||
try {
|
||||
if (sourceType === CourseSourceType.TXT) {
|
||||
extractedText = file.buffer.toString('utf8');
|
||||
parseStatus = CourseSourceParseStatus.PARSED;
|
||||
extractedMeta = { method: 'utf8', chars: extractedText?.length || 0 };
|
||||
} else if (sourceType === CourseSourceType.PDF) {
|
||||
const parsed = await pdfParse(file.buffer);
|
||||
extractedText = (parsed.text || '').trim();
|
||||
parseStatus = CourseSourceParseStatus.PARSED;
|
||||
extractedMeta = {
|
||||
method: 'pdf-parse',
|
||||
pages: parsed.numpages || null,
|
||||
chars: extractedText?.length || 0,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
parseStatus = CourseSourceParseStatus.FAILED;
|
||||
extractedMeta = {
|
||||
method: sourceType === CourseSourceType.PDF ? 'pdf-parse' : 'unknown',
|
||||
error: error?.message || 'Parse failed',
|
||||
};
|
||||
}
|
||||
|
||||
const created = await this.prisma.courseSourceFile.create({
|
||||
data: {
|
||||
courseId,
|
||||
uploadedById: userId,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
fileSize: file.size || file.buffer.length,
|
||||
sourceType,
|
||||
storagePath,
|
||||
parseStatus,
|
||||
extractedText,
|
||||
extractedMeta,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
fileSize: true,
|
||||
sourceType: true,
|
||||
parseStatus: true,
|
||||
extractedMeta: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...created,
|
||||
extractedPreview:
|
||||
parseStatus === CourseSourceParseStatus.PARSED && extractedText
|
||||
? extractedText.slice(0, 500)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getSources(courseId: string, userId: string): Promise<any> {
|
||||
await this.access.assertCourseOwnerOrStaff(courseId, userId);
|
||||
return this.prisma.courseSourceFile.findMany({
|
||||
where: { courseId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
fileSize: true,
|
||||
sourceType: true,
|
||||
parseStatus: true,
|
||||
extractedMeta: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async buildOutlineHints(courseId: string, userId: string): Promise<any> {
|
||||
await this.access.assertCourseOwnerOrStaff(courseId, userId);
|
||||
|
||||
const files = await this.prisma.courseSourceFile.findMany({
|
||||
where: { courseId, parseStatus: CourseSourceParseStatus.PARSED },
|
||||
select: { id: true, fileName: true, sourceType: true, extractedText: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const mergedText = files.map((f) => f.extractedText || '').join('\n\n').trim();
|
||||
if (!mergedText) {
|
||||
return { filesCount: files.length, hints: [], summary: null };
|
||||
}
|
||||
|
||||
const lines = mergedText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 8);
|
||||
const headingCandidates = lines
|
||||
.filter((line) => /^(\\d+[.)]\\s+|[A-ZА-ЯЁ][^.!?]{8,80}$)/.test(line))
|
||||
.slice(0, 24);
|
||||
|
||||
const hints = headingCandidates.slice(0, 8).map((line, idx) => ({
|
||||
id: `hint_${idx + 1}`,
|
||||
title: line.replace(/^\\d+[.)]\\s+/, ''),
|
||||
}));
|
||||
|
||||
return {
|
||||
filesCount: files.length,
|
||||
summary: `Найдено ${files.length} источников с текстом. Сформированы рекомендации по структуре.`,
|
||||
hints,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSourceType(fileName: string, mimeType: string): CourseSourceType {
|
||||
const ext = extname(fileName).toLowerCase();
|
||||
if (mimeType.startsWith('image/') || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'].includes(ext)) {
|
||||
return CourseSourceType.IMAGE;
|
||||
}
|
||||
if (ext === '.pdf' || mimeType.includes('pdf')) return CourseSourceType.PDF;
|
||||
if (ext === '.docx') return CourseSourceType.DOCX;
|
||||
if (ext === '.txt' || mimeType.includes('text/plain')) return CourseSourceType.TXT;
|
||||
if (ext === '.pptx') return CourseSourceType.PPTX;
|
||||
if (ext === '.zip' || mimeType.includes('zip')) return CourseSourceType.ZIP;
|
||||
return CourseSourceType.OTHER;
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,14 @@ import { ChaptersController } from './chapters.controller';
|
||||
import { ChaptersService } from './chapters.service';
|
||||
import { LessonsController } from './lessons.controller';
|
||||
import { LessonsService } from './lessons.service';
|
||||
import { CourseSourcesController } from './course-sources.controller';
|
||||
import { CourseSourcesService } from './course-sources.service';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
controllers: [CoursesController, ChaptersController, LessonsController],
|
||||
providers: [CoursesService, ChaptersService, LessonsService],
|
||||
exports: [CoursesService, ChaptersService, LessonsService],
|
||||
imports: [AccessModule],
|
||||
controllers: [CoursesController, ChaptersController, LessonsController, CourseSourcesController],
|
||||
providers: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
|
||||
exports: [CoursesService, ChaptersService, LessonsService, CourseSourcesService],
|
||||
})
|
||||
export class CoursesModule {}
|
||||
|
||||
@ -12,7 +12,7 @@ export class CoursesService {
|
||||
async create(authorId: string, dto: CreateCourseDto): Promise<Course> {
|
||||
const slug = generateUniqueSlug(dto.title);
|
||||
|
||||
return this.prisma.course.create({
|
||||
const created = await this.prisma.course.create({
|
||||
data: {
|
||||
authorId,
|
||||
title: dto.title,
|
||||
@ -36,6 +36,18 @@ export class CoursesService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId: created.id,
|
||||
fromStatus: null,
|
||||
toStatus: CourseStatus.DRAFT,
|
||||
changedById: authorId,
|
||||
note: 'Course created',
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findAllByAuthor(
|
||||
@ -222,16 +234,28 @@ export class CoursesService {
|
||||
throw new ForbiddenException('You can only edit your own courses');
|
||||
}
|
||||
|
||||
if (status === CourseStatus.PUBLISHED) {
|
||||
if (status === CourseStatus.PUBLISHED || status === CourseStatus.APPROVED) {
|
||||
throw new ForbiddenException('Course can be published only by moderation');
|
||||
}
|
||||
|
||||
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status };
|
||||
const nextStatus = status === CourseStatus.PENDING_REVIEW ? CourseStatus.PENDING_MODERATION : status;
|
||||
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status: nextStatus };
|
||||
|
||||
return this.prisma.course.update({
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId: id,
|
||||
fromStatus: course.status,
|
||||
toStatus: nextStatus,
|
||||
changedById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async checkOwnership(courseId: string, userId: string): Promise<boolean> {
|
||||
|
||||
@ -70,4 +70,15 @@ export class LessonsController {
|
||||
async generateQuiz(@Param('lessonId') lessonId: string): Promise<any> {
|
||||
return this.lessonsService.generateQuiz(lessonId);
|
||||
}
|
||||
|
||||
@Post('lessons/:lessonId/homework/generate')
|
||||
@ApiOperation({ summary: 'Generate homework task for lesson (author only)' })
|
||||
async generateHomework(
|
||||
@Param('courseId') courseId: string,
|
||||
@Param('lessonId') lessonId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body('type') type?: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB'
|
||||
): Promise<any> {
|
||||
return this.lessonsService.generateHomework(courseId, lessonId, user.id, type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { Lesson } from '@coursecraft/database';
|
||||
import { HomeworkType, Lesson } from '@coursecraft/database';
|
||||
import { CoursesService } from './courses.service';
|
||||
import { ChaptersService } from './chapters.service';
|
||||
import { CreateLessonDto } from './dto/create-lesson.dto';
|
||||
@ -268,4 +268,92 @@ ${textContent.slice(0, 3000)}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async generateHomework(
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
userId: string,
|
||||
type: 'TEXT' | 'FILE' | 'PROJECT' | 'QUIZ' | 'GITHUB' = 'TEXT'
|
||||
): Promise<any> {
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
chapter: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
courseId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!lesson || lesson.chapter.courseId !== courseId) {
|
||||
throw new NotFoundException('Lesson not found');
|
||||
}
|
||||
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('Only course author can generate homework');
|
||||
}
|
||||
|
||||
const homeworkType = HomeworkType[type] ? (type as HomeworkType) : HomeworkType.TEXT;
|
||||
const template = this.buildHomeworkTemplate(lesson.title, homeworkType);
|
||||
|
||||
return this.prisma.homework.upsert({
|
||||
where: { lessonId },
|
||||
create: {
|
||||
lessonId,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
type: homeworkType,
|
||||
config: template.config as any,
|
||||
},
|
||||
update: {
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
type: homeworkType,
|
||||
config: template.config as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private buildHomeworkTemplate(lessonTitle: string, type: HomeworkType): {
|
||||
title: string;
|
||||
description: string;
|
||||
config: Record<string, unknown>;
|
||||
} {
|
||||
if (type === HomeworkType.FILE) {
|
||||
return {
|
||||
title: `Практическая работа (файл): ${lessonTitle}`,
|
||||
description: 'Подготовьте файл с выполненным заданием и приложите ссылку/файл в ответе.',
|
||||
config: { acceptedFormats: ['pdf', 'docx', 'txt', 'zip'] },
|
||||
};
|
||||
}
|
||||
if (type === HomeworkType.PROJECT) {
|
||||
return {
|
||||
title: `Мини-проект: ${lessonTitle}`,
|
||||
description: 'Сделайте небольшой проект по материалу урока. Опишите архитектуру и результат.',
|
||||
config: { rubric: ['Понимание темы', 'Практичность', 'Качество решения'] },
|
||||
};
|
||||
}
|
||||
if (type === HomeworkType.GITHUB) {
|
||||
return {
|
||||
title: `GitHub задача: ${lessonTitle}`,
|
||||
description: 'Выполните задачу и приложите ссылку на публичный GitHub-репозиторий.',
|
||||
config: { requireGithubUrl: true },
|
||||
};
|
||||
}
|
||||
if (type === HomeworkType.QUIZ) {
|
||||
return {
|
||||
title: `Тест-кейс: ${lessonTitle}`,
|
||||
description: 'Ответьте на контрольные вопросы и приложите обоснование решений.',
|
||||
config: { requireExplanation: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: `Письменное домашнее задание: ${lessonTitle}`,
|
||||
description:
|
||||
'Опишите, как вы примените изученную тему на практике. Приведите примеры, обоснования и собственные выводы.',
|
||||
config: { minLength: 200 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { HomeworkType } from '@coursecraft/database';
|
||||
import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SubmitHomeworkDto {
|
||||
@ApiProperty({
|
||||
description: 'Written homework answer',
|
||||
minLength: 50,
|
||||
minLength: 1,
|
||||
maxLength: 20000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(50)
|
||||
@MinLength(1)
|
||||
@MaxLength(20000)
|
||||
content: string;
|
||||
content?: string;
|
||||
|
||||
@ApiProperty({ enum: HomeworkType, default: HomeworkType.TEXT })
|
||||
@IsOptional()
|
||||
@IsEnum(HomeworkType)
|
||||
type?: HomeworkType;
|
||||
|
||||
@ApiProperty({ required: false, description: 'File URL for FILE/PROJECT submission' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
attachmentUrl?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'GitHub repository URL' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
githubUrl?: string;
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ export class EnrollmentController {
|
||||
@Body() dto: SubmitHomeworkDto,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto.content);
|
||||
return this.enrollmentService.submitHomework(user.id, courseId, lessonId, dto);
|
||||
}
|
||||
|
||||
@Post(':courseId/review')
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { HomeworkReviewStatus } from '@coursecraft/database';
|
||||
import { HomeworkReviewStatus, HomeworkType } from '@coursecraft/database';
|
||||
|
||||
const QUIZ_PASS_THRESHOLD = 70;
|
||||
|
||||
@ -172,7 +172,12 @@ export class EnrollmentService {
|
||||
return { homework, submission };
|
||||
}
|
||||
|
||||
async submitHomework(userId: string, courseId: string, lessonId: string, content: string): Promise<any> {
|
||||
async submitHomework(
|
||||
userId: string,
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
dto: { content?: string; type?: HomeworkType; attachmentUrl?: string; githubUrl?: string }
|
||||
): Promise<any> {
|
||||
const enrollment = await this.requireEnrollment(userId, courseId);
|
||||
await this.assertLessonUnlocked(userId, courseId, lessonId);
|
||||
|
||||
@ -184,20 +189,36 @@ export class EnrollmentService {
|
||||
}
|
||||
|
||||
const { homework } = await this.getHomework(userId, courseId, lessonId);
|
||||
const aiResult = this.gradeHomeworkWithAI(content);
|
||||
const submissionType = dto.type || homework.type || HomeworkType.TEXT;
|
||||
const normalizedContent = (dto.content || '').trim();
|
||||
if (!normalizedContent && !dto.attachmentUrl && !dto.githubUrl) {
|
||||
throw new BadRequestException('Provide content, attachment URL, or GitHub URL');
|
||||
}
|
||||
const fallbackContent =
|
||||
normalizedContent ||
|
||||
dto.githubUrl ||
|
||||
dto.attachmentUrl ||
|
||||
`Submission type: ${submissionType}`;
|
||||
const aiResult = this.gradeHomeworkWithAI(fallbackContent);
|
||||
|
||||
const submission = await this.prisma.homeworkSubmission.upsert({
|
||||
where: { homeworkId_userId: { homeworkId: homework.id, userId } },
|
||||
create: {
|
||||
homeworkId: homework.id,
|
||||
userId,
|
||||
content,
|
||||
content: fallbackContent,
|
||||
answerType: submissionType,
|
||||
attachmentUrl: dto.attachmentUrl || null,
|
||||
githubUrl: dto.githubUrl || null,
|
||||
aiScore: aiResult.score,
|
||||
aiFeedback: aiResult.feedback,
|
||||
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
|
||||
},
|
||||
update: {
|
||||
content,
|
||||
content: fallbackContent,
|
||||
answerType: submissionType,
|
||||
attachmentUrl: dto.attachmentUrl || null,
|
||||
githubUrl: dto.githubUrl || null,
|
||||
aiScore: aiResult.score,
|
||||
aiFeedback: aiResult.feedback,
|
||||
reviewStatus: HomeworkReviewStatus.AI_REVIEWED,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Param, Body, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
@ -31,13 +31,21 @@ export class GroupsController {
|
||||
}
|
||||
|
||||
@Get(':groupId/messages')
|
||||
async getMessages(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.getGroupMessages(groupId, user.id);
|
||||
async getMessages(
|
||||
@Param('groupId') groupId: string,
|
||||
@Query('lessonId') lessonId: string | undefined,
|
||||
@CurrentUser() user: User
|
||||
): Promise<any> {
|
||||
return this.groupsService.getGroupMessages(groupId, user.id, lessonId);
|
||||
}
|
||||
|
||||
@Post(':groupId/messages')
|
||||
async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.groupsService.sendMessage(groupId, user.id, content);
|
||||
async sendMessage(
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() body: { content: string; lessonId?: string },
|
||||
@CurrentUser() user: User
|
||||
): Promise<any> {
|
||||
return this.groupsService.sendMessage(groupId, user.id, body.content, body.lessonId);
|
||||
}
|
||||
|
||||
@Post(':groupId/invite-link')
|
||||
|
||||
@ -65,12 +65,12 @@ export class GroupsGateway implements OnGatewayConnection {
|
||||
@SubscribeMessage('groups:send')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { groupId: string; content: string }
|
||||
@MessageBody() body: { groupId: string; content: string; lessonId?: string }
|
||||
) {
|
||||
const user = client.data.user;
|
||||
if (!user || !body?.groupId || !body?.content?.trim()) return { ok: false };
|
||||
|
||||
const message = await this.groupsService.sendMessage(body.groupId, user.id, body.content.trim());
|
||||
const message = await this.groupsService.sendMessage(body.groupId, user.id, body.content.trim(), body.lessonId);
|
||||
this.server.to(this.room(body.groupId)).emit('groups:new-message', message);
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ import { GroupsController } from './groups.controller';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { GroupsGateway } from './groups.gateway';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
imports: [UsersModule, AccessModule],
|
||||
controllers: [GroupsController],
|
||||
providers: [GroupsService, GroupsGateway],
|
||||
exports: [GroupsService],
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async createGroup(courseId: string, userId: string, name: string, description?: string): Promise<any> {
|
||||
const course = await this.prisma.course.findFirst({ where: { id: courseId, authorId: userId } });
|
||||
if (!course) throw new ForbiddenException('Only course author can create groups');
|
||||
await this.access.assertCourseOwner(courseId, userId);
|
||||
|
||||
return this.prisma.courseGroup.create({
|
||||
data: { courseId, name, description },
|
||||
@ -71,22 +74,25 @@ export class GroupsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getGroupMessages(groupId: string, userId: string): Promise<any> {
|
||||
async getGroupMessages(groupId: string, userId: string, lessonId?: string): Promise<any> {
|
||||
await this.assertCanReadGroup(groupId, userId);
|
||||
|
||||
return this.prisma.groupMessage.findMany({
|
||||
where: { groupId },
|
||||
where: {
|
||||
groupId,
|
||||
...(lessonId ? { lessonId } : {}),
|
||||
},
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(groupId: string, userId: string, content: string): Promise<any> {
|
||||
async sendMessage(groupId: string, userId: string, content: string, lessonId?: string): Promise<any> {
|
||||
await this.assertCanReadGroup(groupId, userId);
|
||||
|
||||
return this.prisma.groupMessage.create({
|
||||
data: { groupId, userId, content },
|
||||
data: { groupId, userId, content, lessonId: lessonId || null },
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||
});
|
||||
}
|
||||
@ -97,9 +103,7 @@ export class GroupsService {
|
||||
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');
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -49,4 +49,18 @@ export class ModerationController {
|
||||
async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<void> {
|
||||
await this.moderationService.deleteCourse(user.id, courseId);
|
||||
}
|
||||
|
||||
@Get(':courseId/preview')
|
||||
async previewCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||
return this.moderationService.previewCourse(user.id, courseId);
|
||||
}
|
||||
|
||||
@Post(':courseId/quiz-preview')
|
||||
async previewQuiz(
|
||||
@Param('courseId') courseId: string,
|
||||
@Body() body: { lessonId: string; answers?: number[] },
|
||||
@CurrentUser() user: User,
|
||||
): Promise<any> {
|
||||
return this.moderationService.previewQuiz(user.id, courseId, body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModerationController } from './moderation.controller';
|
||||
import { ModerationService } from './moderation.service';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccessModule],
|
||||
controllers: [ModerationController],
|
||||
providers: [ModerationService],
|
||||
exports: [ModerationService],
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { CourseStatus } from '@coursecraft/database';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { CourseStatus, UserRole } from '@coursecraft/database';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
import { COURSE_PENDING_STATUSES, isPendingModeration } from '../common/course-status';
|
||||
|
||||
@Injectable()
|
||||
export class ModerationService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async getPendingCourses(userId: string): Promise<any> {
|
||||
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');
|
||||
}
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
return this.prisma.course.findMany({
|
||||
where: { status: CourseStatus.PENDING_REVIEW },
|
||||
where: { status: { in: COURSE_PENDING_STATUSES } },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { chapters: true } },
|
||||
@ -29,21 +31,27 @@ export class ModerationService {
|
||||
search?: string;
|
||||
}
|
||||
): Promise<any[]> {
|
||||
await this.assertStaff(userId);
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const allowedStatuses = Object.values(CourseStatus);
|
||||
const where: any = {};
|
||||
|
||||
if (options?.status && allowedStatuses.includes(options.status as CourseStatus)) {
|
||||
where.status = options.status as CourseStatus;
|
||||
const inputStatus = options.status as CourseStatus;
|
||||
if (inputStatus === CourseStatus.PENDING_MODERATION) {
|
||||
where.status = { in: COURSE_PENDING_STATUSES };
|
||||
} else {
|
||||
where.status = inputStatus;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.search?.trim()) {
|
||||
const term = options.search.trim();
|
||||
where.OR = [
|
||||
{ title: { contains: options.search.trim(), mode: 'insensitive' } },
|
||||
{ description: { contains: options.search.trim(), mode: 'insensitive' } },
|
||||
{ author: { name: { contains: options.search.trim(), mode: 'insensitive' } } },
|
||||
{ author: { email: { contains: options.search.trim(), mode: 'insensitive' } } },
|
||||
{ title: { contains: term, mode: 'insensitive' } },
|
||||
{ description: { contains: term, mode: 'insensitive' } },
|
||||
{ author: { name: { contains: term, mode: 'insensitive' } } },
|
||||
{ author: { email: { contains: term, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
@ -65,52 +73,69 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> {
|
||||
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');
|
||||
}
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { status: true },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new ForbiddenException('Course not found');
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.status !== CourseStatus.PENDING_REVIEW) {
|
||||
throw new ForbiddenException('Only courses pending review can be approved');
|
||||
if (!isPendingModeration(course.status)) {
|
||||
throw new ForbiddenException('Only courses pending moderation can be approved');
|
||||
}
|
||||
|
||||
return this.prisma.course.update({
|
||||
const now = new Date();
|
||||
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
status: CourseStatus.PUBLISHED,
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
publishedAt: now,
|
||||
moderationNote: note,
|
||||
moderatedAt: new Date(),
|
||||
moderatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.createMany({
|
||||
data: [
|
||||
{
|
||||
courseId,
|
||||
fromStatus: course.status,
|
||||
toStatus: CourseStatus.APPROVED,
|
||||
note: note || 'Approved by moderation',
|
||||
changedById: userId,
|
||||
},
|
||||
{
|
||||
courseId,
|
||||
fromStatus: CourseStatus.APPROVED,
|
||||
toStatus: CourseStatus.PUBLISHED,
|
||||
note: 'Auto publish after approve',
|
||||
changedById: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async rejectCourse(userId: string, courseId: string, reason: string): Promise<any> {
|
||||
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');
|
||||
}
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { status: true },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw new ForbiddenException('Course not found');
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (course.status !== CourseStatus.PENDING_REVIEW) {
|
||||
throw new ForbiddenException('Only courses pending review can be rejected');
|
||||
if (!isPendingModeration(course.status)) {
|
||||
throw new ForbiddenException('Only courses pending moderation can be rejected');
|
||||
}
|
||||
|
||||
return this.prisma.course.update({
|
||||
const updated = await this.prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
status: CourseStatus.REJECTED,
|
||||
@ -118,10 +143,97 @@ export class ModerationService {
|
||||
moderatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.courseStatusHistory.create({
|
||||
data: {
|
||||
courseId,
|
||||
fromStatus: course.status,
|
||||
toStatus: CourseStatus.REJECTED,
|
||||
note: reason,
|
||||
changedById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async previewCourse(userId: string, courseId: string): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true } },
|
||||
chapters: {
|
||||
include: {
|
||||
lessons: {
|
||||
include: {
|
||||
quiz: true,
|
||||
homework: true,
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
|
||||
if (!isPendingModeration(course.status) && course.status !== CourseStatus.REJECTED) {
|
||||
throw new ForbiddenException('Preview is available only for moderation flow courses');
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
async previewQuiz(
|
||||
userId: string,
|
||||
courseId: string,
|
||||
dto: { lessonId: string; answers?: number[] }
|
||||
): Promise<any> {
|
||||
await this.access.assertStaff(userId);
|
||||
|
||||
const lesson = await this.prisma.lesson.findFirst({
|
||||
where: { id: dto.lessonId, chapter: { courseId } },
|
||||
include: { quiz: true },
|
||||
});
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('Lesson not found in this course');
|
||||
}
|
||||
if (!lesson.quiz) {
|
||||
return { questions: [], score: null, passed: null, preview: true };
|
||||
}
|
||||
|
||||
const questions = Array.isArray(lesson.quiz.questions) ? (lesson.quiz.questions as any[]) : [];
|
||||
if (!dto.answers) {
|
||||
return { questions, score: null, passed: null, preview: true };
|
||||
}
|
||||
|
||||
const correct = questions.reduce((acc, question, idx) => {
|
||||
const expected = Number(question.correctAnswer);
|
||||
const actual = Number(dto.answers?.[idx]);
|
||||
return acc + (expected === actual ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const total = questions.length || 1;
|
||||
const score = Math.round((correct / total) * 100);
|
||||
|
||||
return {
|
||||
questions,
|
||||
score,
|
||||
passed: score >= 70,
|
||||
totalQuestions: questions.length,
|
||||
correctAnswers: correct,
|
||||
preview: true,
|
||||
};
|
||||
}
|
||||
|
||||
async hideReview(userId: string, reviewId: string): Promise<any> {
|
||||
await this.assertStaff(userId);
|
||||
await this.access.assertStaff(userId);
|
||||
const review = await this.prisma.review.update({
|
||||
where: { id: reviewId },
|
||||
data: { isApproved: false },
|
||||
@ -131,7 +243,7 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
async unhideReview(userId: string, reviewId: string): Promise<any> {
|
||||
await this.assertStaff(userId);
|
||||
await this.access.assertStaff(userId);
|
||||
const review = await this.prisma.review.update({
|
||||
where: { id: reviewId },
|
||||
data: { isApproved: true },
|
||||
@ -141,7 +253,7 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
async deleteCourse(userId: string, courseId: string): Promise<void> {
|
||||
await this.assertStaff(userId);
|
||||
await this.access.assertStaff(userId);
|
||||
const existing = await this.prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { id: true },
|
||||
@ -153,13 +265,6 @@ export class ModerationService {
|
||||
await this.prisma.course.delete({ where: { id: courseId } });
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
22
apps/api/src/payments/dev-payments.controller.ts
Normal file
22
apps/api/src/payments/dev-payments.controller.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller('payments')
|
||||
@ApiBearerAuth()
|
||||
export class DevPaymentsController {
|
||||
constructor(private paymentsService: PaymentsService) {}
|
||||
|
||||
@Post('dev/yoomoney/complete')
|
||||
@ApiOperation({ summary: 'Complete DEV YooMoney payment (mock flow)' })
|
||||
async completeDevYoomoneyPayment(
|
||||
@CurrentUser() user: User,
|
||||
@Body('courseId') courseId: string,
|
||||
) {
|
||||
return this.paymentsService.completeDevYoomoneyPayment(user.id, courseId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { StripeService } from './stripe.service';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { DevPaymentsController } from './dev-payments.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PaymentsController, WebhooksController],
|
||||
controllers: [PaymentsController, WebhooksController, DevPaymentsController],
|
||||
providers: [PaymentsService, StripeService],
|
||||
exports: [PaymentsService, StripeService],
|
||||
})
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { StripeService } from './stripe.service';
|
||||
import { SubscriptionTier } from '@coursecraft/database';
|
||||
import { PaymentMode, PaymentProvider, SubscriptionTier } from '@coursecraft/database';
|
||||
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
|
||||
|
||||
@Injectable()
|
||||
@ -78,6 +78,9 @@ export class PaymentsService {
|
||||
if (!course) {
|
||||
throw new NotFoundException('Course not found');
|
||||
}
|
||||
if (!course.isPublished) {
|
||||
throw new ForbiddenException('Course is not available for purchase');
|
||||
}
|
||||
if (!course.price) {
|
||||
throw new Error('Course is free, checkout is not required');
|
||||
}
|
||||
@ -85,8 +88,26 @@ export class PaymentsService {
|
||||
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 paymentMode = this.getPaymentMode();
|
||||
|
||||
if (paymentMode === PaymentMode.DEV) {
|
||||
await this.handleCoursePurchaseCompleted({
|
||||
userId,
|
||||
courseId,
|
||||
provider: PaymentProvider.YOOMONEY,
|
||||
mode: PaymentMode.DEV,
|
||||
eventCode: 'DEV_PAYMENT_SUCCESS',
|
||||
});
|
||||
console.log('DEV_PAYMENT_SUCCESS', { userId, courseId, provider: 'YOOMONEY' });
|
||||
return {
|
||||
url: `${appUrl}/courses/${courseId}?purchase=success&devPayment=1`,
|
||||
mode: 'DEV',
|
||||
provider: 'YOOMONEY',
|
||||
};
|
||||
}
|
||||
|
||||
const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
|
||||
const unitAmount = Math.round(Number(course.price) * 100);
|
||||
|
||||
const session = await this.stripeService.createOneTimeCheckoutSession({
|
||||
@ -104,7 +125,23 @@ export class PaymentsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { url: session.url };
|
||||
return { url: session.url, mode: 'PROD', provider: 'STRIPE' };
|
||||
}
|
||||
|
||||
async completeDevYoomoneyPayment(userId: string, courseId: string) {
|
||||
await this.handleCoursePurchaseCompleted({
|
||||
userId,
|
||||
courseId,
|
||||
provider: PaymentProvider.YOOMONEY,
|
||||
mode: PaymentMode.DEV,
|
||||
eventCode: 'DEV_PAYMENT_SUCCESS',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
eventCode: 'DEV_PAYMENT_SUCCESS',
|
||||
provider: 'YOOMONEY',
|
||||
mode: 'DEV',
|
||||
};
|
||||
}
|
||||
|
||||
async createPortalSession(userId: string) {
|
||||
@ -169,6 +206,9 @@ export class PaymentsService {
|
||||
await this.handleCoursePurchaseCompleted({
|
||||
userId: metadata.userId,
|
||||
courseId: metadata.courseId || '',
|
||||
provider: PaymentProvider.STRIPE,
|
||||
mode: PaymentMode.PROD,
|
||||
eventCode: 'STRIPE_PAYMENT_SUCCESS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -201,7 +241,13 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCoursePurchaseCompleted(params: { userId: string; courseId: string }) {
|
||||
private async handleCoursePurchaseCompleted(params: {
|
||||
userId: string;
|
||||
courseId: string;
|
||||
provider?: PaymentProvider;
|
||||
mode?: PaymentMode;
|
||||
eventCode?: string;
|
||||
}) {
|
||||
const { userId, courseId } = params;
|
||||
if (!courseId) return;
|
||||
|
||||
@ -219,11 +265,27 @@ export class PaymentsService {
|
||||
amount: course.price,
|
||||
currency: course.currency,
|
||||
status: 'completed',
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
eventCode: params.eventCode || null,
|
||||
metadata: {
|
||||
eventCode: params.eventCode || null,
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
status: 'completed',
|
||||
amount: course.price,
|
||||
currency: course.currency,
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
eventCode: params.eventCode || null,
|
||||
metadata: {
|
||||
eventCode: params.eventCode || null,
|
||||
provider: params.provider || PaymentProvider.STRIPE,
|
||||
mode: params.mode || PaymentMode.PROD,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -241,6 +303,11 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
private getPaymentMode(): PaymentMode {
|
||||
const raw = (this.configService.get<string>('PAYMENT_MODE') || 'PROD').toUpperCase();
|
||||
return raw === PaymentMode.DEV ? PaymentMode.DEV : PaymentMode.PROD;
|
||||
}
|
||||
|
||||
private async getOrCreateStripeCustomer(userId: string): Promise<{ stripeCustomerId: string; email: string; name: string | null }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
|
||||
@ -3,9 +3,10 @@ import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AccessModule } from '../common/access/access.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
imports: [UsersModule, AccessModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportGateway],
|
||||
exports: [SupportService],
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { UserRole } from '@coursecraft/database';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { AccessService } from '../common/access/access.service';
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private access: AccessService,
|
||||
) {}
|
||||
|
||||
async createTicket(
|
||||
userId: string,
|
||||
@ -150,12 +154,6 @@ export class SupportService {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
await this.access.assertStaff(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../common/prisma/prisma.service';
|
||||
import { User, UserSettings, SubscriptionTier } from '@coursecraft/database';
|
||||
import { User, UserRole, UserSettings, SubscriptionTier } from '@coursecraft/database';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UpdateSettingsDto } from './dto/update-settings.dto';
|
||||
@ -147,4 +147,42 @@ export class UsersService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listUsers(options?: { search?: string; role?: UserRole; limit?: number }) {
|
||||
const limit = Math.min(200, Math.max(1, options?.limit || 100));
|
||||
const where: any = {};
|
||||
if (options?.role) {
|
||||
where.role = options.role;
|
||||
}
|
||||
if (options?.search?.trim()) {
|
||||
const term = options.search.trim();
|
||||
where.OR = [
|
||||
{ email: { contains: term, mode: 'insensitive' } },
|
||||
{ name: { contains: term, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
return this.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
subscriptionTier: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async updateRole(userId: string, role: UserRole): Promise<User> {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
include: { settings: true, subscription: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user