From 6a557e8841fc00f872f64eb19e4ba2908ca6756f Mon Sep 17 00:00:00 2001 From: kelvin Date: Wed, 19 Oct 2022 21:05:19 +0700 Subject: [PATCH] change categories to tags --- .../src/shared/Entities/categories.entity.ts | 37 ----- server/src/shared/Entities/tags.entity.ts | 34 +++++ server/src/shared/Entities/titles.entity.ts | 5 +- .../Repositories/categories.repository.ts | 143 ------------------ .../shared/Repositories/tags.repository.ts | 89 +++++++++++ .../shared/Repositories/title.repository.ts | 56 +++---- server/src/shared/Services/config.service.ts | 4 +- server/src/v1/Auth/Service/auth.service.ts | 2 +- .../Controller/category.controller.ts | 65 -------- .../v1/Category/Dto/create-category.dto.ts | 30 ---- .../v1/Category/Dto/update-category.dto.ts | 31 ---- .../v1/Category/Service/category.service.ts | 94 ------------ server/src/v1/Category/category.module.ts | 30 ---- server/src/v1/Tag/tag.controller.ts | 42 +++++ server/src/v1/Tag/tag.module.ts | 23 +++ server/src/v1/Tag/tag.service.ts | 72 +++++++++ .../v1/Title/Controller/title.controller.ts | 4 +- server/src/v1/Title/Dto/create-title.dto.ts | 14 +- server/src/v1/Title/Dto/update-title.dto.ts | 14 +- server/src/v1/Title/Service/title.service.ts | 55 +++---- server/src/v1/Title/title.module.ts | 4 +- server/src/v1/v1.module.ts | 12 +- server/src/version.routes.ts | 16 +- 23 files changed, 352 insertions(+), 524 deletions(-) delete mode 100644 server/src/shared/Entities/categories.entity.ts create mode 100644 server/src/shared/Entities/tags.entity.ts delete mode 100644 server/src/shared/Repositories/categories.repository.ts create mode 100644 server/src/shared/Repositories/tags.repository.ts delete mode 100644 server/src/v1/Category/Controller/category.controller.ts delete mode 100644 server/src/v1/Category/Dto/create-category.dto.ts delete mode 100644 server/src/v1/Category/Dto/update-category.dto.ts delete mode 100644 server/src/v1/Category/Service/category.service.ts delete mode 100755 server/src/v1/Category/category.module.ts create mode 100644 server/src/v1/Tag/tag.controller.ts create mode 100644 server/src/v1/Tag/tag.module.ts create mode 100644 server/src/v1/Tag/tag.service.ts diff --git a/server/src/shared/Entities/categories.entity.ts b/server/src/shared/Entities/categories.entity.ts deleted file mode 100644 index 41414215..00000000 --- a/server/src/shared/Entities/categories.entity.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Other dependencies -import { - Column, - Entity, - ObjectID, - ObjectIdColumn, - CreateDateColumn, - UpdateDateColumn, -} from 'typeorm' - -@Entity('Categories') -export class CategoriesEntity { - constructor(partial: Partial) { - Object.assign(this, partial) - } - - @ObjectIdColumn() - id: ObjectID - - @Column({ type: 'string', nullable: true }) - parent_category: string - - @Column({ type: 'string', length: 50 }) - name: string - - @Column('boolean') - is_leaf: boolean - - @Column('array') - ancestors: string[] - - @CreateDateColumn('date') - created_at: Date - - @UpdateDateColumn('date') - updated_at: Date -} diff --git a/server/src/shared/Entities/tags.entity.ts b/server/src/shared/Entities/tags.entity.ts new file mode 100644 index 00000000..ce285506 --- /dev/null +++ b/server/src/shared/Entities/tags.entity.ts @@ -0,0 +1,34 @@ +// Other dependencies +import { + Column, + Entity, + ObjectID, + ObjectIdColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm' + +@Entity('Tags') +export class TagsEntity { + constructor(partial: Partial) { + Object.assign(this, partial) + } + + @ObjectIdColumn() + _id: ObjectID + + @Column({ type: 'string' }) + name: string + + @Column({ type: 'int' }) + total_title: number + + @Column({ type: 'double' }) + popularity_ratio: number + + @CreateDateColumn({ type: 'date' }) + created_at: Date + + @UpdateDateColumn({ type: 'date' }) + updated_at: Date +} \ No newline at end of file diff --git a/server/src/shared/Entities/titles.entity.ts b/server/src/shared/Entities/titles.entity.ts index df9da946..2465379a 100644 --- a/server/src/shared/Entities/titles.entity.ts +++ b/server/src/shared/Entities/titles.entity.ts @@ -18,11 +18,8 @@ export class TitlesEntity { @ObjectIdColumn() id: ObjectID - @Column({ type: 'string' }) - category_id: string - @Column({ type: 'array' }) - category_ancestors: string[] + tags: string[] @Column({ unique: true, diff --git a/server/src/shared/Repositories/categories.repository.ts b/server/src/shared/Repositories/categories.repository.ts deleted file mode 100644 index 29f5a76c..00000000 --- a/server/src/shared/Repositories/categories.repository.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Nest dependencies -import { BadRequestException, UnprocessableEntityException, NotFoundException } from '@nestjs/common' - -// Other dependencies -import { Repository, EntityRepository } from 'typeorm' - -// Local files -import { CategoriesEntity } from '../Entities/categories.entity' -import { CreateCategoryDto } from 'src/v1/Category/Dto/create-category.dto' -import { UpdateCategoryDto } from 'src/v1/Category/Dto/update-category.dto' - -@EntityRepository(CategoriesEntity) -export class CategoriesRepository extends Repository { - async getCategory(categoryId: string): Promise { - try { - const category: CategoriesEntity = await this.findOneOrFail(categoryId) - return category - } catch (err) { - throw new NotFoundException('Category could not find by given id') - } - } - - async getMainCategories(): Promise<{categories: CategoriesEntity[], count: number}> { - const [categories, total] = await this.findAndCount({ - where: { - parent_category: null - }, - order: { - name: 'ASC' - } - }) - return { categories, count: total } - } - - async getChildCategories(categoryId: string): Promise<{categories: CategoriesEntity[], count: number}> { - const [categories, total] = await this.findAndCount({ - where: { - parent_category: categoryId, - }, - order: { - is_leaf: 'ASC', - name: 'ASC' - } - }) - return { categories, count: total } - } - - async getCategoryListByIds(idList: string[]): Promise<{categories: CategoriesEntity[], count: number}> { - const [categories, total] = await this.findAndCount({ - where: { - '_id': { - $in: idList - } - }, - skip: 0, - }) - return { categories, count: total } - } - - async createCategory(dto: CreateCategoryDto): Promise { - const categoryPayload: { - name: string, - parent_category?: string, - is_leaf: boolean, - ancestors: string[] - } = { - name: dto.categoryName, - is_leaf: dto.isLeaf, - ancestors: [] - } - - if (dto.parentCategoryId) { - try { - const parentCategory = await this.findOneOrFail(dto.parentCategoryId) - categoryPayload.parent_category = dto.parentCategoryId - categoryPayload.ancestors.push(...parentCategory.ancestors, dto.parentCategoryId) - } catch (err) { - throw new NotFoundException('Category could not find by given id') - } - } - - const newCategory: CategoriesEntity = new CategoriesEntity(categoryPayload) - - try { - return await this.save(newCategory) - } catch (err) { - throw new UnprocessableEntityException(err.errmsg) - } - } - - async updateCategory(categoryId: string, dto: UpdateCategoryDto): Promise { - let parentCategory: CategoriesEntity | null = null - if (dto.parentCategoryId) { - try { - parentCategory = await this.findOneOrFail(dto.parentCategoryId) - } catch (err) { - throw new NotFoundException('Parent category could not find by given id') - } - } - - let category: CategoriesEntity - try { - category = await this.findOneOrFail(categoryId) - } catch { - throw new NotFoundException('Category could not find by given id') - } - - try { - if (dto.categoryName) category.name = dto.categoryName - if (dto.isLeaf !== undefined) category.is_leaf = dto.isLeaf - if (parentCategory) { - category.parent_category = dto.parentCategoryId - category.ancestors = [...parentCategory.ancestors, String(parentCategory.id)] - } - - await this.save(category) - return category - } catch (err) { - throw new BadRequestException(err.errmsg) - } - } - - async deleteCategory(categoryId: string): Promise { - let category: CategoriesEntity - try { - category = await this.findOneOrFail(categoryId) - await this.delete(category) - } catch (err) { - throw new NotFoundException('Category could not find by given id') - } - - // Delete all child categories that belongs to deleted category - const childCategories: any[] = await this.find({ - where: { - ancestors: { - $in: [String(category.id)] - } - }, - }) - await this.remove(childCategories) - } - -} diff --git a/server/src/shared/Repositories/tags.repository.ts b/server/src/shared/Repositories/tags.repository.ts new file mode 100644 index 00000000..cd04c181 --- /dev/null +++ b/server/src/shared/Repositories/tags.repository.ts @@ -0,0 +1,89 @@ +// Other dependencies +import { Repository, EntityRepository } from 'typeorm' + +// Local files +import { TagsEntity } from '../Entities/tags.entity' + +@EntityRepository(TagsEntity) +export class TagsRepository extends Repository { + + async getTrendingTags(): Promise<{ tags: TagsEntity[], count: number }> { + const [tags, total] = await this.findAndCount({ + where: { + updated_at: { + // Last 24 hours + $gte: new Date(new Date().setDate(new Date().getDate() - 1)) + } + }, + order: { + popularity_ratio: 'DESC', + updated_at: 'DESC' + }, + take: 30, + skip: 0, + }) + + return { tags, count: total } + } + + async searchTag(searchValue: string): Promise<{ tags: TagsEntity[] }> { + const [tags] = await this.findAndCount({ + where: { + name: new RegExp(searchValue, 'i') + }, + take: 25, + order: { + popularity_ratio: 'DESC' + } + }) + + return { tags } + } + + async tagActionOnTitleCreateOrUpdate(tagName: string): Promise { + const tag = await this.findOne({ name: tagName }) + + if (!tag) { + this.save({ + name: tagName, + popularity_ratio: 0.01, + total_title: 1 + }) + } + + else { + // @ts-ignore + const diff = new Date().getTime() - tag.updated_at + const diffAsMin = Math.round((diff / 1000) / 60) + + if (diffAsMin <= 1440 && tag.popularity_ratio < 1.00) tag.popularity_ratio += 0.01 + else tag.popularity_ratio = 0.02 + + tag.total_title++ + this.save(tag) + } + + return + } + + async updateTagWhenRemovedFromTitle(tagName: string): Promise { + const tag = await this.findOne({ name: tagName }) + if (!tag) return + + if (tag.total_title > 1) { + tag.total_title-- + if (tag.popularity_ratio > 0.01) (tag.popularity_ratio -= 0.01).toFixed(1) + tag.updated_at = tag.updated_at + this.save(tag) + } + else this.deleteTag(tag) + + return + } + + async deleteTag(tag: TagsEntity): Promise { + await this.delete(tag) + return + } + +} \ No newline at end of file diff --git a/server/src/shared/Repositories/title.repository.ts b/server/src/shared/Repositories/title.repository.ts index 7ac0d613..40f4b4c3 100644 --- a/server/src/shared/Repositories/title.repository.ts +++ b/server/src/shared/Repositories/title.repository.ts @@ -15,7 +15,7 @@ import { UpdateTitleDto } from 'src/v1/Title/Dto/update-title.dto' export class TitlesRepository extends Repository { async getTitleBySlug(titleSlug: string): Promise { try { - const title: TitlesEntity = await this.findOneOrFail({slug: titleSlug}) + const title: TitlesEntity = await this.findOneOrFail({ slug: titleSlug }) return title } catch (err) { throw new NotFoundException('Title could not found by given slug') @@ -31,7 +31,7 @@ export class TitlesRepository extends Repository { } } - async searchTitle({ searchValue } : { searchValue: string }): Promise<{ titles: TitlesEntity[] }> { + async searchTitle({ searchValue }: { searchValue: string }): Promise<{ titles: TitlesEntity[] }> { const [titles] = await this.findAndCount({ where: { name: new RegExp(searchValue, 'i') @@ -58,7 +58,7 @@ export class TitlesRepository extends Repository { async getTitleList( query: { author: string, - categoryIds: string[], + tags: string[], sortBy: 'hot' | 'top', skip: number, } @@ -68,19 +68,10 @@ export class TitlesRepository extends Repository { ...query.author && { opened_by: query.author }, - ...query.categoryIds && { - $or: [ - { - category_id: { - $in: query.categoryIds - } - }, - { - category_ancestors: { - $in: query.categoryIds - } - }, - ] + ...query.tags && { + tags: { + $in: query.tags + } }, ...query.sortBy === 'hot' && { created_at: { @@ -121,12 +112,11 @@ export class TitlesRepository extends Repository { return { titles, count: total } } - async createTitle(openedBy: string, dto: CreateTitleDto, categoryAncestors: string[]): Promise { + async createTitle(openedBy: string, dto: CreateTitleDto): Promise { const newTitle: TitlesEntity = new TitlesEntity({ name: dto.name, slug: slugify(dto.name, { lower: true }), - category_id: dto.categoryId, - category_ancestors: categoryAncestors, + tags: dto.tags, opened_by: openedBy, }) @@ -147,7 +137,7 @@ export class TitlesRepository extends Repository { const docIfAlreadyRated = title.rate.find(item => item.username === ratedBy) if (docIfAlreadyRated) docIfAlreadyRated.rateValue = rateValue - else title.rate.push({ username: ratedBy, rateValue}) + else title.rate.push({ username: ratedBy, rateValue }) this.save(title) } @@ -161,7 +151,7 @@ export class TitlesRepository extends Repository { } const userRate: { rateValue: number } | undefined = title.rate.find(( - item: {username: string, rateValue: number } + item: { username: string, rateValue: number } ) => item.username === username) if (userRate) return userRate!.rateValue @@ -184,17 +174,13 @@ export class TitlesRepository extends Repository { return Math.round(averageRate) } - async updateTitle(updatedBy: string, title: TitlesEntity, dto: UpdateTitleDto, categoryAncestors: string[]): Promise { + async updateTitle(updatedBy: string, title: TitlesEntity, dto: UpdateTitleDto): Promise { try { if (dto.name) { title.name = dto.name title.slug = slugify(dto.name, { lower: true }) } - - if (dto.categoryId) { - title.category_id = dto.categoryId - title.category_ancestors = categoryAncestors - } + if (dto.tags) title.tags = dto.tags title.updated_by = updatedBy @@ -205,6 +191,22 @@ export class TitlesRepository extends Repository { } } + async deleteTagFromTitle(tagName: string): Promise { + const titles = await this.find({ + where: { + tags: { $in: [tagName] } + } + }) + + titles.map(title => { + const updatedTagList = title.tags.filter(tag => tag !== tagName) + title.tags = updatedTagList + this.save(title) + }) + + return + } + async deleteTitle(titleId: string): Promise { try { const title: TitlesEntity = await this.findOneOrFail(titleId) diff --git a/server/src/shared/Services/config.service.ts b/server/src/shared/Services/config.service.ts index 71313051..eecc30dd 100755 --- a/server/src/shared/Services/config.service.ts +++ b/server/src/shared/Services/config.service.ts @@ -6,11 +6,11 @@ import * as env from 'dotenv' // Local files import { UsersEntity } from '../Entities/users.entity' -import { CategoriesEntity } from '../Entities/categories.entity' import { EntriesEntity } from '../Entities/entries.entity' import { TitlesEntity } from '../Entities/titles.entity' import { ConversationsEntity } from '../Entities/conversations.entity' import { MessagesEntity } from '../Entities/messages.entity' +import { TagsEntity } from '../Entities/tags.entity' env.config() @@ -33,7 +33,7 @@ export class ConfigService { synchronize: true, useUnifiedTopology: true, entities: [ - UsersEntity, CategoriesEntity, EntriesEntity, TitlesEntity, ConversationsEntity, MessagesEntity + UsersEntity, TagsEntity, TitlesEntity, EntriesEntity, ConversationsEntity, MessagesEntity ], ssl: false, } diff --git a/server/src/v1/Auth/Service/auth.service.ts b/server/src/v1/Auth/Service/auth.service.ts index 032b79e3..2d657016 100644 --- a/server/src/v1/Auth/Service/auth.service.ts +++ b/server/src/v1/Auth/Service/auth.service.ts @@ -85,7 +85,7 @@ export class AuthService { if (dto.rememberMe) userEntity.refresh_token = await this.usersRepository.triggerRefreshToken(dto.email || dto.username) const id: any = userEntity.id - const properties: string[] = ['id', 'password', 'recovery_key', 'refresh_token'] + const properties: string[] = ['id', 'password', 'recovery_key'] await serializerService.deleteProperties(userEntity, properties) const responseData: object = { diff --git a/server/src/v1/Category/Controller/category.controller.ts b/server/src/v1/Category/Controller/category.controller.ts deleted file mode 100644 index 0f170c5d..00000000 --- a/server/src/v1/Category/Controller/category.controller.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Nest dependencies -import { Controller, Post, Body, UseGuards, Param, Get, Delete, Patch } from '@nestjs/common' -import { AuthGuard } from '@nestjs/passport' -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger' - -// Local files -import { Roles } from 'src/shared/Decorators/roles.decorator' -import { CreateCategoryDto } from '../Dto/create-category.dto' -import { CategoryService } from '../Service/category.service' -import { UpdateCategoryDto } from '../Dto/update-category.dto' -import { ISerializeResponse } from 'src/shared/Services/serializer.service' -import { Role } from 'src/shared/Enums/Roles' -import { StatusOk } from 'src/shared/Types' - -@ApiTags('v1/category') -@Controller() -export class CategoryController { - constructor( - private readonly categoryService: CategoryService, - ) {} - - @Get(':categoryId') - getCategory(@Param('categoryId') categoryId: string): Promise { - return this.categoryService.getCategory(categoryId) - } - - @Get('main-categories') - getMainCategories(): Promise { - return this.categoryService.getMainCategories() - } - - @Get(':categoryId/child-categories') - getChildCategories(@Param('categoryId') categoryId: string): Promise { - return this.categoryService.getChildCategories(categoryId) - } - - @Get('trending-categories') - getTrendingCategories(): Promise { - return this.categoryService.getTrendingCategories() - } - - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) - @Post() - @Roles(Role.SuperAdmin) - createCategory(@Body() dto: CreateCategoryDto): Promise { - return this.categoryService.createCategory(dto) - } - - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) - @Patch(':categoryId') - @Roles(Role.SuperAdmin) - updateCategory(@Param('categoryId') categoryId: string, @Body() dto: UpdateCategoryDto): Promise { - return this.categoryService.updateCategory(categoryId, dto) - } - - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) - @Delete(':categoryId') - @Roles(Role.SuperAdmin) - deleteCategory(@Param('categoryId') categoryId: string): Promise { - return this.categoryService.deleteCategory(categoryId) - } -} diff --git a/server/src/v1/Category/Dto/create-category.dto.ts b/server/src/v1/Category/Dto/create-category.dto.ts deleted file mode 100644 index 95343e27..00000000 --- a/server/src/v1/Category/Dto/create-category.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Nest dependencies -import { ApiProperty } from '@nestjs/swagger' - -// Other dependencies -import { IsNotEmpty, MaxLength, IsOptional, IsMongoId, IsBoolean } from 'class-validator' - -export class CreateCategoryDto { - @ApiProperty({ - required: true, - example: 'Electronic', - }) - @IsNotEmpty() - @MaxLength(50) - categoryName: string - - @ApiProperty({ - required: true, - example: false - }) - @IsBoolean() - isLeaf: boolean - - @ApiProperty({ - required: false, - example: '507f1f77bcf86cd799439011', - }) - @IsMongoId() - @IsOptional() - parentCategoryId: string -} diff --git a/server/src/v1/Category/Dto/update-category.dto.ts b/server/src/v1/Category/Dto/update-category.dto.ts deleted file mode 100644 index 051c8039..00000000 --- a/server/src/v1/Category/Dto/update-category.dto.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Nest dependencies -import { ApiProperty } from '@nestjs/swagger' - -// Other dependencies -import { MaxLength, IsOptional, IsMongoId, IsBoolean } from 'class-validator' - -export class UpdateCategoryDto { - @ApiProperty({ - required: false, - example: 'Example Name', - }) - @IsOptional() - @MaxLength(50) - categoryName: string - - @ApiProperty({ - required: true, - example: false - }) - @IsOptional() - @IsBoolean() - isLeaf: boolean - - @ApiProperty({ - required: false, - example: '507f1f77bcf86cd799439011', - }) - @IsOptional() - @IsMongoId() - parentCategoryId: string -} diff --git a/server/src/v1/Category/Service/category.service.ts b/server/src/v1/Category/Service/category.service.ts deleted file mode 100644 index 8704bad7..00000000 --- a/server/src/v1/Category/Service/category.service.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Nest dependencies -import { Injectable, BadRequestException } from '@nestjs/common' -import { InjectRepository } from '@nestjs/typeorm' - -// Other dependencies -import { ObjectId } from 'mongodb' -import { Validator } from 'class-validator' - -// Local files -import { CategoriesRepository } from 'src/shared/Repositories/categories.repository' -import { CategoriesEntity } from 'src/shared/Entities/categories.entity' -import { serializerService, ISerializeResponse } from 'src/shared/Services/serializer.service' -import { CreateCategoryDto } from '../Dto/create-category.dto' -import { UpdateCategoryDto } from '../Dto/update-category.dto' -import { EntriesRepository } from 'src/shared/Repositories/entries.repository' -import { TitlesRepository } from 'src/shared/Repositories/title.repository' -import { StatusOk } from 'src/shared/Types' - -@Injectable() -export class CategoryService { - private validator: Validator - - constructor( - @InjectRepository(CategoriesRepository) - private readonly categoriesRepository: CategoriesRepository, - @InjectRepository(EntriesRepository) - private readonly entriesRepository: EntriesRepository, - @InjectRepository(TitlesRepository) - private readonly titlesRepository: TitlesRepository, - ) { - this.validator = new Validator() - } - - async getCategory(categoryId: string): Promise { - if (!this.validator.isMongoId(categoryId)) throw new BadRequestException('Id must be a type of MongoId') - - const category: CategoriesEntity = await this.categoriesRepository.getCategory(categoryId) - return serializerService.serializeResponse('category_detail', category) - } - - async getMainCategories(): Promise { - const result: {categories: CategoriesEntity[], count: number} = await this.categoriesRepository.getMainCategories() - return serializerService.serializeResponse('category_list', result) - } - - async getChildCategories(categoryId: string): Promise { - const result: {categories: CategoriesEntity[], count: number} = await this.categoriesRepository.getChildCategories(categoryId) - return serializerService.serializeResponse('category_list', result) - } - - async getTrendingCategories(): Promise { - const latestEntries = await this.entriesRepository.getLatestEntries() - - // Parse most belonged titles - const topTitlesOfLatestEntries = [...latestEntries.entries.reduce((previous, current) => { - if(!previous.has(current.title_id)) previous.set(current.title_id, {id: current.title_id, entryCount: 1}) - else previous.get(current.title_id).entryCount++ - return previous - // tslint:disable-next-line:new-parens - }, new Map).values()] - - // Sort the title list by desc of entry counts and then take first 5 of them (Because trending category count will be 5) - const topFiveTitlesIds = topTitlesOfLatestEntries.sort((x, y) => y.entryCount - x.entryCount).slice(0, 5) - - // Make flat slug list and query them to get titles belongs to that slugs - const idList = topFiveTitlesIds.map(item => ObjectId(item.id)) - const topFiveTitles = await this.titlesRepository.getTitleListByIds(idList) - - // Make flat category_id list to get trending categories - const categoryIdList = topFiveTitles.titles.map(item => ObjectId(item.category_id)) - const trendingCategories = await this.categoriesRepository.getCategoryListByIds(categoryIdList) - - return serializerService.serializeResponse('trending_categories', trendingCategories) - } - - async createCategory(dto: CreateCategoryDto): Promise { - const newCategory: CategoriesEntity = await this.categoriesRepository.createCategory(dto) - return serializerService.serializeResponse('category_detail', newCategory) - } - - async updateCategory(categoryId: string, dto: UpdateCategoryDto): Promise { - if (!this.validator.isMongoId(categoryId)) throw new BadRequestException('Id must be a type of MongoId') - - const category: CategoriesEntity = await this.categoriesRepository.updateCategory(categoryId, dto) - return serializerService.serializeResponse('category_detail', category) - } - - async deleteCategory(categoryId: string): Promise { - if (!this.validator.isMongoId(categoryId)) throw new BadRequestException('Id must be a type of MongoId') - - await this.categoriesRepository.deleteCategory(categoryId) - return { status: 'ok', message: 'Category has been deleted' } - } -} diff --git a/server/src/v1/Category/category.module.ts b/server/src/v1/Category/category.module.ts deleted file mode 100755 index 3baef380..00000000 --- a/server/src/v1/Category/category.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Nest dependencies -import { APP_GUARD } from '@nestjs/core' -import { Module } from '@nestjs/common' -import { TypeOrmModule } from '@nestjs/typeorm' - -// Local files -import { CategoriesEntity } from 'src/shared/Entities/categories.entity' -import { CategoriesRepository } from 'src/shared/Repositories/categories.repository' -import { CategoryService } from './Service/category.service' -import { CategoryController } from './Controller/category.controller' -import { EntriesRepository } from 'src/shared/Repositories/entries.repository' -import { TitlesRepository } from 'src/shared/Repositories/title.repository' -import { RolesGuard } from 'src/shared/Guards/roles.guard' - -@Module({ - imports: [ - TypeOrmModule.forFeature([CategoriesEntity, CategoriesRepository, EntriesRepository, TitlesRepository]) - ], - controllers: [CategoryController], - providers: [ - CategoryService, - { - provide: APP_GUARD, - useClass: RolesGuard - } - ], - exports: [CategoryService] -}) - -export class CategoryModule {} diff --git a/server/src/v1/Tag/tag.controller.ts b/server/src/v1/Tag/tag.controller.ts new file mode 100644 index 00000000..97c44a9b --- /dev/null +++ b/server/src/v1/Tag/tag.controller.ts @@ -0,0 +1,42 @@ +// Nest dependencies +import { Controller, Delete, Get, Param, Query, UseGuards } from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger' + +// Local files +import { ISerializeResponse } from 'src/shared/Services/serializer.service' +import { Roles } from 'src/shared/Decorators/roles.decorator' +import { Role } from 'src/shared/Enums/Roles' +import { StatusOk } from 'src/shared/Types' +import { TagService } from './tag.service' + +@ApiTags('v1/tag') +@Controller() +export class TagController { + constructor(private readonly tagService: TagService) {} + + @Get(':tagId') + getTag(@Param('tagId') tagId: string): Promise { + return this.tagService.getTag(tagId) + } + + @Get('search') + searchTag(@Query('searchValue') searchValue: string): Promise { + return this.tagService.searchTag(searchValue) + } + + @ApiBearerAuth() + @Get('trending') + getString(): Promise { + return this.tagService.getTrendingTags() + } + + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + @Delete(':tagId') + @Roles(Role.SuperAdmin) + deleteTag(@Param('tagId') tagId: string): Promise { + return this.tagService.deleteTag(tagId) + } + +} \ No newline at end of file diff --git a/server/src/v1/Tag/tag.module.ts b/server/src/v1/Tag/tag.module.ts new file mode 100644 index 00000000..6565bbb1 --- /dev/null +++ b/server/src/v1/Tag/tag.module.ts @@ -0,0 +1,23 @@ +// Nest dependencies +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +// Local files +import { TitlesRepository } from 'src/shared/Repositories/title.repository' +import { TagsRepository } from 'src/shared/Repositories/tags.repository' +import { TagController } from './tag.controller' +import { TagService } from './tag.service' + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + TagsRepository, + TitlesRepository + ]) + ], + controllers: [TagController], + providers: [TagService], + exports: [TagService] +}) + +export class TagModule {} diff --git a/server/src/v1/Tag/tag.service.ts b/server/src/v1/Tag/tag.service.ts new file mode 100644 index 00000000..bcd955a8 --- /dev/null +++ b/server/src/v1/Tag/tag.service.ts @@ -0,0 +1,72 @@ +// Nest dependencies +import { BadRequestException, Injectable, NotFoundException} from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' + +// Other dependencies +import { Validator } from 'class-validator' + +// Local files +import { ISerializeResponse, serializerService } from 'src/shared/Services/serializer.service' +import { TitlesRepository } from 'src/shared/Repositories/title.repository' +import { TagsRepository } from 'src/shared/Repositories/tags.repository' +import { TagsEntity } from 'src/shared/Entities/tags.entity' +import { StatusOk } from 'src/shared/Types' + +@Injectable() +export class TagService { + private validator: Validator + + constructor( + @InjectRepository(TagsRepository) + private readonly tagRepository: TagsRepository, + @InjectRepository(TitlesRepository) + private readonly titleRepository: TitlesRepository, + ) { + this.validator = new Validator() + } + + async getTag(tagId: string): Promise { + if (!this.validator.isMongoId(tagId)) throw new BadRequestException('Id must be a type of MongoId') + + let tag + try { + tag = await this.tagRepository.findOneOrFail(tagId) + } + catch { + throw new NotFoundException('Tag could not found by given id') + } + + return serializerService.serializeResponse('tag_detail', tag) + } + + async searchTag(searchValue: string): Promise { + if (searchValue.length < 3) throw new BadRequestException('Search value must be greater than 2 characters') + const result = await this.tagRepository.searchTag(searchValue) + return serializerService.serializeResponse('tag_search_result', result) + } + + async getTrendingTags(): Promise { + const result: { + tags: TagsEntity[], + count: number + } = await this.tagRepository.getTrendingTags() + return serializerService.serializeResponse('trending_tag_list', result) + } + + async deleteTag(tagId: string): Promise { + if (!this.validator.isMongoId(tagId)) throw new BadRequestException('Id must be a type of MongoId') + + let tag: TagsEntity | null + try { + tag = await this.tagRepository.findOneOrFail(tagId) + } + catch { + throw new NotFoundException('Tag could not found by given id') + } + + await this.titleRepository.deleteTagFromTitle(tag.name) + await this.tagRepository.deleteTag(tag) + + return { status: 'ok', message: 'Tag has been deleted' } + } +} \ No newline at end of file diff --git a/server/src/v1/Title/Controller/title.controller.ts b/server/src/v1/Title/Controller/title.controller.ts index d1ee5594..a66d27ef 100644 --- a/server/src/v1/Title/Controller/title.controller.ts +++ b/server/src/v1/Title/Controller/title.controller.ts @@ -54,12 +54,12 @@ export class TitleController { getTitleList( @Query() query: { author: string, - categoryIds: any, + tags: any, sortBy: 'hot' | 'top', skip: number, } ): Promise { - if (query.categoryIds) query.categoryIds = query.categoryIds.split(',') + if (query.tags?.split) query.tags = query.tags.split(',') return this.titleService.getTitleList(query) } diff --git a/server/src/v1/Title/Dto/create-title.dto.ts b/server/src/v1/Title/Dto/create-title.dto.ts index 3dde33bd..5aafd84a 100644 --- a/server/src/v1/Title/Dto/create-title.dto.ts +++ b/server/src/v1/Title/Dto/create-title.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger' // Other dependencies -import { IsNotEmpty, IsMongoId, Length } from 'class-validator' +import { IsNotEmpty, Length, IsArray, MinLength, NotContains } from 'class-validator' export class CreateTitleDto { @ApiProperty({ @@ -15,8 +15,14 @@ export class CreateTitleDto { @ApiProperty({ required: true, - example: '507f1f77bcf86cd799439011', + example: '["electronics", "phone", "samsung"]' }) - @IsMongoId() - categoryId: string + @NotContains(' ', { + each: true, + }) + @MinLength(3, { + each: true, + }) + @IsArray() + tags: string[] } diff --git a/server/src/v1/Title/Dto/update-title.dto.ts b/server/src/v1/Title/Dto/update-title.dto.ts index b76d5dec..9852d89d 100644 --- a/server/src/v1/Title/Dto/update-title.dto.ts +++ b/server/src/v1/Title/Dto/update-title.dto.ts @@ -2,22 +2,28 @@ import { ApiProperty } from '@nestjs/swagger' // Other dependencies -import { IsNotEmpty, IsMongoId, MaxLength, IsOptional } from 'class-validator' +import { IsNotEmpty, MaxLength, IsOptional, MinLength, NotContains } from 'class-validator' export class UpdateTitleDto { @ApiProperty({ required: true, example: 'Phone Y', }) + @IsOptional() @IsNotEmpty() @MaxLength(60) name: string @ApiProperty({ required: false, - example: '507f1f77bcf86cd799439011', + example: '"electronics", "phone", "samsung"' + }) + @NotContains(' ', { + each: true, + }) + @MinLength(3, { + each: true, }) @IsOptional() - @IsMongoId() - categoryId: string + tags: string[] } diff --git a/server/src/v1/Title/Service/title.service.ts b/server/src/v1/Title/Service/title.service.ts index 642b98ed..841e135c 100644 --- a/server/src/v1/Title/Service/title.service.ts +++ b/server/src/v1/Title/Service/title.service.ts @@ -10,16 +10,15 @@ import { createReadStream } from 'fs' import { TitlesRepository } from 'src/shared/Repositories/title.repository' import { CreateTitleDto } from '../Dto/create-title.dto' import { TitlesEntity } from 'src/shared/Entities/titles.entity' -import { CategoriesRepository } from 'src/shared/Repositories/categories.repository' import { UpdateTitleDto } from '../Dto/update-title.dto' import { serializerService, ISerializeResponse } from 'src/shared/Services/serializer.service' import { EntriesRepository } from 'src/shared/Repositories/entries.repository' import { UsersRepository } from 'src/shared/Repositories/users.repository' -import { CategoriesEntity } from 'src/shared/Entities/categories.entity' import { AwsService } from 'src/shared/Services/aws.service' import { configService } from 'src/shared/Services/config.service' import { sitemapManipulationService } from 'src/shared/Services/sitemap.manipulation.service' import { StatusOk } from 'src/shared/Types' +import { TagsRepository } from 'src/shared/Repositories/tags.repository' @Injectable() export class TitleService { @@ -28,8 +27,8 @@ export class TitleService { constructor( @InjectRepository(TitlesRepository) private readonly titlesRepository: TitlesRepository, - @InjectRepository(CategoriesRepository) - private readonly categoriesRepository: CategoriesRepository, + @InjectRepository(TagsRepository) + private readonly tagsRepository: TagsRepository, @InjectRepository(EntriesRepository) private readonly entriesRepository: EntriesRepository, @InjectRepository(UsersRepository) @@ -61,7 +60,7 @@ export class TitleService { async getTitleList( query: { author: string, - categoryIds: string[], + tags: string[], sortBy: 'hot' | 'top', skip: number, } @@ -73,34 +72,30 @@ export class TitleService { return serializerService.serializeResponse('title_list', result) } - async createTitle(openedBy: string, payload: CreateTitleDto, buffer: Buffer): Promise { + async createTitle(openedBy: string, payload: any, buffer: Buffer): Promise { payload.name = payload.name.replace(/^\s+|\s+$/g, '') if (payload.name.length === 0) throw new BadRequestException('Title name can not be whitespace') + if (!payload.tags) throw new BadRequestException('tags must be provided') const dto = new CreateTitleDto() dto.name = payload.name - dto.categoryId = payload.categoryId + dto.tags = payload.tags.split(',') - return await validate(dto, { validationError: { target: false } }).then(async errors => { + const result = await validate(dto, { validationError: { target: false } }).then(async errors => { if (errors.length > 0) { throw new BadRequestException(errors) } - let category: CategoriesEntity - try { - category = await this.categoriesRepository.findOneOrFail(dto.categoryId) - } catch (err) { - throw new NotFoundException('Category could not found by given id') - } - - if (!category.is_leaf) throw new BadRequestException('Category that is not leaf can not have titles') - - const newTitle: TitlesEntity = await this.titlesRepository.createTitle(openedBy, dto, category.ancestors) + const newTitle: TitlesEntity = await this.titlesRepository.createTitle(openedBy, dto) if (buffer) this.awsService.uploadImage(String(newTitle.id), 'titles', buffer) - if (configService.isProduction()) sitemapManipulationService.addToIndexedSitemap(newTitle.slug, new Date().toJSON().slice(0,10)) + if (configService.isProduction()) sitemapManipulationService.addToIndexedSitemap(newTitle.slug, new Date().toJSON().slice(0, 10)) return serializerService.serializeResponse('title_detail', newTitle) }) + + dto.tags.forEach(async element => this.tagsRepository.tagActionOnTitleCreateOrUpdate(element)) + + return result } async getTitleImage(titleId: string): Promise { @@ -154,8 +149,8 @@ export class TitleService { } async updateTitle(updatedBy: string, titleId: string, dto: UpdateTitleDto): Promise { - dto.name = dto.name.replace(/^\s+|\s+$/g, '') - if (dto.name.length === 0) throw new BadRequestException('Title name can not be whitespace') + if (dto.name) dto.name = dto.name.replace(/^\s+|\s+$/g, '') + if (dto.name?.length === 0) throw new BadRequestException('Title name can not be whitespace') if (!this.validator.isMongoId(titleId)) throw new BadRequestException('Id must be a type of MongoId') @@ -166,21 +161,13 @@ export class TitleService { throw new NotFoundException('Title could not found by given id') } - if (dto.categoryId && !this.validator.isMongoId(dto.categoryId)) { - throw new BadRequestException('Id must be a type of MongoId') - } - - let category - if (dto.categoryId) { - try { - category = await this.categoriesRepository.findOneOrFail(dto.categoryId) - } catch (err) { - throw new NotFoundException('Category could not found by given id') - } + if (dto.tags) { + dto.tags.forEach(async element => this.tagsRepository.tagActionOnTitleCreateOrUpdate(element)) + title.tags.forEach(async element => this.tagsRepository.updateTagWhenRemovedFromTitle(element)) } - const updatedTitle: TitlesEntity = await this.titlesRepository.updateTitle(updatedBy, title, dto, category?.ancestors) - if (configService.isProduction()) sitemapManipulationService.addToIndexedSitemap(updatedTitle.slug, new Date().toJSON().slice(0,10)) + const updatedTitle: TitlesEntity = await this.titlesRepository.updateTitle(updatedBy, title, dto) + if (configService.isProduction()) sitemapManipulationService.addToIndexedSitemap(updatedTitle.slug, new Date().toJSON().slice(0, 10)) return serializerService.serializeResponse('title_detail', updatedTitle) } diff --git a/server/src/v1/Title/title.module.ts b/server/src/v1/Title/title.module.ts index be24f781..da1d55d0 100755 --- a/server/src/v1/Title/title.module.ts +++ b/server/src/v1/Title/title.module.ts @@ -7,17 +7,17 @@ import { TypeOrmModule } from '@nestjs/typeorm' import { TitlesEntity } from 'src/shared/Entities/titles.entity' import { TitlesRepository } from 'src/shared/Repositories/title.repository' import { TitleService } from './Service/title.service' -import { CategoriesRepository } from 'src/shared/Repositories/categories.repository' import { TitleController } from './Controller/title.controller' import { EntriesRepository } from 'src/shared/Repositories/entries.repository' import { UsersRepository } from 'src/shared/Repositories/users.repository' +import { TagsRepository } from 'src/shared/Repositories/tags.repository' import { AwsService } from 'src/shared/Services/aws.service' import { RolesGuard } from 'src/shared/Guards/roles.guard' @Module({ imports: [ TypeOrmModule.forFeature([ - TitlesEntity, TitlesRepository, CategoriesRepository, UsersRepository, EntriesRepository + TitlesEntity, TagsRepository, TitlesRepository, UsersRepository, EntriesRepository ]) ], controllers: [TitleController], diff --git a/server/src/v1/v1.module.ts b/server/src/v1/v1.module.ts index fff688b2..0017734c 100644 --- a/server/src/v1/v1.module.ts +++ b/server/src/v1/v1.module.ts @@ -4,20 +4,20 @@ import { Module, MiddlewareConsumer } from '@nestjs/common' // Local files import { BlacklistMiddleware } from 'src/shared/Middleware/blacklist.middleware' import { RedisService } from 'src/shared/Services/redis.service' -import { UserModule } from './User/user.module' import { AuthModule } from './Auth/auth.module' -import { CategoryModule } from './Category/category.module' -import { EntryModule } from './Entry/entry.module' +import { UserModule } from './User/user.module' +import { TagModule } from './Tag/tag.module' import { TitleModule } from './Title/title.module' +import { EntryModule } from './Entry/entry.module' import { MessageModule } from './Message/message.module' @Module({ imports: [ - UserModule, AuthModule, - CategoryModule, - EntryModule, + UserModule, + TagModule, TitleModule, + EntryModule, MessageModule ], providers: [RedisService], diff --git a/server/src/version.routes.ts b/server/src/version.routes.ts index 737876c8..c7247284 100644 --- a/server/src/version.routes.ts +++ b/server/src/version.routes.ts @@ -4,11 +4,11 @@ import { Routes } from 'nest-router' // Local files import { V1Module } from './v1/v1.module' import { AuthModule } from './v1/Auth/auth.module' -import { CategoryModule } from './v1/Category/category.module' import { EntryModule } from './v1/Entry/entry.module' import { UserModule } from './v1/User/user.module' import { TitleModule } from './v1/Title/title.module' import { MessageModule } from './v1/Message/message.module' +import { TagModule } from './v1/Tag/tag.module' export const versionRoutes: Routes = [ { @@ -20,25 +20,25 @@ export const versionRoutes: Routes = [ module: AuthModule, }, { - path: '/category', - module: CategoryModule, + path: '/user', + module: UserModule, }, { - path: '/entry', - module: EntryModule, + path: '/tag', + module: TagModule }, { path: '/title', module: TitleModule, }, { - path: '/user', - module: UserModule, + path: '/entry', + module: EntryModule, }, { path: '/message', module: MessageModule, - }, + } ], }, ]