project init
This commit is contained in:
67
apps/api/src/courses/chapters.controller.ts
Normal file
67
apps/api/src/courses/chapters.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
131
apps/api/src/courses/chapters.service.ts
Normal file
131
apps/api/src/courses/chapters.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
78
apps/api/src/courses/courses.controller.ts
Normal file
78
apps/api/src/courses/courses.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/courses/courses.module.ts
Normal file
14
apps/api/src/courses/courses.module.ts
Normal 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 {}
|
||||
231
apps/api/src/courses/courses.service.ts
Normal file
231
apps/api/src/courses/courses.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/api/src/courses/dto/create-chapter.dto.ts
Normal file
22
apps/api/src/courses/dto/create-chapter.dto.ts
Normal 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;
|
||||
}
|
||||
25
apps/api/src/courses/dto/create-course.dto.ts
Normal file
25
apps/api/src/courses/dto/create-course.dto.ts
Normal 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;
|
||||
}
|
||||
30
apps/api/src/courses/dto/create-lesson.dto.ts
Normal file
30
apps/api/src/courses/dto/create-lesson.dto.ts
Normal 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;
|
||||
}
|
||||
17
apps/api/src/courses/dto/update-chapter.dto.ts
Normal file
17
apps/api/src/courses/dto/update-chapter.dto.ts
Normal 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;
|
||||
}
|
||||
71
apps/api/src/courses/dto/update-course.dto.ts
Normal file
71
apps/api/src/courses/dto/update-course.dto.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/courses/dto/update-lesson.dto.ts
Normal file
34
apps/api/src/courses/dto/update-lesson.dto.ts
Normal 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;
|
||||
}
|
||||
67
apps/api/src/courses/lessons.controller.ts
Normal file
67
apps/api/src/courses/lessons.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
141
apps/api/src/courses/lessons.service.ts
Normal file
141
apps/api/src/courses/lessons.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user