feat: phase1 platform upgrade with moderation, dev payments, admin panel and landing updates

This commit is contained in:
root
2026-02-06 17:26:53 +00:00
parent 4ca66ea896
commit 979adb9d3d
54 changed files with 2687 additions and 318 deletions

View 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 });
}
}

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

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

View File

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

View File

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

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

View 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);
}
}

View 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);
}

View 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);
}
}

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

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

View File

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

View 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);
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')

View File

@ -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,

View File

@ -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')

View File

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

View File

@ -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],

View File

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

View File

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

View File

@ -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],

View File

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

View 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);
}
}

View File

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

View File

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

View File

@ -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],

View File

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

View File

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