project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View File

@ -0,0 +1,67 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ChaptersService } from './chapters.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { CreateChapterDto } from './dto/create-chapter.dto';
import { UpdateChapterDto } from './dto/update-chapter.dto';
@ApiTags('chapters')
@Controller('courses/:courseId/chapters')
@ApiBearerAuth()
export class ChaptersController {
constructor(private chaptersService: ChaptersService) {}
@Post()
@ApiOperation({ summary: 'Create a new chapter' })
async create(
@Param('courseId') courseId: string,
@CurrentUser() user: User,
@Body() dto: CreateChapterDto
) {
return this.chaptersService.create(courseId, user.id, dto);
}
@Get()
@ApiOperation({ summary: 'Get all chapters for a course' })
async findAll(@Param('courseId') courseId: string) {
return this.chaptersService.findAllByCourse(courseId);
}
@Patch(':chapterId')
@ApiOperation({ summary: 'Update chapter' })
async update(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body() dto: UpdateChapterDto
) {
return this.chaptersService.update(chapterId, user.id, dto);
}
@Delete(':chapterId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete chapter' })
async delete(@Param('chapterId') chapterId: string, @CurrentUser() user: User) {
await this.chaptersService.delete(chapterId, user.id);
}
@Post('reorder')
@ApiOperation({ summary: 'Reorder chapters' })
async reorder(
@Param('courseId') courseId: string,
@CurrentUser() user: User,
@Body('chapterIds') chapterIds: string[]
) {
return this.chaptersService.reorder(courseId, user.id, chapterIds);
}
}

View File

