diff --git a/src/app.module.ts b/src/app.module.ts index 9c79077..a226e98 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { FormBuilderModule } from './modules/form-builder/form-builder.module'; import { RequestModule } from './modules/request/request.module'; import { InvoiceModule } from './modules/invoice/invoice.module'; import { OrderModule } from './modules/order/order.module'; +import { LearningModule } from './modules/learnings/learning.module'; @Module({ imports: [ @@ -47,7 +48,8 @@ import { OrderModule } from './modules/order/order.module'; FormBuilderModule, RequestModule, InvoiceModule, - OrderModule + OrderModule, + LearningModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index af47706..7e80634 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -119,6 +119,24 @@ export const enum TicketMessageEnum { OPENED = "تیکت با موفقیت باز شد", } +export const enum LearningMessage { + NOT_FOUND = 'آموزش مورد نظر یافت نشد', + CATEGORY_NOT_FOUND = 'دسته‌بندی مورد نظر یافت نشد', + CATEGORY_NAME_EXIST = 'دسته‌بندی با این نام قبلاً وجود دارد', + CATEGORY_MUST_BE_ID = 'شناسه دسته‌بندی الزامی است', + CATEGORY_MUST_BE_UUID = 'شناسه دسته‌بندی باید معتبر باشد', + NAME_REQUIRED = 'نام دسته‌بندی الزامی است', + NAME_STRING = 'نام دسته‌بندی باید رشته باشد', + TITLE_REQUIRED = 'عنوان الزامی است', + TITLE_STRING = 'عنوان باید رشته باشد', + DESCRIPTION_REQUIRED = 'توضیحات الزامی است', + DESCRIPTION_STRING = 'توضیحات باید رشته باشد', + COVER_URL_REQUIRED = 'آدرس تصویر جلد الزامی است', + COVER_URL_INVALID = 'آدرس تصویر جلد نامعتبر است', + VIDEO_URL_INVALID = 'آدرس ویدیو نامعتبر است', + VIDEO_DURATION_INVALID = 'مدت زمان ویدیو نامعتبر است', +} + export const enum productMessage { NOT_FOUND = 'کالایی با این مشخصات یافت نشد', } diff --git a/src/common/enums/permission.enum.ts b/src/common/enums/permission.enum.ts index df53c31..3c9ba0c 100644 --- a/src/common/enums/permission.enum.ts +++ b/src/common/enums/permission.enum.ts @@ -42,6 +42,7 @@ export enum PermissionEnum { MANAGE_PRINT = 'view_print', + MANAGE_LEARNINGS = 'manage_learnings', } /** @@ -75,5 +76,6 @@ export const PermissionTitles: Record = { [PermissionEnum.DELETE_INVOICE]: "", [PermissionEnum.DELETE_ORDER]: "", [PermissionEnum.CREATE_PRINT]: "ایجاد فرم چاپ برای سفارش", - [PermissionEnum.MANAGE_PRINT]: "مدیریت فرم چاپ" + [PermissionEnum.MANAGE_PRINT]: "مدیریت فرم چاپ", + [PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزش‌ها" }; diff --git a/src/modules/learnings/DTO/create-learning-category.dto.ts b/src/modules/learnings/DTO/create-learning-category.dto.ts new file mode 100755 index 0000000..c2c0495 --- /dev/null +++ b/src/modules/learnings/DTO/create-learning-category.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { LearningMessage } from "../../../common/enums/message.enum"; + +export class CreateLearningCategoryDto { + @ApiProperty({ description: "name of the learning category", maxLength: 200 }) + @IsString({ message: LearningMessage.NAME_STRING }) + @IsNotEmpty({ message: LearningMessage.NAME_REQUIRED }) + name: string; +} diff --git a/src/modules/learnings/DTO/create-learning.dto.ts b/src/modules/learnings/DTO/create-learning.dto.ts new file mode 100755 index 0000000..f2662dc --- /dev/null +++ b/src/modules/learnings/DTO/create-learning.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsNumberString, IsOptional, IsString, IsUrl } from "class-validator"; + +import { LearningMessage } from "../../../common/enums/message.enum"; + +export class CreateLearningDto { + @IsNotEmpty({ message: LearningMessage.CATEGORY_MUST_BE_ID }) + @IsString({ message: LearningMessage.CATEGORY_MUST_BE_UUID }) + @ApiProperty({ description: "Category ID", example: "01ARZ3NDEKTSV4RRFFQ69G5FAV" }) + categoryId: string; + + @ApiProperty({ description: "Title of the learning", maxLength: 200 }) + @IsString({ message: LearningMessage.TITLE_STRING }) + @IsNotEmpty({ message: LearningMessage.TITLE_REQUIRED }) + title: string; + + @IsNotEmpty({ message: LearningMessage.DESCRIPTION_REQUIRED }) + @IsString({ message: LearningMessage.DESCRIPTION_STRING }) + @ApiProperty({ description: "Description of the learning" }) + description: string; + + @IsNotEmpty({ message: LearningMessage.COVER_URL_REQUIRED }) + @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: LearningMessage.COVER_URL_INVALID }) + @IsString({ message: LearningMessage.COVER_URL_INVALID }) + @ApiProperty({ description: "Cover image URL" }) + coverUrl: string; + + @IsOptional() + @IsString({ message: LearningMessage.VIDEO_URL_INVALID }) + @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: LearningMessage.VIDEO_URL_INVALID }) + @ApiProperty({ description: "Video URL", required: false }) + videoUrl?: string; + + @IsOptional() + @IsNumberString({ no_symbols: true }, { message: LearningMessage.VIDEO_DURATION_INVALID }) + @ApiProperty({ description: "Video Duration", required: false }) + videoDuration?: string; +} diff --git a/src/modules/learnings/DTO/update-learning-category.dto.ts b/src/modules/learnings/DTO/update-learning-category.dto.ts new file mode 100755 index 0000000..b251a21 --- /dev/null +++ b/src/modules/learnings/DTO/update-learning-category.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateLearningCategoryDto } from "./create-learning-category.dto"; + +export class UpdateLearningCategoryDto extends PartialType(CreateLearningCategoryDto) {} diff --git a/src/modules/learnings/DTO/update-learning.dto.ts b/src/modules/learnings/DTO/update-learning.dto.ts new file mode 100755 index 0000000..c35b5f2 --- /dev/null +++ b/src/modules/learnings/DTO/update-learning.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateLearningDto } from "./create-learning.dto"; + +export class UpdateLearningDto extends PartialType(CreateLearningDto) {} diff --git a/src/modules/learnings/entities/learning-category.entity.ts b/src/modules/learnings/entities/learning-category.entity.ts new file mode 100755 index 0000000..fb8481a --- /dev/null +++ b/src/modules/learnings/entities/learning-category.entity.ts @@ -0,0 +1,14 @@ +import { Collection, Entity, OneToMany, Property, OptionalProps } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Learning } from './learning.entity'; + +@Entity({ tableName: 'learning_categories' }) +export class LearningCategory extends BaseEntity { + [OptionalProps]?: 'createdAt' | 'deletedAt'; + + @Property({ type: 'varchar', length: 100, unique: true }) + name: string; + + @OneToMany(() => Learning, (learning) => learning.category) + learnings = new Collection(this); +} diff --git a/src/modules/learnings/entities/learning-progress.entity.ts b/src/modules/learnings/entities/learning-progress.entity.ts new file mode 100755 index 0000000..705418a --- /dev/null +++ b/src/modules/learnings/entities/learning-progress.entity.ts @@ -0,0 +1,18 @@ +import { Entity, ManyToOne, Property, OptionalProps } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Learning } from './learning.entity'; +import { User } from '../../user/entities/user.entity'; + +@Entity({ tableName: 'learning_progress' }) +export class LearningProgress extends BaseEntity { + [OptionalProps]?: 'createdAt' | 'deletedAt'; + + @ManyToOne(() => User) + user: User; + + @ManyToOne(() => Learning) + learning: Learning; + + @Property({ type: 'timestamptz', defaultRaw: 'CURRENT_TIMESTAMP' }) + watchedAt: Date; +} diff --git a/src/modules/learnings/entities/learning.entity.ts b/src/modules/learnings/entities/learning.entity.ts new file mode 100755 index 0000000..d6d7f58 --- /dev/null +++ b/src/modules/learnings/entities/learning.entity.ts @@ -0,0 +1,30 @@ +import { Collection, Entity, ManyToOne, OneToMany, Property, OptionalProps } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { LearningCategory } from './learning-category.entity'; +import { LearningProgress } from './learning-progress.entity'; + +@Entity({ tableName: 'learnings' }) +export class Learning extends BaseEntity { + [OptionalProps]?: 'createdAt' | 'deletedAt'; + + @Property({ type: 'varchar', length: 200 }) + title: string; + + @Property({ type: 'text' }) + description: string; + + @Property({ type: 'text' }) + coverUrl: string; + + @Property({ type: 'text', nullable: true }) + videoUrl?: string; + + @Property({ type: 'varchar', length: 200, nullable: true }) + videoDuration?: string; + + @ManyToOne(() => LearningCategory) + category: LearningCategory; + + @OneToMany(() => LearningProgress, (learningProgress) => learningProgress.learning) + learningProgress = new Collection(this); +} diff --git a/src/modules/learnings/learning.controller.ts b/src/modules/learnings/learning.controller.ts new file mode 100755 index 0000000..1855adb --- /dev/null +++ b/src/modules/learnings/learning.controller.ts @@ -0,0 +1,114 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateLearningCategoryDto } from './DTO/create-learning-category.dto'; +import { CreateLearningDto } from './DTO/create-learning.dto'; +import { UpdateLearningCategoryDto } from './DTO/update-learning-category.dto'; +import { UpdateLearningDto } from './DTO/update-learning.dto'; +import { LearningService } from './providers/learning.service'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { AdminAuthGuard } from '../auth/guards/adminAuth.guard'; +import { Permissions } from 'src/common/decorators/permissions.decorator'; +import { UserId } from 'src/common/decorators'; +import { PermissionEnum } from 'src/common/enums/permission.enum'; + +@ApiTags('Learning') +@ApiBearerAuth() +@Controller('learnings') +export class LearningController { + constructor(private readonly learningService: LearningService) {} + + // ----------------- Admin routes ----------------- + + @Post() + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_LEARNINGS) + @ApiOperation({ summary: 'Create learning video' }) + createLearningVideo(@Body() createDto: CreateLearningDto) { + return this.learningService.createLearningVideo(createDto); + } + + @Delete(':id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_LEARNINGS) + @ApiOperation({ summary: 'Delete learning video by id' }) + deleteLearningVideoById(@Param('id') id: string) { + return this.learningService.deleteLearningVideoById(id); + } + + @Patch(':id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_LEARNINGS) + @ApiOperation({ summary: 'Update learning video by id' }) + updateLearningVideoById(@Param('id') id: string, @Body() updateDto: UpdateLearningDto) { + return this.learningService.updateLearningVideo(id, updateDto); + } + + @Post('category') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_LEARNINGS) + @ApiOperation({ summary: 'Create learning category' }) + createCategory(@Body() createDto: CreateLearningCategoryDto) { + return this.learningService.createLearningCategory(createDto); + } + + @Patch('category/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_LEARNINGS) + @ApiOperation({ summary: 'Update learning category' }) + updateLearningCategory(@Param('id') id: string, @Body() updateDto: UpdateLearningCategoryDto) { + return this.learningService.updateLearningCategory(id, updateDto); + } + + @Delete('category/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_LEARNINGS) + @ApiOperation({ summary: 'Delete category by id' }) + deleteCategoryById(@Param('id') id: string) { + return this.learningService.deleteCategoryById(id); + } + + // ----------------- Public / User routes ----------------- + + @Get() + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Find learnings video' }) + findAllLearnings() { + return this.learningService.findLearningsVideos(); + } + + @Get('category') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Find learning categories' }) + findCategories() { + return this.learningService.findCategories(); + } + + @Get('category/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Find one learning category by id' }) + findCategoryById(@Param('id') id: string) { + return this.learningService.findCategoryById(id); + } + + @Get('progress') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get current user learning progress' }) + getUserProgress(@UserId() userId: string) { + return this.learningService.getUserProgress(userId); + } + + @Post(':learningId/progress') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Track learning progress' }) + trackProgress(@UserId() userId: string, @Param('learningId') learningId: string) { + return this.learningService.trackProgress(userId, learningId); + } + + @Get(':id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Find learning video by id' }) + findLearningVideoById(@Param('id') id: string) { + return this.learningService.findLearningVideoById(id); + } +} diff --git a/src/modules/learnings/learning.module.ts b/src/modules/learnings/learning.module.ts new file mode 100755 index 0000000..72b96e8 --- /dev/null +++ b/src/modules/learnings/learning.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; + +import { Learning } from './entities/learning.entity'; +import { LearningCategory } from './entities/learning-category.entity'; +import { LearningProgress } from './entities/learning-progress.entity'; +import { LearningController } from './learning.controller'; +import { LearningService } from './providers/learning.service'; +import { LearningCategoryRepository } from './repositories/learning-category.repository'; +import { LearningProgressRepository } from './repositories/learning-progress.repository'; +import { LearningRepository } from './repositories/learning.repository'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [ + MikroOrmModule.forFeature([Learning, LearningCategory, LearningProgress]), + UserModule, + ], + providers: [ + LearningService, + LearningRepository, + LearningCategoryRepository, + LearningProgressRepository, + ], + controllers: [LearningController], + exports: [LearningService], +}) +export class LearningModule {} diff --git a/src/modules/learnings/providers/learning.service.ts b/src/modules/learnings/providers/learning.service.ts new file mode 100755 index 0000000..7c18c61 --- /dev/null +++ b/src/modules/learnings/providers/learning.service.ts @@ -0,0 +1,158 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { CommonMessage, LearningMessage } from '../../../common/enums/message.enum'; +import { UserService } from '../../user/providers/user.service'; +import { CreateLearningCategoryDto } from '../DTO/create-learning-category.dto'; +import { CreateLearningDto } from '../DTO/create-learning.dto'; +import { UpdateLearningCategoryDto } from '../DTO/update-learning-category.dto'; +import { UpdateLearningDto } from '../DTO/update-learning.dto'; +import { LearningCategoryRepository } from '../repositories/learning-category.repository'; +import { LearningProgressRepository } from '../repositories/learning-progress.repository'; +import { LearningRepository } from '../repositories/learning.repository'; + +@Injectable() +export class LearningService { + constructor( + private readonly learningRepository: LearningRepository, + private readonly learningProgressRepository: LearningProgressRepository, + private readonly learningCategoryRepository: LearningCategoryRepository, + private readonly userService: UserService, + private readonly em: EntityManager, + ) {} + + async createLearningVideo(createDto: CreateLearningDto) { + const { category } = await this.findCategoryById(createDto.categoryId); + + const learning = this.learningRepository.create({ ...createDto, category }); + await this.em.persistAndFlush(learning); + + return { + message: CommonMessage.CREATED, + learning, + }; + } + + async updateLearningVideo(learningId: string, updateDto: UpdateLearningDto) { + const { learning } = await this.findLearningVideoById(learningId); + + if (updateDto.categoryId) { + const { category } = await this.findCategoryById(updateDto.categoryId); + learning.category = category; + } + + this.em.assign(learning, updateDto); + await this.em.flush(); + + return { + message: CommonMessage.UPDATE_SUCCESS, + learning, + }; + } + + async deleteLearningVideoById(learningId: string) { + const { learning } = await this.findLearningVideoById(learningId); + learning.deletedAt = new Date(); + await this.em.flush(); + + return { + message: CommonMessage.DELETED, + }; + } + + async findLearningsVideos() { + const learnings = await this.learningRepository.findAll(); + + return { learnings }; + } + + async findLearningVideoById(id: string) { + const learning = await this.learningRepository.findOne({ id }); + if (!learning) throw new BadRequestException(LearningMessage.NOT_FOUND); + + return { learning }; + } + + async createLearningCategory(createDto: CreateLearningCategoryDto) { + const existCategory = await this.learningCategoryRepository.findOne({ + name: createDto.name, + }); + if (existCategory) throw new BadRequestException(LearningMessage.CATEGORY_NAME_EXIST); + + const category = this.learningCategoryRepository.create(createDto); + await this.em.persistAndFlush(category); + + return { category }; + } + + async updateLearningCategory(categoryId: string, updateDto: UpdateLearningCategoryDto) { + const category = await this.learningCategoryRepository.findOne({ id: categoryId }); + if (!category) throw new BadRequestException(LearningMessage.CATEGORY_NOT_FOUND); + + if (updateDto.name) { + const existCategory = await this.learningCategoryRepository.findOne({ + name: updateDto.name, + id: { $ne: categoryId }, + }); + if (existCategory) throw new BadRequestException(LearningMessage.CATEGORY_NAME_EXIST); + category.name = updateDto.name; + } + + await this.em.flush(); + + return { + message: CommonMessage.UPDATE_SUCCESS, + category, + }; + } + + async deleteCategoryById(categoryId: string) { + await this.findCategoryById(categoryId); + + const category = await this.learningCategoryRepository.findOneOrFail({ id: categoryId }); + category.deletedAt = new Date(); + await this.em.flush(); + + return { + message: CommonMessage.DELETED, + }; + } + + async findCategories() { + const categories = await this.learningCategoryRepository.findAll({ + filters: ['notDeleted'], + }); + + return { categories }; + } + + async findCategoryById(id: string) { + const category = await this.learningCategoryRepository.findOne( + { id }, + { filters: ['notDeleted'] }, + ); + if (!category) throw new BadRequestException(LearningMessage.CATEGORY_NOT_FOUND); + + return { category }; + } + + async trackProgress(userId: string, learningId: string) { + const user = await this.userService.findOrFail(userId); + const { learning } = await this.findLearningVideoById(learningId); + + const progress = this.learningProgressRepository.create({ + user, + learning, + watchedAt: new Date(), + }); + await this.em.persistAndFlush(progress); + + return progress; + } + + async getUserProgress(userId: string) { + return this.learningProgressRepository.find( + { user: { id: userId } }, + { populate: ['learning'] }, + ); + } +} diff --git a/src/modules/learnings/repositories/learning-category.repository.ts b/src/modules/learnings/repositories/learning-category.repository.ts new file mode 100755 index 0000000..c141353 --- /dev/null +++ b/src/modules/learnings/repositories/learning-category.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { LearningCategory } from '../entities/learning-category.entity'; + +@Injectable() +export class LearningCategoryRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, LearningCategory); + } +} diff --git a/src/modules/learnings/repositories/learning-progress.repository.ts b/src/modules/learnings/repositories/learning-progress.repository.ts new file mode 100755 index 0000000..f2663f3 --- /dev/null +++ b/src/modules/learnings/repositories/learning-progress.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { LearningProgress } from '../entities/learning-progress.entity'; + +@Injectable() +export class LearningProgressRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, LearningProgress); + } +} diff --git a/src/modules/learnings/repositories/learning.repository.ts b/src/modules/learnings/repositories/learning.repository.ts new file mode 100755 index 0000000..04b5829 --- /dev/null +++ b/src/modules/learnings/repositories/learning.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { Learning } from '../entities/learning.entity'; + +@Injectable() +export class LearningRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Learning); + } +} diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index 48661aa..4f46343 100644 --- a/src/modules/user/entities/user.entity.ts +++ b/src/modules/user/entities/user.entity.ts @@ -1,5 +1,6 @@ import { Entity, Index, Property, OneToMany, Collection, Cascade, PrimaryKey, OptionalProps } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; +import { LearningProgress } from '../../learnings/entities/learning-progress.entity'; // import { Order } from 'src/modules/order/entities/order.entity'; import { normalizePhone } from '../../util/phone.util'; import { ulid } from 'ulid'; @@ -8,6 +9,9 @@ import { ulid } from 'ulid'; export class User extends BaseEntity { [OptionalProps]?: 'createdAt' | 'deletedAt' + @OneToMany(() => LearningProgress, (lp) => lp.user) + learningProgress = new Collection(this); + // @OneToMany(() => Order, order => order.user) // orders = new Collection(this);