diff --git a/src/modules/content/repositories/category.repository.ts b/src/modules/content/repositories/category.repository.ts index 17874eb..5b7e995 100644 --- a/src/modules/content/repositories/category.repository.ts +++ b/src/modules/content/repositories/category.repository.ts @@ -1,4 +1,4 @@ -import { pick, unset } from 'lodash'; +import { isNil, pick, unset } from 'lodash'; import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm'; import { CategoryEntity } from '@/modules/content/entities/category.entity'; @@ -111,4 +111,17 @@ export class CategoryRepository extends TreeRepository { } return data as CategoryEntity[]; } + + async flatAncestorsTree(item: CategoryEntity) { + let data: Omit[] = []; + const category = await this.findAncestorsTree(item); + const { parent } = category; + unset(category, 'children'); + unset(category, 'item'); + data.push(item); + if (!isNil(parent)) { + data = [...(await this.flatAncestorsTree(parent)), ...data]; + } + return data as CategoryEntity[]; + } } diff --git a/src/modules/content/services/search.service.ts b/src/modules/content/services/search.service.ts new file mode 100644 index 0000000..8ca2596 --- /dev/null +++ b/src/modules/content/services/search.service.ts @@ -0,0 +1,94 @@ +import { ForbiddenException, OnModuleInit } from '@nestjs/common'; +import { isNil, omit } from 'lodash'; +import { MeiliSearch } from 'meilisearch'; + +import { PostEntity } from '@/modules/content/entities'; +import { + CategoryRepository, + CommentRepository, + PostRepository, +} from '@/modules/content/repositories'; +import { SearchOption } from '@/modules/content/types'; +import { getSearchData, getSearchItem } from '@/modules/content/utils'; +import { SelectTrashMode } from '@/modules/database/constants'; +import { MeiliService } from '@/modules/meilisearch/meili.service'; + +export class SearchService implements OnModuleInit { + index = 'content'; + + protected client: MeiliSearch; + + constructor( + protected meiliService: MeiliService, + protected categoryRepository: CategoryRepository, + protected commentRepository: CommentRepository, + protected postRepository: PostRepository, + ) { + this.client = this.meiliService.getClient(); + } + + async onModuleInit(): Promise { + await this.client.deleteIndex(this.index); + this.client.index(this.index).updateFilterableAttributes(['deleteAt', 'publishedAt']); + this.client.index(this.index).updateSortableAttributes(['updatedAt', 'commentCount']); + const posts = await this.postRepository.buildBaseQB().withDeleted().getMany(); + await this.client + .index(this.index) + .addDocuments( + await getSearchData(posts, this.categoryRepository, this.commentRepository), + ); + } + + getClient() { + if (isNil(this.client)) { + throw new ForbiddenException('Has no meili search client!'); + } + return this.client; + } + + async search(text: string, param: SearchOption = {}) { + const option = { page: 1, limit: 10, trashed: SelectTrashMode.NONE, ...param }; + const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit; + const page = isNil(option.page) || option.page < 1 ? 1 : option.page; + let filter = ['deletedAt IS NULL']; + if (option.trashed === SelectTrashMode.ALL) { + filter = []; + } else if (option.trashed === SelectTrashMode.ONLY) { + filter = ['deletedAt IS NOT NULL']; + } + if (option.isPublished) { + filter.push('publishedAt IS NOT NULL'); + } + const result = await this.client + .index(this.index) + .search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter }); + return { + item: result.hits, + currentPage: result.page, + perPage: result.hitsPerPage, + totalItems: result.estimatedTotalHits, + itemCount: result.totalHits, + ...omit(result, ['hits', 'page', 'hitsPerPage', 'estimatedTotalHits', 'totalHits']), + }; + } + + async create(post: PostEntity) { + return this.getClient() + .index(this.index) + .addDocuments( + await getSearchItem(this.categoryRepository, this.commentRepository, post), + ); + } + + async update(posts: PostEntity[]) { + return this.getClient() + .index(this.index) + .updateDocuments( + await getSearchData(posts, this.categoryRepository, this.commentRepository), + ); + } + + async delete(ids: string[]) { + return this.getClient().index(this.index).deleteDocuments(ids); + } +} diff --git a/src/modules/content/types.ts b/src/modules/content/types.ts index 5c42908..dc9daef 100644 --- a/src/modules/content/types.ts +++ b/src/modules/content/types.ts @@ -1,5 +1,14 @@ +import { SelectTrashMode } from '@/modules/database/constants'; + export type SearchType = 'mysql'; export interface ContentConfig { SearchType?: SearchType; } + +export interface SearchOption { + trashed?: SelectTrashMode; + isPublished?: boolean; + page?: number; + limit?: number; +} diff --git a/src/modules/content/utils.ts b/src/modules/content/utils.ts new file mode 100644 index 0000000..b589bd0 --- /dev/null +++ b/src/modules/content/utils.ts @@ -0,0 +1,53 @@ +import { instanceToPlain } from 'class-transformer'; +import { isNil, pick } from 'lodash'; + +import { PostEntity } from '@/modules/content/entities'; +import { CategoryRepository, CommentRepository } from '@/modules/content/repositories'; + +export async function getSearchItem( + categoryRepository: CategoryRepository, + commentRepository: CommentRepository, + post: PostEntity, +) { + const categories = isNil(post.category) + ? [] + : (await categoryRepository.flatAncestorsTree(post.category)).map((item) => ({ + id: item.id, + name: item.name, + })); + const comments = ( + await commentRepository.find({ + relations: ['post'], + where: { post: { id: post.id } }, + }) + ).map((item) => ({ id: item.id, name: item.body })); + return [ + { + ...pick(instanceToPlain(post), [ + 'id', + 'title', + 'body', + 'summary', + 'commentCount', + 'deleteAt', + 'publishedAt', + 'createdAt', + 'updatedAt', + ]), + categories, + comments, + tags: post.tags.map((item) => ({ id: item.id, name: item.name })), + }, + ]; +} + +export const getSearchData = async ( + posts: PostEntity[], + categoryRepository: CategoryRepository, + commentRepository: CommentRepository, +) => + ( + await Promise.all( + posts.map((post) => getSearchItem(categoryRepository, commentRepository, post)), + ) + ).reduce((o, n) => [...o, ...n], []);