chore: add blog blog comment and blog category crud

This commit is contained in:
mahyargdz
2025-04-12 16:09:25 +03:30
parent 0bc001c66b
commit a9879732a3
23 changed files with 976 additions and 3 deletions
@@ -0,0 +1,5 @@
import { SetMetadata } from "@nestjs/common";
export const SKIP_AUTH_KEY = "skipAuth";
export const SkipAuth = () => SetMetadata(SKIP_AUTH_KEY, true);
+49
View File
@@ -616,3 +616,52 @@ export const enum UserQuickAccessMessage {
export const enum SmsMessage { export const enum SmsMessage {
SEND_SMS_ERROR = "خطا در ارسال پیامک", SEND_SMS_ERROR = "خطا در ارسال پیامک",
} }
export const enum BlogMessage {
TITLE_REQUIRED = "عنوان الزامی است",
TITLE_STRING = "عنوان باید یک رشته باشد",
DESCRIPTION_REQUIRED = "توضیحات الزامی است",
DESCRIPTION_STRING = "توضیحات باید یک رشته باشد",
TITLE_MAX_LENGTH = "حداکثر طول عنوان باید ۱۰۰ کاراکتر باشد",
DESCRIPTION_MAX_LENGTH = "حداکثر طول توضیحات باید ۲۵۵ کاراکتر باشد",
ICON_URL_REQUIRED = "آدرس آیکون الزامی است",
ICON_URL_INVALID = "آدرس آیکون نامعتبر است",
ICON_URL_MAX_LENGTH = "حداکثر طول آدرس آیکون باید ۲۵۵ کاراکتر باشد",
IS_ACTIVE_REQUIRED = "وضعیت فعال بودن الزامی است",
IS_ACTIVE_SHOULD_BE_A_BOOLEAN = "وضعیت فعال بودن باید یک بولین باشد",
CATEGORY_ALREADY_EXISTS = "دسته بندی با این عنوان قبلا ثبت شده است",
CATEGORY_NOT_FOUND = "دسته بندی مورد نظر یافت نشد",
BLOG_ALREADY_EXISTS = "بلاگ با این عنوان قبلا ثبت شده است",
BLOG_NOT_FOUND = "بلاگ مورد نظر یافت نشد",
PREVIEW_CONTENT_REQUIRED = "محتوای پیشنمایش الزامی است",
PREVIEW_CONTENT_STRING = "محتوای پیشنمایش باید یک رشته باشد",
PREVIEW_CONTENT_MAX_LENGTH = "حداکثر طول محتوای پیشنمایش باید ۱۵۰ کاراکتر باشد",
CONTENT_REQUIRED = "محتوای بلاگ الزامی است",
CONTENT_STRING = "محتوای بلاگ باید یک رشته باشد",
CONTENT_MAX_LENGTH = "حداکثر طول محتوای بلاگ باید ۶۰۰ کاراکتر باشد",
IMAGE_URL_REQUIRED = "آدرس تصویر الزامی است",
IMAGE_URL_STRING = "آدرس تصویر باید یک رشته باشد",
IMAGE_URL_MAX_LENGTH = "حداکثر طول آدرس تصویر باید ۱۵۰ کاراکتر باشد",
META_TITLE_REQUIRED = "عنوان متا الزامی است",
META_TITLE_STRING = "عنوان متا باید یک رشته باشد",
META_TITLE_MAX_LENGTH = "حداکثر طول عنوان متا باید ۶۰ کاراکتر باشد",
META_DESCRIPTION_REQUIRED = "توضیحات متا الزامی است",
META_DESCRIPTION_STRING = "توضیحات متا باید یک رشته باشد",
META_DESCRIPTION_MAX_LENGTH = "حداکثر طول توضیحات متا باید ۱۶۰ کاراکتر باشد",
TAGS_REQUIRED = "تگ‌ها الزامی است",
TAGS_ARRAY = "تگ‌ها باید یک آرایه باشد",
TAGS_STRING = "تگ‌ها باید یک رشته باشد",
TAGS_MIN_SIZE = "حداقل تعداد تگ‌ها باید ۱ باشد",
CATEGORY_ID_REQUIRED = "شناسه دسته بندی الزامی است",
CATEGORY_ID_UUID = "شناسه دسته بندی باید یک UUID معتبر باشد",
COMMENT_CONTENT_REQUIRED = "محتوای دیدگاه الزامی است",
COMMENT_CONTENT_STRING = "محتوای دیدگاه باید یک رشته باشد",
COMMENT_STATUS_REQUIRED = "وضعیت دیدگاه الزامی است",
COMMENT_STATUS_INVALID = "وضعیت دیدگاه نامعتبر است",
COMMENT_NOT_FOUND = "دیدگاه مورد نظر یافت نشد",
SLUG_NOT_EMPTY = "اسلاگ بلاگ نمیتواند خالی باشد",
SLUG_STRING = "اسلاگ بلاگ باید یک رشته باشد",
INVALID_DATE = "تاریخ نامعتبر است",
IS_ACTIVE_SHOULD_BE_1_0 = "وضعیت فعال بودن باید یکی از ۱ یا ۰ باشد",
SEARCH_QUERY_STRING = "رشته جستجو باید یک رشته باشد",
}
+10
View File
@@ -1,13 +1,23 @@
import { ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; import { ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport"; import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { JWT_STRATEGY_NAME } from "../../../common/constants"; import { JWT_STRATEGY_NAME } from "../../../common/constants";
import { SKIP_AUTH_KEY } from "../../../common/decorators/skip-auth.decorator";
import { AuthMessage } from "../../../common/enums/message.enum"; import { AuthMessage } from "../../../common/enums/message.enum";
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY_NAME) { export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY_NAME) {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> { canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const skipAuth = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH_KEY, [context.getHandler(), context.getClass()]);
if (skipAuth) return true;
return super.canActivate(context); return super.canActivate(context);
} }
+58
View File
@@ -0,0 +1,58 @@
import { ApiProperty } from "@nestjs/swagger";
import { ArrayMinSize, IsArray, IsBoolean, IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
import { BlogMessage } from "../../../common/enums/message.enum";
export class CreateBlogDto {
@IsNotEmpty({ message: BlogMessage.TITLE_REQUIRED })
@IsString({ message: BlogMessage.TITLE_STRING })
@MaxLength(150, { message: BlogMessage.TITLE_MAX_LENGTH })
@ApiProperty({ description: "Title of the blog", example: "Blog Title" })
title: string;
@IsNotEmpty({ message: BlogMessage.PREVIEW_CONTENT_REQUIRED })
@IsString({ message: BlogMessage.PREVIEW_CONTENT_STRING })
@MaxLength(150, { message: BlogMessage.PREVIEW_CONTENT_MAX_LENGTH })
@ApiProperty({ description: "Preview content of the blog", example: "Preview content of the blog" })
previewContent: string;
@IsNotEmpty({ message: BlogMessage.CONTENT_REQUIRED })
@IsString({ message: BlogMessage.CONTENT_STRING })
@MaxLength(600, { message: BlogMessage.CONTENT_MAX_LENGTH })
@ApiProperty({ description: "Content of the blog", example: "Content of the blog" })
content: string;
@IsNotEmpty({ message: BlogMessage.IMAGE_URL_REQUIRED })
@IsString({ message: BlogMessage.IMAGE_URL_STRING })
@MaxLength(150, { message: BlogMessage.IMAGE_URL_MAX_LENGTH })
imageUrl: string;
@IsNotEmpty({ message: BlogMessage.META_TITLE_REQUIRED })
@IsString({ message: BlogMessage.META_TITLE_STRING })
@MaxLength(60, { message: BlogMessage.META_TITLE_MAX_LENGTH })
@ApiProperty({ description: "Meta title of the blog", example: "Meta title of the blog" })
metaTitle: string;
@IsNotEmpty({ message: BlogMessage.META_DESCRIPTION_REQUIRED })
@IsString({ message: BlogMessage.META_DESCRIPTION_STRING })
@MaxLength(160, { message: BlogMessage.META_DESCRIPTION_MAX_LENGTH })
@ApiProperty({ description: "Meta description of the blog", example: "Meta description of the blog" })
metaDescription: string;
@IsNotEmpty({ message: BlogMessage.TAGS_REQUIRED })
@IsArray({ message: BlogMessage.TAGS_ARRAY })
@IsString({ each: true, message: BlogMessage.TAGS_STRING })
@ArrayMinSize(1, { message: BlogMessage.TAGS_MIN_SIZE })
@ApiProperty({ description: "Tags of the blog", example: ["tag1", "tag2", "tag3"] })
tags: string[];
@IsNotEmpty({ message: BlogMessage.CATEGORY_ID_REQUIRED })
@IsUUID("4", { message: BlogMessage.CATEGORY_ID_UUID })
@ApiProperty({ description: "Category ID of the blog", example: "123e4567-e89b-12d3-a456-426614174000" })
categoryId: string;
@IsNotEmpty({ message: BlogMessage.IS_ACTIVE_REQUIRED })
@IsBoolean({ message: BlogMessage.IS_ACTIVE_SHOULD_BE_A_BOOLEAN })
@ApiProperty({ description: "Is active of the blog", example: true })
isActive: boolean;
}
@@ -0,0 +1,29 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsNotEmpty, IsString, IsUrl, MaxLength } from "class-validator";
import { BlogMessage } from "../../../common/enums/message.enum";
export class CreateCategoryDto {
@IsNotEmpty({ message: BlogMessage.TITLE_REQUIRED })
@IsString({ message: BlogMessage.TITLE_STRING })
@MaxLength(100, { message: BlogMessage.TITLE_MAX_LENGTH })
@ApiProperty({ description: "The title of the category", example: "This is the title of the category" })
title: string;
@IsNotEmpty({ message: BlogMessage.DESCRIPTION_REQUIRED })
@IsString({ message: BlogMessage.DESCRIPTION_STRING })
@MaxLength(255, { message: BlogMessage.DESCRIPTION_MAX_LENGTH })
@ApiProperty({ description: "The description of the category", example: "This is the description of the category" })
description: string;
@IsNotEmpty({ message: BlogMessage.ICON_URL_REQUIRED })
@IsUrl({ protocols: ["http", "https"] }, { message: BlogMessage.ICON_URL_INVALID })
@MaxLength(255, { message: BlogMessage.ICON_URL_MAX_LENGTH })
@ApiProperty({ description: "The URL of the icon for the category", example: "https://example.com/icon.png" })
iconUrl: string;
@IsNotEmpty({ message: BlogMessage.IS_ACTIVE_REQUIRED })
@IsBoolean({ message: BlogMessage.IS_ACTIVE_SHOULD_BE_A_BOOLEAN })
@ApiProperty({ description: "The status of the category", example: true })
isActive: boolean;
}
@@ -0,0 +1,16 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from "class-validator";
import { BlogMessage } from "../../../common/enums/message.enum";
export class CreateCommentDto {
@IsNotEmpty({ message: BlogMessage.TITLE_REQUIRED })
@IsString({ message: BlogMessage.TITLE_STRING })
@ApiProperty({ description: "the title of comment" })
title: string;
@IsNotEmpty({ message: BlogMessage.COMMENT_CONTENT_REQUIRED })
@IsString({ message: BlogMessage.COMMENT_CONTENT_STRING })
@ApiProperty({ description: "the content of comment", example: "sd" })
content: string;
}
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from "class-validator";
import { BlogMessage } from "../../../common/enums/message.enum";
export class BlogSlugDto {
@IsNotEmpty({ message: BlogMessage.SLUG_NOT_EMPTY })
@IsString({ message: BlogMessage.SLUG_STRING })
@ApiProperty({ description: "The slug of the blog", example: "blog-slug" })
slug: string;
}
@@ -0,0 +1,31 @@
import { ApiPropertyOptional, PickType } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsDateString, IsIn, IsOptional, IsString, IsUUID } from "class-validator";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
import { BlogMessage, CategoryMessage } from "../../../common/enums/message.enum";
export class BlogListSearchQueryDto extends PaginationDto {
@IsOptional()
@IsString({ message: BlogMessage.SEARCH_QUERY_STRING })
@ApiPropertyOptional({ description: "Search query", example: "search query" })
q?: string;
@IsOptional()
@IsUUID(4, { message: CategoryMessage.CAT_ID_SHOULD_BE_UUID })
@ApiPropertyOptional({ description: "Category id", example: "8b1e8b1e-8b1e-8b1e-8b1e-8b1e8b1e8b1e" })
categoryId?: string;
@IsOptional()
@IsDateString({}, { message: BlogMessage.INVALID_DATE })
@ApiPropertyOptional({ description: "since query", example: "2025-01-27" })
since?: string;
@IsOptional()
@Type(() => Number)
@IsIn([1, 0], { message: BlogMessage.IS_ACTIVE_SHOULD_BE_1_0 })
@ApiPropertyOptional({ description: "blog status", example: 1 })
isActive?: number;
}
export class BlogSearchQueryDto extends PickType(BlogListSearchQueryDto, ["limit", "page", "categoryId"] as const) {}
+5
View File
@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateBlogDto } from "./create-blog.dto";
export class UpdateBlogDto extends PartialType(CreateBlogDto) {}
@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateCategoryDto } from "./create-category.dto";
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
@@ -0,0 +1,12 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty } from "class-validator";
import { BlogMessage } from "../../../common/enums/message.enum";
import { CommentStatus } from "../enums/comment-status.enum";
export class UpdateCommentStatusDto {
@IsNotEmpty({ message: BlogMessage.COMMENT_STATUS_REQUIRED })
@IsEnum(CommentStatus)
@ApiProperty({ description: "the status of comment", example: CommentStatus.APPROVED, enum: CommentStatus })
status: CommentStatus;
}
+19
View File
@@ -0,0 +1,19 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BlogsController } from "./controllers/blogs.controller";
import { BlogCategory } from "./entities/blog-category.entity";
import { BlogComment } from "./entities/blog-comment.entity";
import { Blog } from "./entities/blog.entity";
import { BlogsService } from "./providers/blogs.service";
import { BlogCategoriesRepository } from "./repositories/blog-categories.repository";
import { BlogCommentsRepository } from "./repositories/blog-comments.repository";
import { BlogsRepository } from "./repositories/blogs.repository";
@Module({
imports: [TypeOrmModule.forFeature([Blog, BlogComment, BlogCategory])],
controllers: [BlogsController],
providers: [BlogsService, BlogsRepository, BlogCommentsRepository, BlogCategoriesRepository],
exports: [BlogsService],
})
export class BlogsModule {}
@@ -0,0 +1,157 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { AdminRoute } from "../../../common/decorators/admin.decorator";
import { AuthGuards } from "../../../common/decorators/auth-guard.decorator";
import { Pagination } from "../../../common/decorators/pagination.decorator";
import { PermissionsDec } from "../../../common/decorators/permission.decorator";
import { SkipAuth } from "../../../common/decorators/skip-auth.decorator";
import { UserDec } from "../../../common/decorators/user.decorator";
import { ParamDto } from "../../../common/DTO/param.dto";
import { CategoryListSearchQueryDto } from "../../danak-services/DTO/category-search-query.dto";
import { PermissionEnum } from "../../users/enums/permission.enum";
import { CreateBlogDto } from "../DTO/create-blog.dto";
import { CreateCategoryDto } from "../DTO/create-category.dto";
import { CreateCommentDto } from "../DTO/create-comment.dto";
import { BlogSlugDto } from "../DTO/get-blog-by-slug.dto";
import { BlogListSearchQueryDto, BlogSearchQueryDto } from "../DTO/search-blog-query.dto";
import { UpdateBlogDto } from "../DTO/update-blog.dto";
import { UpdateCategoryDto } from "../DTO/update-category.dto";
import { UpdateCommentStatusDto } from "../DTO/update-comment-status.dto";
import { BlogsService } from "../providers/blogs.service";
@Controller("blogs")
@AuthGuards()
export class BlogsController {
constructor(private readonly blogsService: BlogsService) {}
@ApiOperation({ summary: "Get all public and active blogs with category id" })
@SkipAuth()
@Get()
getBlogsPublic(@Query() queryDto: BlogSearchQueryDto) {
return this.blogsService.getBlogsPublic(queryDto);
}
@ApiOperation({ summary: "Get all blogs (admin route)" })
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
@Get("list")
getBlogList(@Query() queryDto: BlogListSearchQueryDto) {
return this.blogsService.getBlogList(queryDto);
}
@ApiOperation({ summary: "Get a blog by id" })
@SkipAuth()
@Get(":id")
getBlog(@Param() paramDto: ParamDto) {
return this.blogsService.getBlogById(paramDto.id);
}
@ApiOperation({ summary: "Get a blog by slug" })
@SkipAuth()
@Get("slug/:slug")
getBlogBySlug(@Param() paramDto: BlogSlugDto) {
return this.blogsService.getBlogBySlug(paramDto.slug);
}
@ApiOperation({ summary: "Create a new blog (admin route)" })
@Post()
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
createBlog(@Body() createBlogDto: CreateBlogDto, @UserDec("id") userId: string) {
return this.blogsService.createBlog(createBlogDto, userId);
}
@ApiOperation({ summary: "Update a blog (admin route)" })
@Patch(":id")
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
updateBlog(@Param() paramDto: ParamDto, @Body() updateBlogDto: UpdateBlogDto) {
return this.blogsService.updateBlog(paramDto.id, updateBlogDto);
}
@ApiOperation({ summary: "Delete a blog (admin route) " })
@Delete(":id")
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
deleteBlog(@Param() paramDto: ParamDto) {
return this.blogsService.deleteBlog(paramDto.id);
}
//-------------- comments --------------
@ApiOperation({ summary: "Create a new comment" })
@Post(":id/comments")
createComment(@Param() paramDto: ParamDto, @Body() createCommentDto: CreateCommentDto, @UserDec("id") userId: string) {
return this.blogsService.createComment(paramDto.id, createCommentDto, userId);
}
@ApiOperation({ summary: "Get all comments for a blog" })
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
@Get(":id/comments")
getBlogComments(@Param() paramDto: ParamDto) {
return this.blogsService.getBlogComments(paramDto.id);
}
@ApiOperation({ summary: "Update the status of a comment (admin route)" })
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
@Put("comments/:id/status")
updateCommentStatus(@Param() paramDto: ParamDto, @Body() updateCommentStatusDto: UpdateCommentStatusDto) {
return this.blogsService.updateCommentStatus(paramDto.id, updateCommentStatusDto);
}
//-------------- categories --------------
@ApiOperation({ summary: "Get all active categories (admin route)" })
@Pagination()
@Get("categories/list")
getAllActiveCategories(@Query() queryDto: CategoryListSearchQueryDto) {
return this.blogsService.getCategories(queryDto);
}
@ApiOperation({ summary: "Get all active categories (user side)" })
@Get("categories/public")
getCategoriesUserSide() {
return this.blogsService.getCategoriesUserSide();
}
@ApiOperation({ summary: "Get a category by id" })
@Get("categories/:id")
getCategory(@Param() paramDto: ParamDto) {
return this.blogsService.getCategory(paramDto.id);
}
@ApiOperation({ summary: "Create a new category (admin route)" })
@Post("categories")
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
createCategory(@Body() createCategoryDto: CreateCategoryDto) {
return this.blogsService.createCategory(createCategoryDto);
}
@ApiOperation({ summary: "Update a category (admin route)" })
@Put("categories/:id")
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
updateCategory(@Param() paramDto: ParamDto, @Body() updateCategoryDto: UpdateCategoryDto) {
return this.blogsService.updateCategory(paramDto.id, updateCategoryDto);
}
@ApiOperation({ summary: "Delete a category (admin route)" })
@Delete("categories/:id")
@AdminRoute()
@PermissionsDec(PermissionEnum.BLOGS)
deleteCategory(@Param() paramDto: ParamDto) {
return this.blogsService.deleteCategory(paramDto.id);
}
@ApiOperation({ summary: "toggle status of categories (admin route)" })
@PermissionsDec(PermissionEnum.BLOGS)
@HttpCode(HttpStatus.OK)
@Post("categories/toggle-status/:id")
toggleCategoryStatus(@Param() paramDto: ParamDto) {
return this.blogsService.toggleCategoryStatus(paramDto.id);
}
}
@@ -0,0 +1,25 @@
import { Column, DeleteDateColumn, Entity, OneToMany } from "typeorm";
import { Blog } from "./blog.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
@Entity()
export class BlogCategory extends BaseEntity {
@Column({ type: "varchar", length: 100, unique: true })
title: string;
@Column({ type: "varchar", length: 255, nullable: true })
description: string;
@Column({ type: "varchar", length: 255, nullable: true })
iconUrl: string;
@Column({ type: "boolean", default: true })
isActive: boolean;
@OneToMany(() => Blog, (blog) => blog.category)
blogs: Blog[];
@DeleteDateColumn({ type: "timestamptz" })
deletedAt?: Date;
}
@@ -0,0 +1,24 @@
import { Column, Entity, ManyToOne } from "typeorm";
import { Blog } from "./blog.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
import { CommentStatus } from "../enums/comment-status.enum";
@Entity()
export class BlogComment extends BaseEntity {
@Column({ type: "varchar", length: 150, nullable: false })
title: string;
@Column({ type: "text" })
content: string;
@Column({ type: "enum", enum: CommentStatus, default: CommentStatus.PENDING })
status: CommentStatus;
@ManyToOne(() => User, (user) => user.blogComments)
user: User;
@ManyToOne(() => Blog, (blog) => blog.comments)
blog: Blog;
}
+48
View File
@@ -0,0 +1,48 @@
import { Column, DeleteDateColumn, Entity, ManyToOne, OneToMany } from "typeorm";
import { BlogCategory } from "./blog-category.entity";
import { BlogComment } from "./blog-comment.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
@Entity()
export class Blog extends BaseEntity {
@Column({ type: "varchar", length: 150, unique: true })
title: string;
@Column({ type: "varchar", length: 150, unique: true })
slug: string;
@Column({ type: "text" })
previewContent: string;
@Column({ type: "text" })
content: string;
@Column({ type: "varchar", length: 150, nullable: true })
imageUrl: string;
@Column({ type: "varchar", length: 100, nullable: true })
metaTitle: string;
@Column({ type: "varchar", length: 160, nullable: true })
metaDescription: string;
@Column({ type: "simple-array", nullable: true })
tags: string[];
@Column({ type: "boolean", default: true })
isActive: boolean;
@ManyToOne(() => User, (user) => user.blogs)
author: User;
@ManyToOne(() => BlogCategory, (category) => category.blogs)
category: BlogCategory;
@OneToMany(() => BlogComment, (comment) => comment.blog)
comments: BlogComment[];
@DeleteDateColumn({ type: "timestamptz" })
deletedAt?: Date;
}
@@ -0,0 +1,5 @@
export enum CommentStatus {
PENDING = "PENDING",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
}
@@ -0,0 +1,240 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import slugify from "slugify";
import { Not } from "typeorm";
import { BlogMessage, CommonMessage } from "../../../common/enums/message.enum";
import { CategoryListSearchQueryDto } from "../../danak-services/DTO/category-search-query.dto";
import { UsersService } from "../../users/providers/users.service";
import { CreateBlogDto } from "../DTO/create-blog.dto";
import { CreateCategoryDto } from "../DTO/create-category.dto";
import { CreateCommentDto } from "../DTO/create-comment.dto";
import { BlogListSearchQueryDto, BlogSearchQueryDto } from "../DTO/search-blog-query.dto";
import { UpdateBlogDto } from "../DTO/update-blog.dto";
import { UpdateCategoryDto } from "../DTO/update-category.dto";
import { UpdateCommentStatusDto } from "../DTO/update-comment-status.dto";
import { CommentStatus } from "../enums/comment-status.enum";
import { BlogCategoriesRepository } from "../repositories/blog-categories.repository";
import { BlogCommentsRepository } from "../repositories/blog-comments.repository";
import { BlogsRepository } from "../repositories/blogs.repository";
@Injectable()
export class BlogsService {
constructor(
private readonly blogsRepository: BlogsRepository,
private readonly blogCommentsRepository: BlogCommentsRepository,
private readonly blogCategoriesRepository: BlogCategoriesRepository,
private readonly usersService: UsersService,
) {}
//*********************************** */
async createCategory(createCategoryDto: CreateCategoryDto) {
const existingCategory = await this.blogCategoriesRepository.findOneBy({ title: createCategoryDto.title });
if (existingCategory) throw new BadRequestException(BlogMessage.CATEGORY_ALREADY_EXISTS);
const category = await this.blogCategoriesRepository.createCategory(createCategoryDto);
return {
message: CommonMessage.CREATED,
category,
};
}
//*********************************** */
async updateCategory(id: string, updateCategoryDto: UpdateCategoryDto) {
const category = await this.findCategoryById(id);
if (updateCategoryDto.title) {
await this.checkCategoryExists(updateCategoryDto.title, id);
}
await this.blogCategoriesRepository.save({ ...category, ...updateCategoryDto });
return {
message: CommonMessage.UPDATE_SUCCESS,
category,
};
}
//*********************************** */
async deleteCategory(id: string) {
await this.findCategoryById(id);
await this.blogCategoriesRepository.softDelete(id);
}
//*********************************** */
async toggleCategoryStatus(id: string) {
const category = await this.findCategoryById(id);
//
category.isActive = !category.isActive;
await this.blogCategoriesRepository.save(category);
//
return { message: CommonMessage.UPDATE_SUCCESS, category };
}
//*********************************** */
async getCategory(id: string) {
const category = await this.findCategoryById(id);
return { category };
}
//*********************************** */
async getCategoriesUserSide() {
const categories = await this.blogCategoriesRepository.getCategoriesUserSide();
return { categories };
}
//*********************************** */
async getCategories(queryDto: CategoryListSearchQueryDto) {
const [categories, count] = await this.blogCategoriesRepository.getCategories(queryDto);
return { categories, count, paginate: true };
}
//*********************************** */
async createBlog(createBlogDto: CreateBlogDto, authorId: string) {
const existingBlog = await this.blogsRepository.findOne({ where: { title: createBlogDto.title } });
if (existingBlog) throw new BadRequestException(BlogMessage.BLOG_ALREADY_EXISTS);
const category = await this.findCategoryById(createBlogDto.categoryId);
const slug = this.generateSlug(createBlogDto.title);
const blog = this.blogsRepository.create({
...createBlogDto,
slug,
author: { id: authorId },
category,
});
await this.blogsRepository.save(blog);
return {
message: CommonMessage.CREATED,
blog,
};
}
//*********************************** */
async updateBlog(id: string, updateBlogDto: UpdateBlogDto) {
const blog = await this.findBlogById(id);
if (updateBlogDto.title) {
const existBlog = await this.blogsRepository.findOne({ where: { title: updateBlogDto.title, id: Not(id) } });
if (existBlog) throw new BadRequestException(BlogMessage.BLOG_ALREADY_EXISTS);
blog.slug = this.generateSlug(updateBlogDto.title);
}
if (updateBlogDto.categoryId) {
const category = await this.findCategoryById(updateBlogDto.categoryId);
blog.category = category;
}
await this.blogsRepository.save({ ...blog, ...updateBlogDto });
return {
message: CommonMessage.UPDATE_SUCCESS,
blog,
};
}
//*********************************** */
async deleteBlog(id: string) {
await this.findBlogById(id);
await this.blogsRepository.softDelete(id);
return { message: CommonMessage.DELETED };
}
//*********************************** */
async getBlogById(id: string) {
const blog = await this.findBlogById(id);
return { blog };
}
//*********************************** */
async getBlogBySlug(slug: string) {
const blog = await this.blogsRepository.findOneBySlug(slug);
if (!blog) throw new BadRequestException(BlogMessage.BLOG_NOT_FOUND);
return { blog };
}
//*********************************** */
async getBlogsPublic(queryDto: BlogSearchQueryDto) {
const blogs = await this.blogsRepository.getBlogsForUserSide(queryDto);
return { blogs };
}
//*********************************** */
async getBlogList(queryDto: BlogListSearchQueryDto) {
const [blogs, count] = await this.blogsRepository.getBlogListForAdmin(queryDto);
return { blogs, count, paginate: true };
}
//*********************************** */
async createComment(blogId: string, createCommentDto: CreateCommentDto, userId: string) {
const blog = await this.findBlogById(blogId);
const { user } = await this.usersService.findOneById(userId);
const comment = this.blogCommentsRepository.create({
...createCommentDto,
blog,
user,
status: CommentStatus.PENDING,
});
await this.blogCommentsRepository.save(comment);
return {
message: CommonMessage.CREATED,
comment,
};
}
//*********************************** */
async updateCommentStatus(commentId: string, updateCommentStatusDto: UpdateCommentStatusDto) {
const comment = await this.blogCommentsRepository.findOneById(commentId);
if (!comment) throw new BadRequestException(BlogMessage.COMMENT_NOT_FOUND);
comment.status = updateCommentStatusDto.status;
await this.blogCommentsRepository.save(comment);
return {
message: CommonMessage.UPDATE_SUCCESS,
comment,
};
}
//*********************************** */
async getBlogComments(blogId: string) {
const comments = await this.blogCommentsRepository.findAllByBlogId(blogId);
return { comments };
}
//*********************************** */
private generateSlug(title: string): string {
return slugify(title, { lower: true, strict: true });
}
//*********************************** */
private async checkCategoryExists(title: string, id?: string) {
const existingCategory = await this.blogCategoriesRepository.findOneBy({ title, ...(id && { id: Not(id) }) });
if (existingCategory) throw new BadRequestException(BlogMessage.CATEGORY_ALREADY_EXISTS);
}
//*********************************** */
private async findCategoryById(id: string) {
const category = await this.blogCategoriesRepository.findOneById(id);
if (!category) throw new BadRequestException(BlogMessage.CATEGORY_NOT_FOUND);
return category;
}
//*********************************** */
private async findBlogById(id: string) {
const blog = await this.blogsRepository.findOneById(id);
if (!blog) throw new BadRequestException(BlogMessage.BLOG_NOT_FOUND);
return blog;
}
}
@@ -0,0 +1,54 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { IsNull, Repository } from "typeorm";
import { CategoryListSearchQueryDto } from "../../danak-services/DTO/category-search-query.dto";
import { PaginationUtils } from "../../utils/providers/pagination.utils";
import { CreateCategoryDto } from "../DTO/create-category.dto";
import { BlogCategory } from "../entities/blog-category.entity";
@Injectable()
export class BlogCategoriesRepository extends Repository<BlogCategory> {
constructor(@InjectRepository(BlogCategory) repository: Repository<BlogCategory>) {
super(repository.target, repository.manager, repository.queryRunner);
}
async findOneById(id: string): Promise<BlogCategory | null> {
return this.findOne({
where: { id, deletedAt: IsNull() },
});
}
async getCategoriesUserSide() {
return this.find({
where: { isActive: true, deletedAt: IsNull() },
order: { createdAt: "DESC" },
});
}
async getCategories(queryDto: CategoryListSearchQueryDto) {
const { limit, skip } = PaginationUtils(queryDto);
const query = this.createQueryBuilder("blogCategory").where("blogCategory.deletedAt IS NULL");
if (queryDto.q) {
query.andWhere("blogCategory.title ILIKE :q OR blogCategory.slug ILIKE :q", { q: `%${queryDto.q}%` });
}
if (queryDto.isActive) {
query.andWhere("blogCategory.isActive = :isActive", { isActive: queryDto.isActive });
}
query.skip(skip).take(limit).orderBy({ createdAt: "DESC" });
return query.getManyAndCount();
}
async createCategory(createBlogCategoryDto: CreateCategoryDto) {
const { title, description, iconUrl, isActive } = createBlogCategoryDto;
const blogCategory = this.create({ title, description, iconUrl, isActive });
return this.save(blogCategory);
}
}
@@ -0,0 +1,26 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BlogComment } from "../entities/blog-comment.entity";
import { CommentStatus } from "../enums/comment-status.enum";
@Injectable()
export class BlogCommentsRepository extends Repository<BlogComment> {
constructor(@InjectRepository(BlogComment) blogCommentsRepository: Repository<BlogComment>) {
super(blogCommentsRepository.target, blogCommentsRepository.manager, blogCommentsRepository.queryRunner);
}
async findOneById(id: string) {
return this.findOne({
where: { id },
});
}
async findAllByBlogId(blogId: string) {
return this.find({
where: { blog: { id: blogId }, status: CommentStatus.APPROVED },
order: { createdAt: "DESC" },
});
}
}
@@ -0,0 +1,136 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Brackets, IsNull, Repository } from "typeorm";
import { PaginationUtils } from "../../utils/providers/pagination.utils";
import { BlogListSearchQueryDto, BlogSearchQueryDto } from "../DTO/search-blog-query.dto";
import { Blog } from "../entities/blog.entity";
@Injectable()
export class BlogsRepository extends Repository<Blog> {
constructor(@InjectRepository(Blog) blogsRepository: Repository<Blog>) {
super(blogsRepository.target, blogsRepository.manager, blogsRepository.queryRunner);
}
async findOneById(id: string): Promise<Blog | null> {
return this.findOne({
where: { id, deletedAt: IsNull() },
relations: {
author: true,
category: true,
},
select: {
id: true,
title: true,
previewContent: true,
metaTitle: true,
metaDescription: true,
imageUrl: true,
slug: true,
createdAt: true,
content: true,
tags: true,
category: {
id: true,
title: true,
iconUrl: true,
description: true,
},
author: {
id: true,
firstName: true,
lastName: true,
},
},
});
}
async findOneBySlug(slug: string): Promise<Blog | null> {
return this.findOne({
where: { slug, deletedAt: IsNull(), isActive: true },
});
}
async fineOneByTitle(title: string): Promise<Blog | null> {
return this.findOne({
where: { title, deletedAt: IsNull(), isActive: true },
});
}
//*********************************** */
async getBlogsForUserSide(queryDto: BlogSearchQueryDto) {
const { limit, skip } = PaginationUtils(queryDto);
const queryBuilder = this.createQueryBuilder("blog")
.where("blog.deletedAt IS NULL")
.andWhere("blog.isActive = :isActive", { isActive: true })
.leftJoinAndSelect("blog.category", "category")
.leftJoinAndSelect("blog.author", "author")
.select([
"blog.id",
"blog.title",
"blog.previewContent",
"blog.metaTitle",
"blog.metaDescription",
"blog.imageUrl",
"blog.slug",
"blog.createdAt",
"category.id",
"category.iconUrl",
"category.title",
"author.id",
"author.firstName",
"author.lastName",
]);
if (queryDto.categoryId) {
queryBuilder.andWhere("blog.categoryId = :categoryId", { categoryId: queryDto.categoryId });
}
queryBuilder.skip(skip).take(limit).orderBy("blog.createdAt", "DESC");
return queryBuilder.getManyAndCount();
}
//*********************************** */
async getBlogListForAdmin(queryDto: BlogListSearchQueryDto) {
const { limit, skip } = PaginationUtils(queryDto);
const queryBuilder = this.createQueryBuilder("blog")
.where("blog.deletedAt IS NULL")
.leftJoinAndSelect("blog.category", "category")
.leftJoinAndSelect("blog.author", "author");
if (queryDto.isActive) {
queryBuilder.andWhere("blog.isActive = :isActive", { isActive: queryDto.isActive === 1 });
}
if (queryDto.q) {
// queryBuilder.andWhere(
// "blog.title ILIKE :q OR blog.previewContent ILIKE :q OR blog.content ILIKE :q OR blog.metaTitle ILIKE :q OR blog.slug ILIKE :q",
// { q: `%${queryDto.q}%` },
// );
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where("blog.title ILIKE :q", { q: `%${queryDto.q}%` })
.orWhere("blog.previewContent ILIKE :q", { q: `%${queryDto.q}%` })
.orWhere("blog.content ILIKE :q", { q: `%${queryDto.q}%` })
.orWhere("blog.metaTitle ILIKE :q", { q: `%${queryDto.q}%` })
.orWhere("blog.slug ILIKE :q", { q: `%${queryDto.q}%` });
}),
);
}
if (queryDto.categoryId) {
queryBuilder.andWhere("blog.categoryId = :categoryId", { categoryId: queryDto.categoryId });
}
if (queryDto.since) {
queryBuilder.andWhere("blog.createdAt >= :since", { since: queryDto.since });
}
queryBuilder.skip(skip).take(limit).orderBy("blog.createdAt", "DESC");
return queryBuilder.getManyAndCount();
}
}
@@ -7,15 +7,15 @@ import { ReviewStatus } from "../enums/review-status.enum";
@Entity() @Entity()
export class DanakServiceReview extends BaseEntity { export class DanakServiceReview extends BaseEntity {
@Column({ type: "varchar", length: 150, nullable: false })
title: string;
@Column({ type: "text", nullable: false }) @Column({ type: "text", nullable: false })
comment: string; comment: string;
@Column({ type: "int", nullable: false }) @Column({ type: "int", nullable: false })
rating: number; rating: number;
@Column({ type: "varchar", length: 150, nullable: false })
title: string;
@Column({ type: "enum", enum: ReviewStatus, default: ReviewStatus.PENDING }) @Column({ type: "enum", enum: ReviewStatus, default: ReviewStatus.PENDING })
status: ReviewStatus; status: ReviewStatus;
@@ -8,6 +8,8 @@ import { Role } from "./role.entity";
import { UserGroup } from "./user-group.entity"; import { UserGroup } from "./user-group.entity";
import { BaseEntity } from "../../../common/entities/base.entity"; import { BaseEntity } from "../../../common/entities/base.entity";
import { UserAnnouncement } from "../../announcements/entities/user-announcement.entity"; import { UserAnnouncement } from "../../announcements/entities/user-announcement.entity";
import { BlogComment } from "../../blogs/entities/blog-comment.entity";
import { Blog } from "../../blogs/entities/blog.entity";
import { Criticism } from "../../criticisms/entities/criticism.entity"; import { Criticism } from "../../criticisms/entities/criticism.entity";
import { DanakServiceReview } from "../../danak-services/entities/danak-service-review.entity"; import { DanakServiceReview } from "../../danak-services/entities/danak-service-review.entity";
import { Discount } from "../../discounts/entities/discount.entity"; import { Discount } from "../../discounts/entities/discount.entity";
@@ -74,6 +76,12 @@ export class User extends BaseEntity {
@OneToMany(() => DanakServiceReview, (danakServiceReview) => danakServiceReview.user) @OneToMany(() => DanakServiceReview, (danakServiceReview) => danakServiceReview.user)
reviews: DanakServiceReview[]; reviews: DanakServiceReview[];
@OneToMany(() => Blog, (blog) => blog.author)
blogs: Blog[];
@OneToMany(() => BlogComment, (blogComment) => blogComment.user)
blogComments: BlogComment[];
@OneToMany(() => Ticket, (ticket) => ticket.user) @OneToMany(() => Ticket, (ticket) => ticket.user)
tickets: Ticket[]; tickets: Ticket[];