feat: add certificates, groups, support system, and moderation

Backend changes:
- Add Certificate generation service with beautiful HTML templates
- Add CourseGroup, GroupMember, GroupMessage models for group collaboration
- Add Homework and HomeworkSubmission models with AI + teacher grading
- Add SupportTicket and TicketMessage models for help desk
- Add Moderation API for admin/moderator course approval workflow
- All new modules: CertificatesModule, GroupsModule, SupportModule, ModerationModule

Frontend changes:
- Add certificate download button when course completed
- Update course page to load enrollment progress from backend
- Integrate lesson completion with backend API

Database schema now supports:
- Course groups with chat functionality
- Homework assignments with dual AI/human grading
- Support ticket system with admin responses
- Full moderation workflow (PENDING_REVIEW -> PUBLISHED/REJECTED)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-06 10:50:04 +00:00
parent 2ed65f5678
commit 5ddb3db1ac
16 changed files with 605 additions and 1 deletions

View File

@ -7,6 +7,10 @@ import { UsersModule } from './users/users.module';
import { CoursesModule } from './courses/courses.module';
import { CatalogModule } from './catalog/catalog.module';
import { EnrollmentModule } from './enrollment/enrollment.module';
import { CertificatesModule } from './certificates/certificates.module';
import { GroupsModule } from './groups/groups.module';
import { SupportModule } from './support/support.module';
import { ModerationModule } from './moderation/moderation.module';
import { GenerationModule } from './generation/generation.module';
import { PaymentsModule } from './payments/payments.module';
import { SearchModule } from './search/search.module';
@ -42,6 +46,10 @@ import { PrismaModule } from './common/prisma/prisma.module';
CoursesModule,
CatalogModule,
EnrollmentModule,
CertificatesModule,
GroupsModule,
SupportModule,
ModerationModule,
GenerationModule,
PaymentsModule,
SearchModule,

View File

