nestapp/src/modules/content/services/post.service.ts
2025-06-01 21:24:43 +08:00

223 lines
8.6 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { isNil } from '@nestjs/common/utils/shared.utils';
import { isArray, isFunction, omit, pick } from 'lodash';
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostOrder } from '@/modules/content/constants';
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { CategoryRepository } from '@/modules/content/repositories';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { SearchService } from '@/modules/content/services/search.service';
import { SearchType } from '@/modules/content/types';
import { SelectTrashMode } from '@/modules/database/constants';
import { QueryHook } from '@/modules/database/types';
import { paginate } from '@/modules/database/utils';
import { TagRepository } from '../repositories/tag.repository';
import { CategoryService } from './category.service';
type FindParams = {
[key in keyof Omit<QueryPostDto, 'limit' | 'page'>]: QueryPostDto[key];
};
@Injectable()
export class PostService {
constructor(
protected repository: PostRepository,
protected categoryRepository: CategoryRepository,
protected categoryService: CategoryService,
protected tagRepository: TagRepository,
protected searchService?: SearchService,
protected searchType: SearchType = 'mysql',
) {}
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
if (!isNil(this.searchService) && !isNil(options.search) && this.searchType === 'meili') {
return this.searchService.search(
options.search,
pick(options, ['trashed', 'page', 'limit']),
);
}
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
return paginate(qb, options);
}
async detail(id: string, callback?: QueryHook<PostEntity>) {
let qb = this.repository.buildBaseQB();
qb.where(`post.id = :id`, { id });
qb = !isNil(callback) && isFunction(callback) ? await callback(qb) : qb;
const item = await qb.getOne();
if (!item) {
throw new EntityNotFoundError(PostEntity, `The post ${id} not exists!`);
}
return item;
}
async create(data: CreatePostDto) {
let publishedAt: Date | null;
if (!isNil(data.publish)) {
publishedAt = data.publish ? new Date() : null;
}
const createPostDto = {
...omit(data, ['publish']),
category: isNil(data.category)
? null
: await this.categoryRepository.findOneOrFail({ where: { id: data.category } }),
tags: isArray(data.tags) ? await this.tagRepository.findBy({ id: In(data.tags) }) : [],
publishedAt,
};
const item = await this.repository.save(createPostDto);
const result = await this.detail(item.id);
if (!isNil(this.searchService)) {
await this.searchService.create(result);
}
return result;
}
async update(data: UpdatePostDto) {
let publishedAt: Date | null;
if (!isNil(data.publish)) {
publishedAt = data.publish ? new Date() : null;
}
const post = await this.detail(data.id);
if (data.category !== undefined) {
post.category = isNil(data.category)
? null
: await this.categoryRepository.findOneByOrFail({ id: data.category });
await this.repository.save(post, { reload: true });
}
if (isArray(data.tags)) {
await this.repository
.createQueryBuilder('post')
.relation(PostEntity, 'tags')
.of(post)
.addAndRemove(data.tags, post.tags ?? []);
}
await this.repository.update(data.id, {
...omit(data, ['id', 'publish', 'tags', 'category']),
publishedAt,
});
const result = await this.detail(data.id);
if (!isNil(this.searchService)) {
await this.searchService.update([result]);
}
return result;
}
async delete(ids: string[], trash?: boolean) {
const items = await this.repository
.buildBaseQB()
.where('post.id IN (:...ids)', { ids })
.withDeleted()
.getMany();
let result: PostEntity[];
if (trash) {
const directs = items.filter((item) => !isNil(item.deleteAt));
const softs = items.filter((item) => isNil(item.deleteAt));
result = [
...(await this.repository.remove(directs)),
...(await this.repository.softRemove(softs)),
];
if (!isNil(this.searchService)) {
await this.searchService.delete(directs.map((item) => item.id));
await this.searchService.update(softs);
}
} else {
result = await this.repository.remove(items);
if (!isNil(this.searchService)) {
await this.searchService.delete(ids);
}
}
return result;
}
async restore(ids: string[]) {
const items = await this.repository
.buildBaseQB()
.where('post.id IN (:...ids)', { ids })
.withDeleted()
.getMany();
const trashes = items.filter((item) => !isNil(item.deleteAt));
await this.searchService.update(trashes);
const trashedIds = trashes.map((item) => item.id);
if (trashedIds.length < 1) {
return [];
}
await this.repository.restore(trashedIds);
const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) =>
qbuilder.andWhereInIds(trashedIds),
);
return qb.getMany();
}
protected async buildListQuery(
qb: SelectQueryBuilder<PostEntity>,
options: FindParams,
callback?: QueryHook<PostEntity>,
) {
const { orderBy, isPublished, category, tag, trashed } = options;
if (typeof isPublished === 'boolean') {
isPublished
? qb.where({ publishedAt: Not(IsNull()) })
: qb.where({ publishedAt: IsNull() });
}
if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) {
qb.withDeleted();
if (trashed === SelectTrashMode.ONLY) {
qb.where('post.deletedAt is not null');
}
}
this.queryOrderBy(qb, orderBy);
if (category) {
await this.queryByCategory(category, qb);
}
if (tag) {
qb.where('tags.id = :id', { id: tag });
}
if (callback) {
return callback(qb);
}
return qb;
}
protected buildSearchQuery(qb: SelectQueryBuilder<PostEntity>, search: string) {
qb.orWhere('title LIKE :search', { search: `%${search}%` })
.orWhere('summary LIKE :search', { search: `%${search}%` })
.orWhere('body LIKE :search', { search: `%${search}%` })
.orWhere('category.name LIKE :search', { search: `%${search}%` })
.orWhere('tags.name LIKE :search', { search: `%${search}%` });
return qb;
}
protected queryOrderBy(qb: SelectQueryBuilder<PostEntity>, orderBy?: PostOrder) {
switch (orderBy) {
case PostOrder.CREATED:
return qb.orderBy('post.createdAt', 'DESC');
case PostOrder.UPDATED:
return qb.orderBy('post.updatedAt', 'DESC');
case PostOrder.PUBLISHED:
return qb.orderBy('post.publishedAt', 'DESC');
case PostOrder.CUSTOM:
return qb.orderBy('post.customOrder', 'DESC');
case PostOrder.COMMENTCOUNT:
return qb.orderBy('post.commentCount', 'DESC');
default:
return qb
.orderBy('post.createdAt', 'DESC')
.addOrderBy('post.updatedAt', 'DESC')
.addOrderBy('post.publishedAt', 'DESC');
}
}
protected async queryByCategory(id: string, qb: SelectQueryBuilder<PostEntity>) {
const root = await this.categoryService.detail(id);
const tree = await this.categoryRepository.findDescendantsTree(root);
const flatDes = await this.categoryRepository.toFlatTrees(tree.children);
const ids = [tree.id, ...flatDes.map((item) => item.id)];
return qb.where('categoryRepository.id IN (:...ids)', { ids });
}
}