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:
@ -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,
|
||||
|
||||
18
apps/api/src/certificates/certificates.controller.ts
Normal file
18
apps/api/src/certificates/certificates.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/certificates/certificates.module.ts
Normal file
10
apps/api/src/certificates/certificates.module.ts
Normal 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 {}
|
||||
136
apps/api/src/certificates/certificates.service.ts
Normal file
136
apps/api/src/certificates/certificates.service.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
32
apps/api/src/groups/groups.controller.ts
Normal file
32
apps/api/src/groups/groups.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/groups/groups.module.ts
Normal file
10
apps/api/src/groups/groups.module.ts
Normal 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 {}
|
||||
48
apps/api/src/groups/groups.service.ts
Normal file
48
apps/api/src/groups/groups.service.ts
Normal 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 } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
27
apps/api/src/moderation/moderation.controller.ts
Normal file
27
apps/api/src/moderation/moderation.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/moderation/moderation.module.ts
Normal file
10
apps/api/src/moderation/moderation.module.ts
Normal 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 {}
|
||||
58
apps/api/src/moderation/moderation.service.ts
Normal file
58
apps/api/src/moderation/moderation.service.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
32
apps/api/src/support/support.controller.ts
Normal file
32
apps/api/src/support/support.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/support/support.module.ts
Normal file
10
apps/api/src/support/support.module.ts
Normal 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 {}
|
||||
40
apps/api/src/support/support.service.ts
Normal file
40
apps/api/src/support/support.service.ts
Normal 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 } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -66,6 +66,7 @@ export default function CoursePage() {
|
||||
const [enrollmentProgress, setEnrollmentProgress] = useState<any>(null);
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
const [quizQuestions, setQuizQuestions] = useState<any[]>([]);
|
||||
const [generatingCertificate, setGeneratingCertificate] = useState(false);
|
||||
|
||||
// Flat list of all lessons
|
||||
const flatLessons = useMemo(() => {
|
||||
@ -188,6 +189,19 @@ export default function CoursePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetCertificate = async () => {
|
||||
if (!id || generatingCertificate) return;
|
||||
setGeneratingCertificate(true);
|
||||
try {
|
||||
const { certificateUrl } = await api.getCertificate(id);
|
||||
window.open(certificateUrl, '_blank');
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setGeneratingCertificate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToNextLesson = () => {
|
||||
if (currentLessonIndex < flatLessons.length - 1) {
|
||||
markComplete();
|
||||
@ -506,8 +520,13 @@ export default function CoursePage() {
|
||||
Следующий урок
|
||||
<ChevronRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
) : completedCount >= totalLessons ? (
|
||||
<Button size="sm" onClick={handleGetCertificate} disabled={generatingCertificate}>
|
||||
<GraduationCap className="mr-1.5 h-4 w-4" />
|
||||
{generatingCertificate ? 'Генерация...' : 'Получить сертификат'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" disabled={completedCount < totalLessons}>
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<GraduationCap className="mr-1.5 h-4 w-4" />
|
||||
Завершить курс
|
||||
</Button>
|
||||
|
||||
@ -294,6 +294,11 @@ class ApiClient {
|
||||
return this.request<{ data: any[]; meta: any }>(`/enrollment/${courseId}/reviews${params}`);
|
||||
}
|
||||
|
||||
// Certificates
|
||||
async getCertificate(courseId: string) {
|
||||
return this.request<{ certificateUrl: string; html: string }>(`/certificates/${courseId}`);
|
||||
}
|
||||
|
||||
// Search
|
||||
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
||||
const searchParams = new URLSearchParams({ q: query });
|
||||
|
||||
Reference in New Issue
Block a user