@ -0,0 +1,18 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { CertificatesService } from './certificates.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
@ApiTags('certificates')
@Controller('certificates')
@ApiBearerAuth()
export class CertificatesController {
constructor(private certificatesService: CertificatesService) {}
@Get(':courseId')
@ApiOperation({ summary: 'Generate certificate for completed course' })
async getCertificate(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
return this.certificatesService.generateCertificate(user.id, courseId);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CertificatesController } from './certificates.controller';
import { CertificatesService } from './certificates.service';
@Module({
controllers: [CertificatesController],
providers: [CertificatesService],
exports: [CertificatesService],
})
export class CertificatesModule {}

View File

@ -0,0 +1,136 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
@Injectable()
export class CertificatesService {
constructor(private prisma: PrismaService) {}
async generateCertificate(userId: string, courseId: string): Promise<any> {
const enrollment = await this.prisma.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
include: { course: true, user: true },
});
if (!enrollment) throw new NotFoundException('Not enrolled');
if (!enrollment.completedAt) throw new Error('Course not completed yet');
// Generate certificate HTML (in production, render to PDF using puppeteer or similar)
const certificateHtml = this.renderCertificateHTML(
enrollment.user.name || enrollment.user.email,
enrollment.course.title,
new Date(enrollment.completedAt)
);
// In production: save to S3/R2 and return URL
// For now, return inline HTML
const certificateUrl = `data:text/html;base64,${Buffer.from(certificateHtml).toString('base64')}`;
await this.prisma.enrollment.update({
where: { id: enrollment.id },
data: { certificateUrl },
});
return { certificateUrl, html: certificateHtml };
}
private renderCertificateHTML(userName: string, courseTitle: string, completionDate: Date): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Сертификат - ${courseTitle}</title>
<style>
@page { size: A4 landscape; margin: 0; }
body {
margin: 0;
font-family: 'Georgia', serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-center;
}
.certificate {
background: white;
width: 1000px;
padding: 80px 100px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
border: 12px solid #f8f9fa;
position: relative;
}
.certificate::before {
content: '';
position: absolute;
inset: 30px;
border: 2px solid #667eea;
pointer-events: none;
}
h1 {
font-size: 48px;
text-align: center;
color: #667eea;
margin: 0 0 20px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 4px;
}
h2 {
font-size: 24px;
text-align: center;
color: #333;
margin: 0 0 40px;
font-weight: normal;
}
.recipient {
text-align: center;
font-size: 36px;
color: #1a202c;
margin: 40px 0;
font-weight: bold;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
display: inline-block;
width: 100%;
}
.course-title {
text-align: center;
font-size: 28px;
color: #4a5568;
margin: 40px 0;
}
.date {
text-align: center;
color: #718096;
margin-top: 60px;
font-size: 16px;
}
.logo {
text-align: center;
font-size: 20px;
color: #667eea;
font-weight: bold;
margin-bottom: 40px;
}
</style>
</head>
<body>
<div class="certificate">
<div class="logo">✨ CourseCraft</div>
<h1>Сертификат</h1>
<h2>о прохождении курса</h2>
<div style="text-align: center; margin: 40px 0;">
<div style="font-size: 18px; color: #718096; margin-bottom: 10px;">Настоящий сертификат подтверждает, что</div>
<div class="recipient">${userName}</div>
</div>
<div style="text-align: center; margin: 20px 0;">
<div style="font-size: 18px; color: #718096; margin-bottom: 10px;">успешно завершил(а) курс</div>
<div class="course-title">${courseTitle}</div>
</div>
<div class="date">
Дата выдачи: ${completionDate.toLocaleDateString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric' })}
</div>
</div>
</body>
</html>`;
}
}

View File

@ -0,0 +1,32 @@
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { GroupsService } from './groups.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
@ApiTags('groups')
@Controller('groups')
@ApiBearerAuth()
export class GroupsController {
constructor(private groupsService: GroupsService) {}
@Post()
async createGroup(@Body() body: { courseId: string; name: string; description?: string }, @CurrentUser() user: User): Promise<any> {
return this.groupsService.createGroup(body.courseId, user.id, body.name, body.description);
}
@Post(':groupId/members')
async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string): Promise<any> {
return this.groupsService.addMember(groupId, userId);
}
@Get(':groupId/messages')
async getMessages(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
return this.groupsService.getGroupMessages(groupId, user.id);
}
@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);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service';
@Module({
controllers: [GroupsController],
providers: [GroupsService],
exports: [GroupsService],
})
export class GroupsModule {}

View File

@ -0,0 +1,48 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
@Injectable()
export class GroupsService {
constructor(private prisma: PrismaService) {}
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');
return this.prisma.courseGroup.create({
data: { courseId, name, description },
});
}
async addMember(groupId: string, userId: string, role = 'student'): Promise<any> {
return this.prisma.groupMember.create({
data: { groupId, userId, role },
});
}
async getGroupMessages(groupId: string, userId: string): Promise<any> {
const member = await this.prisma.groupMember.findUnique({
where: { groupId_userId: { groupId, userId } },
});
if (!member) throw new ForbiddenException('Not a member of this group');
return this.prisma.groupMessage.findMany({
where: { groupId },
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'asc' },
take: 100,
});
}
async sendMessage(groupId: string, userId: string, content: string): Promise<any> {
const member = await this.prisma.groupMember.findUnique({
where: { groupId_userId: { groupId, userId } },
});
if (!member) throw new ForbiddenException('Not a member of this group');
return this.prisma.groupMessage.create({
data: { groupId, userId, content },
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
});
}
}

View File

@ -0,0 +1,27 @@
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ModerationService } from './moderation.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
@ApiTags('moderation')
@Controller('moderation')
@ApiBearerAuth()
export class ModerationController {
constructor(private moderationService: ModerationService) {}
@Get('pending')
async getPendingCourses(@CurrentUser() user: User): Promise<any> {
return this.moderationService.getPendingCourses(user.id);
}
@Post(':courseId/approve')
async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise<any> {
return this.moderationService.approveCourse(user.id, courseId, note);
}
@Post(':courseId/reject')
async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise<any> {
return this.moderationService.rejectCourse(user.id, courseId, reason);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ModerationController } from './moderation.controller';
import { ModerationService } from './moderation.service';
@Module({
controllers: [ModerationController],
providers: [ModerationService],
exports: [ModerationService],
})
export class ModerationModule {}

View File

@ -0,0 +1,58 @@
import { Injectable, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { CourseStatus, UserRole } from '@coursecraft/database';
@Injectable()
export class ModerationService {
constructor(private prisma: PrismaService) {}
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');
}
return this.prisma.course.findMany({
where: { status: CourseStatus.PENDING_REVIEW },
include: {
author: { select: { id: true, name: true, email: true } },
_count: { select: { chapters: true } },
},
orderBy: { createdAt: 'asc' },
});
}
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');
}
return this.prisma.course.update({
where: { id: courseId },
data: {
status: CourseStatus.PUBLISHED,
isPublished: true,
publishedAt: new Date(),
moderationNote: note,
moderatedAt: new Date(),
},
});
}
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');
}
return this.prisma.course.update({
where: { id: courseId },
data: {
status: CourseStatus.REJECTED,
moderationNote: reason,
moderatedAt: new Date(),
},
});
}
}

View File

@ -0,0 +1,32 @@
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { SupportService } from './support.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
@ApiTags('support')
@Controller('support')
@ApiBearerAuth()
export class SupportController {
constructor(private supportService: SupportService) {}
@Post('tickets')
async createTicket(@Body('title') title: string, @CurrentUser() user: User): Promise<any> {
return this.supportService.createTicket(user.id, title);
}
@Get('tickets')
async getMyTickets(@CurrentUser() user: User): Promise<any> {
return this.supportService.getUserTickets(user.id);
}
@Get('tickets/:id/messages')
async getMessages(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
return this.supportService.getTicketMessages(id, user.id);
}
@Post('tickets/:id/messages')
async sendMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
return this.supportService.sendMessage(id, user.id, content);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SupportController } from './support.controller';
import { SupportService } from './support.service';
@Module({
controllers: [SupportController],
providers: [SupportService],
exports: [SupportService],
})
export class SupportModule {}

View File

@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
@Injectable()
export class SupportService {
constructor(private prisma: PrismaService) {}
async createTicket(userId: string, title: string): Promise<any> {
return this.prisma.supportTicket.create({
data: { userId, title },
include: { messages: { include: { user: { select: { name: true } } } } },
});
}
async getUserTickets(userId: string): Promise<any> {
return this.prisma.supportTicket.findMany({
where: { userId },
include: { messages: { orderBy: { createdAt: 'desc' }, take: 1 } },
orderBy: { updatedAt: 'desc' },
});
}
async getTicketMessages(ticketId: string, userId: string): Promise<any> {
const ticket = await this.prisma.supportTicket.findFirst({ where: { id: ticketId, userId } });
if (!ticket) throw new Error('Ticket not found');
return this.prisma.ticketMessage.findMany({
where: { ticketId },
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'asc' },
});
}
async sendMessage(ticketId: string, userId: string, content: string): Promise<any> {
return this.prisma.ticketMessage.create({
data: { ticketId, userId, content, isStaff: false },
include: { user: { select: { id: true, name: true } } },
});
}
}