@ -0,0 +1,131 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Chapter } from '@coursecraft/database';
import { CoursesService } from './courses.service';
import { CreateChapterDto } from './dto/create-chapter.dto';
import { UpdateChapterDto } from './dto/update-chapter.dto';
@Injectable()
export class ChaptersService {
constructor(
private prisma: PrismaService,
private coursesService: CoursesService
) {}
async create(courseId: string, userId: string, dto: CreateChapterDto): Promise<Chapter> {
// Check ownership
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Get max order
const maxOrder = await this.prisma.chapter.aggregate({
where: { courseId },
_max: { order: true },
});
const order = (maxOrder._max.order ?? -1) + 1;
return this.prisma.chapter.create({
data: {
courseId,
title: dto.title,
description: dto.description,
order,
},
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async findAllByCourse(courseId: string): Promise<Chapter[]> {
return this.prisma.chapter.findMany({
where: { courseId },
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
});
}
async findById(id: string): Promise<Chapter | null> {
return this.prisma.chapter.findUnique({
where: { id },
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async update(
chapterId: string,
userId: string,
dto: UpdateChapterDto
): Promise<Chapter> {
const chapter = await this.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.chapter.update({
where: { id: chapterId },
data: {
title: dto.title,
description: dto.description,
},
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async delete(chapterId: string, userId: string): Promise<void> {
const chapter = await this.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await this.prisma.chapter.delete({
where: { id: chapterId },
});
}
async reorder(courseId: string, userId: string, chapterIds: string[]): Promise<Chapter[]> {
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Update order for each chapter
await Promise.all(
chapterIds.map((id, index) =>
this.prisma.chapter.update({
where: { id },
data: { order: index },
})
)
);
return this.findAllByCourse(courseId);
}
}

View File

@ -0,0 +1,78 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { CoursesService } from './courses.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User, CourseStatus } from '@coursecraft/database';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
@ApiTags('courses')
@Controller('courses')
@ApiBearerAuth()
export class CoursesController {
constructor(private coursesService: CoursesService) {}
@Post()
@ApiOperation({ summary: 'Create a new course' })
async create(@CurrentUser() user: User, @Body() dto: CreateCourseDto): Promise<any> {
return this.coursesService.create(user.id, dto);
}
@Get()
@ApiOperation({ summary: 'Get all courses for current user' })
@ApiQuery({ name: 'status', required: false, enum: CourseStatus })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findAll(
@CurrentUser() user: User,
@Query('status') status?: CourseStatus,
@Query('page') page?: number,
@Query('limit') limit?: number
) {
return this.coursesService.findAllByAuthor(user.id, { status, page, limit });
}
@Get(':id')
@ApiOperation({ summary: 'Get course by ID' })
async findOne(@Param('id') id: string): Promise<any> {
return this.coursesService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update course' })
async update(
@Param('id') id: string,
@CurrentUser() user: User,
@Body() dto: UpdateCourseDto
): Promise<any> {
return this.coursesService.update(id, user.id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete course' })
async delete(@Param('id') id: string, @CurrentUser() user: User) {
await this.coursesService.delete(id, user.id);
}
@Patch(':id/status')
@ApiOperation({ summary: 'Update course status' })
async updateStatus(
@Param('id') id: string,
@CurrentUser() user: User,
@Body('status') status: CourseStatus
): Promise<any> {
return this.coursesService.updateStatus(id, user.id, status);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { CoursesController } from './courses.controller';
import { CoursesService } from './courses.service';
import { ChaptersController } from './chapters.controller';
import { ChaptersService } from './chapters.service';
import { LessonsController } from './lessons.controller';
import { LessonsService } from './lessons.service';
@Module({
controllers: [CoursesController, ChaptersController, LessonsController],
providers: [CoursesService, ChaptersService, LessonsService],
exports: [CoursesService, ChaptersService, LessonsService],
})
export class CoursesModule {}

View File

@ -0,0 +1,231 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Course, CourseStatus } from '@coursecraft/database';
import { generateUniqueSlug } from '@coursecraft/shared';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
@Injectable()
export class CoursesService {
constructor(private prisma: PrismaService) {}
async create(authorId: string, dto: CreateCourseDto): Promise<Course> {
const slug = generateUniqueSlug(dto.title);
return this.prisma.course.create({
data: {
authorId,
title: dto.title,
description: dto.description,
slug,
status: CourseStatus.DRAFT,
},
include: {
chapters: {
include: {
lessons: true,
},
orderBy: { order: 'asc' },
},
},
});
}
async findAllByAuthor(
authorId: string,
options?: {
status?: CourseStatus;
page?: number;
limit?: number;
}
) {
const page = options?.page || 1;
const limit = options?.limit || 10;
const skip = (page - 1) * limit;
const where = {
authorId,
...(options?.status && { status: options.status }),
};
const [courses, total] = await Promise.all([
this.prisma.course.findMany({
where,
include: {
_count: {
select: {
chapters: true,
},
},
chapters: {
include: {
_count: {
select: { lessons: true },
},
},
},
},
orderBy: { updatedAt: 'desc' },
skip,
take: limit,
}),
this.prisma.course.count({ where }),
]);
// Transform to include counts
const transformedCourses = courses.map((course) => ({
id: course.id,
title: course.title,
description: course.description,
slug: course.slug,
coverImage: course.coverImage,
status: course.status,
chaptersCount: course._count.chapters,
lessonsCount: course.chapters.reduce((acc, ch) => acc + ch._count.lessons, 0),
createdAt: course.createdAt,
updatedAt: course.updatedAt,
}));
return {
data: transformedCourses,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(id: string): Promise<Course | null> {
return this.prisma.course.findUnique({
where: { id },
include: {
author: {
select: {
id: true,
name: true,
avatarUrl: true,
},
},
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
category: true,
},
});
}
async findBySlug(slug: string): Promise<Course | null> {
return this.prisma.course.findUnique({
where: { slug },
include: {
author: {
select: {
id: true,
name: true,
avatarUrl: true,
},
},
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
},
});
}
async update(id: string, userId: string, dto: UpdateCourseDto): Promise<Course> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.course.update({
where: { id },
data: {
title: dto.title,
description: dto.description,
coverImage: dto.coverImage,
status: dto.status,
tags: dto.tags,
difficulty: dto.difficulty,
estimatedHours: dto.estimatedHours,
metaTitle: dto.metaTitle,
metaDescription: dto.metaDescription,
},
include: {
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
},
});
}
async delete(id: string, userId: string): Promise<void> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only delete your own courses');
}
await this.prisma.course.delete({
where: { id },
});
}
async updateStatus(id: string, userId: string, status: CourseStatus): Promise<Course> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only edit your own courses');
}
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status };
if (status === CourseStatus.PUBLISHED && !course.publishedAt) {
updateData.publishedAt = new Date();
}
return this.prisma.course.update({
where: { id },
data: updateData,
});
}
async checkOwnership(courseId: string, userId: string): Promise<boolean> {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { authorId: true },
});
return course?.authorId === userId;
}
}

View File

@ -0,0 +1,22 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateChapterDto {
@ApiProperty({
description: 'Chapter title',
example: 'Introduction to Digital Marketing',
})
@IsString()
@MinLength(VALIDATION.CHAPTER.TITLE_MIN)
@MaxLength(VALIDATION.CHAPTER.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Chapter description',
example: 'Learn the basics of digital marketing strategies',
})
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,25 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateCourseDto {
@ApiProperty({
description: 'Course title',
example: 'Introduction to Marketing',
minLength: VALIDATION.COURSE.TITLE_MIN,
maxLength: VALIDATION.COURSE.TITLE_MAX,
})
@IsString()
@MinLength(VALIDATION.COURSE.TITLE_MIN)
@MaxLength(VALIDATION.COURSE.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Course description',
example: 'Learn the fundamentals of digital marketing...',
})
@IsOptional()
@IsString()
@MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX)
description?: string;
}

View File

@ -0,0 +1,30 @@
import { IsString, IsOptional, IsObject, IsNumber, MinLength, MaxLength, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateLessonDto {
@ApiProperty({
description: 'Lesson title',
example: 'What is Digital Marketing?',
})
@IsString()
@MinLength(VALIDATION.LESSON.TITLE_MIN)
@MaxLength(VALIDATION.LESSON.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Lesson content in TipTap JSON format',
})
@IsOptional()
@IsObject()
content?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
example: 15,
})
@IsOptional()
@IsNumber()
@Min(0)
durationMinutes?: number;
}

View File

@ -0,0 +1,17 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateChapterDto {
@ApiPropertyOptional({ description: 'Chapter title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.CHAPTER.TITLE_MIN)
@MaxLength(VALIDATION.CHAPTER.TITLE_MAX)
title?: string;
@ApiPropertyOptional({ description: 'Chapter description' })
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,71 @@
import {
IsString,
IsOptional,
IsArray,
IsNumber,
IsEnum,
IsUrl,
MinLength,
MaxLength,
Min,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { CourseStatus } from '@coursecraft/database';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateCourseDto {
@ApiPropertyOptional({ description: 'Course title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.COURSE.TITLE_MIN)
@MaxLength(VALIDATION.COURSE.TITLE_MAX)
title?: string;
@ApiPropertyOptional({ description: 'Course description' })
@IsOptional()
@IsString()
@MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX)
description?: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@IsOptional()
@IsUrl()
coverImage?: string;
@ApiPropertyOptional({ description: 'Course status', enum: CourseStatus })
@IsOptional()
@IsEnum(CourseStatus)
status?: CourseStatus;
@ApiPropertyOptional({ description: 'Course tags', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({
description: 'Course difficulty',
enum: ['beginner', 'intermediate', 'advanced'],
})
@IsOptional()
@IsString()
difficulty?: string;
@ApiPropertyOptional({ description: 'Estimated hours to complete' })
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@ApiPropertyOptional({ description: 'SEO meta title' })
@IsOptional()
@IsString()
@MaxLength(100)
metaTitle?: string;
@ApiPropertyOptional({ description: 'SEO meta description' })
@IsOptional()
@IsString()
@MaxLength(300)
metaDescription?: string;
}

View File

@ -0,0 +1,34 @@
import { IsString, IsOptional, IsObject, IsNumber, IsUrl, MinLength, MaxLength, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateLessonDto {
@ApiPropertyOptional({ description: 'Lesson title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.LESSON.TITLE_MIN)
@MaxLength(VALIDATION.LESSON.TITLE_MAX)
title?: string;
@ApiPropertyOptional({
description: 'Lesson content in TipTap JSON format',
})
@IsOptional()
@IsObject()
content?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
})
@IsOptional()
@IsNumber()
@Min(0)
durationMinutes?: number;
@ApiPropertyOptional({
description: 'Video URL for the lesson',
})
@IsOptional()
@IsUrl()
videoUrl?: string;
}

View File

@ -0,0 +1,67 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LessonsService } from './lessons.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { CreateLessonDto } from './dto/create-lesson.dto';
import { UpdateLessonDto } from './dto/update-lesson.dto';
@ApiTags('lessons')
@Controller('courses/:courseId')
@ApiBearerAuth()
export class LessonsController {
constructor(private lessonsService: LessonsService) {}
@Post('chapters/:chapterId/lessons')
@ApiOperation({ summary: 'Create a new lesson' })
async create(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body() dto: CreateLessonDto
): Promise<any> {
return this.lessonsService.create(chapterId, user.id, dto);
}
@Get('lessons/:lessonId')
@ApiOperation({ summary: 'Get lesson by ID' })
async findOne(@Param('lessonId') lessonId: string): Promise<any> {
return this.lessonsService.findById(lessonId);
}
@Patch('lessons/:lessonId')
@ApiOperation({ summary: 'Update lesson' })
async update(
@Param('lessonId') lessonId: string,
@CurrentUser() user: User,
@Body() dto: UpdateLessonDto
): Promise<any> {
return this.lessonsService.update(lessonId, user.id, dto);
}
@Delete('lessons/:lessonId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete lesson' })
async delete(@Param('lessonId') lessonId: string, @CurrentUser() user: User): Promise<void> {
await this.lessonsService.delete(lessonId, user.id);
}
@Post('chapters/:chapterId/lessons/reorder')
@ApiOperation({ summary: 'Reorder lessons in a chapter' })
async reorder(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body('lessonIds') lessonIds: string[]
): Promise<any> {
return this.lessonsService.reorder(chapterId, user.id, lessonIds);
}
}

View File

@ -0,0 +1,141 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Lesson } from '@coursecraft/database';
import { CoursesService } from './courses.service';
import { ChaptersService } from './chapters.service';
import { CreateLessonDto } from './dto/create-lesson.dto';
import { UpdateLessonDto } from './dto/update-lesson.dto';
@Injectable()
export class LessonsService {
constructor(
private prisma: PrismaService,
private coursesService: CoursesService,
private chaptersService: ChaptersService
) {}
async create(chapterId: string, userId: string, dto: CreateLessonDto): Promise<Lesson> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Get max order
const maxOrder = await this.prisma.lesson.aggregate({
where: { chapterId },
_max: { order: true },
});
const order = (maxOrder._max.order ?? -1) + 1;
return this.prisma.lesson.create({
data: {
chapterId,
title: dto.title,
content: dto.content as any,
order,
durationMinutes: dto.durationMinutes,
},
});
}
async findById(id: string): Promise<Lesson | null> {
return this.prisma.lesson.findUnique({
where: { id },
include: {
chapter: {
select: {
id: true,
title: true,
courseId: true,
},
},
},
});
}
async update(lessonId: string, userId: string, dto: UpdateLessonDto): Promise<Lesson> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.lesson.update({
where: { id: lessonId },
data: {
title: dto.title,
content: dto.content as any,
durationMinutes: dto.durationMinutes,
videoUrl: dto.videoUrl,
},
});
}
async delete(lessonId: string, userId: string): Promise<void> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await this.prisma.lesson.delete({
where: { id: lessonId },
});
}
async reorder(chapterId: string, userId: string, lessonIds: string[]): Promise<Lesson[]> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await Promise.all(
lessonIds.map((id, index) =>
this.prisma.lesson.update({
where: { id },
data: { order: index },
})
)
);
return this.prisma.lesson.findMany({
where: { chapterId },
orderBy: { order: 'asc' },
});
}
}