Compare commits
10 Commits
cbb3a6e7de
...
db465b70c8
Author | SHA1 | Date | |
---|---|---|---|
db465b70c8 | |||
dca8ed67af | |||
e3f3497406 | |||
af6906f232 | |||
552cbf2e57 | |||
619a59e33e | |||
b25a85bf06 | |||
4c063515ba | |||
8ab109ce26 | |||
388ab50718 |
18
.vscode/launch.json
vendored
Normal file
18
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// 使用 IntelliSense 了解相关属性。
|
||||||
|
// 悬停以查看现有属性的描述。
|
||||||
|
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "debug 3r",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": ["run-script", "start:debug"],
|
||||||
|
"autoAttachChildProcesses": true,
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"type": "node"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"javascript.preferences.importModuleSpecifier": "project-relative",
|
||||||
|
"typescript.suggest.jsdoc.generateReturns": false,
|
||||||
|
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"eslint.packageManager": "pnpm",
|
||||||
|
"stylelint.packageManager": "pnpm",
|
||||||
|
"npm.packageManager": "pnpm"
|
||||||
|
}
|
@ -7,5 +7,14 @@ export enum PostOrder {
|
|||||||
CREATED = 'createdAt',
|
CREATED = 'createdAt',
|
||||||
UPDATED = 'updatedAt',
|
UPDATED = 'updatedAt',
|
||||||
PUBLISHED = 'publishedAt',
|
PUBLISHED = 'publishedAt',
|
||||||
|
COMMENTCOUNT = 'commentCount',
|
||||||
CUSTOM = 'custom',
|
CUSTOM = 'custom',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_VALIDATION_CONFIG = Object.freeze({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
});
|
||||||
|
@ -2,23 +2,25 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
import * as controllers from '@/modules/content/controllers';
|
||||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
import * as entities from '@/modules/content/entities';
|
||||||
|
import * as repositories from '@/modules/content/repositories';
|
||||||
|
import * as services from '@/modules/content/services';
|
||||||
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
||||||
import { PostService } from '@/modules/content/services/post.service';
|
|
||||||
|
|
||||||
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
|
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
|
||||||
import { DatabaseModule } from '@/modules/database/database.module';
|
import { DatabaseModule } from '@/modules/database/database.module';
|
||||||
|
|
||||||
import { PostController } from './controllers/post.controller';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([PostEntity]),
|
TypeOrmModule.forFeature(Object.values(entities)),
|
||||||
DatabaseModule.forRepository([PostRepository]),
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
|
],
|
||||||
|
controllers: Object.values(controllers),
|
||||||
|
providers: [...Object.values(services), PostSubscriber, SanitizeService],
|
||||||
|
exports: [
|
||||||
|
...Object.values(services),
|
||||||
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
],
|
],
|
||||||
controllers: [PostController],
|
|
||||||
providers: [PostService, PostSubscriber, SanitizeService],
|
|
||||||
exports: [PostService, DatabaseModule.forRepository([PostRepository])],
|
|
||||||
})
|
})
|
||||||
export class ContentModule {}
|
export class ContentModule {}
|
||||||
|
81
src/modules/content/controllers/category.controller.ts
Normal file
81
src/modules/content/controllers/category.controller.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
UseInterceptors,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
|
||||||
|
|
||||||
|
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
|
||||||
|
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
|
||||||
|
import { CategoryService } from '../services';
|
||||||
|
|
||||||
|
@UseInterceptors(AppInterceptor)
|
||||||
|
@Controller('category')
|
||||||
|
export class CategoryController {
|
||||||
|
constructor(protected service: CategoryService) {}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
@SerializeOptions({ groups: ['category-tree'] })
|
||||||
|
async tree() {
|
||||||
|
return this.service.findTrees();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['category-list'] })
|
||||||
|
async list(
|
||||||
|
@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG))
|
||||||
|
options: QueryCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async store(
|
||||||
|
@Body(
|
||||||
|
new ValidationPipe({
|
||||||
|
...DEFAULT_VALIDATION_CONFIG,
|
||||||
|
groups: ['create'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data: CreateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async update(
|
||||||
|
@Body(
|
||||||
|
new ValidationPipe({
|
||||||
|
...DEFAULT_VALIDATION_CONFIG,
|
||||||
|
groups: ['update'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data: UpdateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.delete(id);
|
||||||
|
}
|
||||||
|
}
|
58
src/modules/content/controllers/comment.controller.ts
Normal file
58
src/modules/content/controllers/comment.controller.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
UseInterceptors,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
|
||||||
|
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
|
||||||
|
|
||||||
|
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
|
||||||
|
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '../dtos/comment.dto';
|
||||||
|
import { CommentService } from '../services';
|
||||||
|
|
||||||
|
@Controller('comment')
|
||||||
|
@UseInterceptors(AppInterceptor)
|
||||||
|
export class CommentController {
|
||||||
|
constructor(protected service: CommentService) {}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
@SerializeOptions({ groups: ['comment-tree'] })
|
||||||
|
async tree(@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG)) options: QueryCommentTreeDto) {
|
||||||
|
return this.service.findTrees(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['comment-list'] })
|
||||||
|
async list(
|
||||||
|
@Query(
|
||||||
|
new ValidationPipe({
|
||||||
|
...pick(DEFAULT_VALIDATION_CONFIG, ['forbidNonWhitelisted', 'whitelist']),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
options: QueryCommentDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@SerializeOptions({ groups: ['comment-detail'] })
|
||||||
|
async store(@Body(new ValidationPipe(DEFAULT_VALIDATION_CONFIG)) data: CreateCommentDto) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@SerializeOptions({ groups: ['comment-detail'] })
|
||||||
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.delete(id);
|
||||||
|
}
|
||||||
|
}
|
4
src/modules/content/controllers/index.ts
Normal file
4
src/modules/content/controllers/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './category.controller';
|
||||||
|
export * from './tag.controller';
|
||||||
|
export * from './post.controller';
|
||||||
|
export * from './comment.controller';
|
@ -17,6 +17,8 @@ import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dt
|
|||||||
import { PostService } from '@/modules/content/services/post.service';
|
import { PostService } from '@/modules/content/services/post.service';
|
||||||
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
|
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
|
||||||
|
|
||||||
|
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
|
||||||
|
|
||||||
@UseInterceptors(AppInterceptor)
|
@UseInterceptors(AppInterceptor)
|
||||||
@Controller('posts')
|
@Controller('posts')
|
||||||
export class PostController {
|
export class PostController {
|
||||||
@ -25,15 +27,7 @@ export class PostController {
|
|||||||
@Get()
|
@Get()
|
||||||
@SerializeOptions({ groups: ['post-list'] })
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
async list(
|
async list(
|
||||||
@Query(
|
@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG))
|
||||||
new ValidationPipe({
|
|
||||||
transform: true,
|
|
||||||
whitelist: true,
|
|
||||||
forbidUnknownValues: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
validationError: { target: false },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
options: QueryPostDto,
|
options: QueryPostDto,
|
||||||
) {
|
) {
|
||||||
return this.postService.paginate(options);
|
return this.postService.paginate(options);
|
||||||
@ -50,11 +44,7 @@ export class PostController {
|
|||||||
async store(
|
async store(
|
||||||
@Body(
|
@Body(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
transform: true,
|
...DEFAULT_VALIDATION_CONFIG,
|
||||||
whitelist: true,
|
|
||||||
forbidUnknownValues: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
validationError: { target: false },
|
|
||||||
groups: ['create'],
|
groups: ['create'],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ -68,11 +58,7 @@ export class PostController {
|
|||||||
async update(
|
async update(
|
||||||
@Body(
|
@Body(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
transform: true,
|
...DEFAULT_VALIDATION_CONFIG,
|
||||||
whitelist: true,
|
|
||||||
forbidUnknownValues: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
validationError: { target: false },
|
|
||||||
groups: ['update'],
|
groups: ['update'],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
65
src/modules/content/controllers/tag.controller.ts
Normal file
65
src/modules/content/controllers/tag.controller.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
UseInterceptors,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
|
||||||
|
|
||||||
|
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
|
||||||
|
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '../dtos/tag.dto';
|
||||||
|
import { TagService } from '../services';
|
||||||
|
|
||||||
|
@Controller('tag')
|
||||||
|
@UseInterceptors(AppInterceptor)
|
||||||
|
export class TagController {
|
||||||
|
constructor(protected service: TagService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({})
|
||||||
|
async list(
|
||||||
|
@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG))
|
||||||
|
options: QueryTagDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@SerializeOptions({})
|
||||||
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@SerializeOptions({})
|
||||||
|
async store(
|
||||||
|
@Body(new ValidationPipe({ ...DEFAULT_VALIDATION_CONFIG, groups: ['create'] }))
|
||||||
|
data: CreateTagDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@SerializeOptions({})
|
||||||
|
async update(
|
||||||
|
@Body(new ValidationPipe({ ...DEFAULT_VALIDATION_CONFIG, groups: ['update'] }))
|
||||||
|
date: UpdateTagDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@SerializeOptions({})
|
||||||
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.delete(id);
|
||||||
|
}
|
||||||
|
}
|
@ -43,7 +43,7 @@ export class CreateCategoryDto {
|
|||||||
})
|
})
|
||||||
@ValidateIf((value) => value.parent !== null && value.parent)
|
@ValidateIf((value) => value.parent !== null && value.parent)
|
||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
@Transform((value) => (value === 'null' ? null : value))
|
@Transform(({ value }) => (value === 'null' ? null : value))
|
||||||
parent?: string;
|
parent?: string;
|
||||||
|
|
||||||
@Transform(({ value }) => toNumber(value))
|
@Transform(({ value }) => toNumber(value))
|
||||||
|
@ -11,6 +11,8 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { toNumber } from 'lodash';
|
import { toNumber } from 'lodash';
|
||||||
|
|
||||||
|
import { PaginateOptions } from '@/modules/database/types';
|
||||||
|
|
||||||
export class QueryTagDto implements PaginateOptions {
|
export class QueryTagDto implements PaginateOptions {
|
||||||
@Transform(({ value }) => toNumber(value))
|
@Transform(({ value }) => toNumber(value))
|
||||||
@Min(1, { message: 'The current page must be greater than 1.' })
|
@Min(1, { message: 'The current page must be greater than 1.' })
|
||||||
|
4
src/modules/content/entities/index.ts
Normal file
4
src/modules/content/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './category.entity';
|
||||||
|
export * from './comment.entity';
|
||||||
|
export * from './post.entity';
|
||||||
|
export * from './tag.entity';
|
@ -13,7 +13,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
import { PostBodyType } from '@/modules/content/constants';
|
import { PostBodyType } from '@/modules/content/constants';
|
||||||
import { CategoryEntity } from '@/modules/content/entities/CategoryEntity';
|
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||||
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
||||||
import { TagEntity } from '@/modules/content/entities/tag.entity';
|
import { TagEntity } from '@/modules/content/entities/tag.entity';
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { pick, unset } from 'lodash';
|
import { pick, unset } from 'lodash';
|
||||||
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
|
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
|
||||||
|
|
||||||
import { CategoryEntity } from '@/modules/content/entities/CategoryEntity';
|
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||||
|
|
||||||
@CustomRepository(CategoryEntity)
|
@CustomRepository(CategoryEntity)
|
||||||
|
4
src/modules/content/repositories/index.ts
Normal file
4
src/modules/content/repositories/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './category.repository';
|
||||||
|
export * from './tag.repository';
|
||||||
|
export * from './post.repository';
|
||||||
|
export * from './comment.repository';
|
93
src/modules/content/services/category.service.ts
Normal file
93
src/modules/content/services/category.service.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { isNil, omit } from 'lodash';
|
||||||
|
import { EntityNotFoundError } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateCategoryDto,
|
||||||
|
QueryCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
} from '@/modules/content/dtos/category.dto';
|
||||||
|
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||||
|
import { CategoryRepository } from '@/modules/content/repositories/category.repository';
|
||||||
|
import { treePaginate } from '@/modules/database/utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoryService {
|
||||||
|
constructor(protected repository: CategoryRepository) {}
|
||||||
|
|
||||||
|
async findTrees() {
|
||||||
|
return this.repository.findTrees();
|
||||||
|
}
|
||||||
|
|
||||||
|
async paginate(options?: QueryCategoryDto) {
|
||||||
|
const tree = await this.findTrees();
|
||||||
|
const data = await this.repository.toFlatTrees(tree);
|
||||||
|
return treePaginate(options, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string) {
|
||||||
|
return this.repository.findOneOrFail({ where: { id }, relations: ['parent'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateCategoryDto) {
|
||||||
|
const item = await this.repository.save({
|
||||||
|
...data,
|
||||||
|
parent: await this.getParent(undefined, data.parent),
|
||||||
|
});
|
||||||
|
return this.detail(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(data: UpdateCategoryDto) {
|
||||||
|
await this.repository.update(data.id, omit(data, ['id', 'parent']));
|
||||||
|
const item = await this.repository.findOneOrFail({
|
||||||
|
where: { id: data.id },
|
||||||
|
relations: ['parent'],
|
||||||
|
});
|
||||||
|
const parent = await this.getParent(item.parent?.id, data.parent);
|
||||||
|
const shouldUpdateParent =
|
||||||
|
(!isNil(item.parent) && !isNil(parent) && item.parent.id !== parent.id) ||
|
||||||
|
(!isNil(item.parent) && !isNil(parent)) ||
|
||||||
|
(!isNil(item.parent) && isNil(parent));
|
||||||
|
|
||||||
|
if (shouldUpdateParent && parent !== undefined) {
|
||||||
|
item.parent = parent;
|
||||||
|
await this.repository.save(item, { reload: true });
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const item = await this.repository.findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
relations: ['parent', 'children'],
|
||||||
|
});
|
||||||
|
if (!isNil(item.children) && item.children.length > 0) {
|
||||||
|
const childrenCategories = [...item.children].map((c) => {
|
||||||
|
c.parent = item.parent;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
await this.repository.save(childrenCategories, { reload: true });
|
||||||
|
}
|
||||||
|
return this.repository.remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParent(current?: string, parentId?: string) {
|
||||||
|
if (current === parentId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let parent: CategoryEntity | undefined;
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
if (parentId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
parent = await this.repository.findOne({ where: { id: parentId } });
|
||||||
|
if (!parent) {
|
||||||
|
throw new EntityNotFoundError(
|
||||||
|
CategoryEntity,
|
||||||
|
`Parent category with id ${parentId} not exists!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
93
src/modules/content/services/comment.service.ts
Normal file
93
src/modules/content/services/comment.service.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isNil } from 'lodash';
|
||||||
|
|
||||||
|
import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateCommentDto,
|
||||||
|
QueryCommentDto,
|
||||||
|
QueryCommentTreeDto,
|
||||||
|
} from '@/modules/content/dtos/comment.dto';
|
||||||
|
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
||||||
|
import { treePaginate } from '@/modules/database/utils';
|
||||||
|
|
||||||
|
import { CommentRepository } from '../repositories/comment.repository';
|
||||||
|
import { PostRepository } from '../repositories/post.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentService {
|
||||||
|
constructor(
|
||||||
|
protected repository: CommentRepository,
|
||||||
|
protected postRepository: PostRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findTrees(options: QueryCommentTreeDto = {}) {
|
||||||
|
return this.repository.findTrees({
|
||||||
|
addQuery: (qb) => {
|
||||||
|
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async paginate(options: QueryCommentDto) {
|
||||||
|
const { post, ...query } = options;
|
||||||
|
const addQuery = (qb: SelectQueryBuilder<CommentEntity>) => {
|
||||||
|
const condition: RecordString = {};
|
||||||
|
if (!isNil(post)) {
|
||||||
|
condition.post = post;
|
||||||
|
}
|
||||||
|
return Object.keys(condition).length > 0 ? qb.andWhere(condition) : qb;
|
||||||
|
};
|
||||||
|
const data = await this.repository.findRoots({ addQuery });
|
||||||
|
let comments: CommentEntity[] = [];
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const comment = data[i];
|
||||||
|
comments.push(await this.repository.findDescendantsTree(comment, { addQuery }));
|
||||||
|
}
|
||||||
|
comments = await this.repository.toFlatTrees(comments);
|
||||||
|
return treePaginate(query, comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateCommentDto) {
|
||||||
|
const parent = await this.getParent(undefined, data.parent);
|
||||||
|
if (!isNil(parent) && parent.post.id !== data.post) {
|
||||||
|
throw new ForbiddenException('Parent comment and child comment must belong same post!');
|
||||||
|
}
|
||||||
|
const item = await this.repository.save({
|
||||||
|
...data,
|
||||||
|
parent,
|
||||||
|
post: await this.getPost(data.post),
|
||||||
|
});
|
||||||
|
return this.repository.findOneOrFail({ where: { id: item.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const comment = await this.repository.findOneOrFail({ where: { id: id ?? null } });
|
||||||
|
return this.repository.remove(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getPost(id: string) {
|
||||||
|
return isNil(id) ? null : this.postRepository.findOneOrFail({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getParent(current?: string, id?: string) {
|
||||||
|
if (current === id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let parent: CommentEntity | undefined;
|
||||||
|
if (id !== undefined) {
|
||||||
|
if (id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
parent = await this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['parent', 'children'],
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new EntityNotFoundError(CommentEntity, `Parent Comment ${id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
4
src/modules/content/services/index.ts
Normal file
4
src/modules/content/services/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './category.service';
|
||||||
|
export * from './tag.service';
|
||||||
|
export * from './post.service';
|
||||||
|
export * from './comment.service';
|
@ -1,21 +1,35 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isNil } from '@nestjs/common/utils/shared.utils';
|
import { isNil } from '@nestjs/common/utils/shared.utils';
|
||||||
|
|
||||||
import { isFunction, omit } from 'lodash';
|
import { isArray, isFunction, omit } from 'lodash';
|
||||||
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { PostOrder } from '@/modules/content/constants';
|
import { PostOrder } from '@/modules/content/constants';
|
||||||
import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
||||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||||
|
import { CategoryRepository } from '@/modules/content/repositories';
|
||||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||||
import { PaginateOptions, QueryHook } from '@/modules/database/types';
|
import { QueryHook } from '@/modules/database/types';
|
||||||
import { paginate } from '@/modules/database/utils';
|
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()
|
@Injectable()
|
||||||
export class PostService {
|
export class PostService {
|
||||||
constructor(protected repository: PostRepository) {}
|
constructor(
|
||||||
|
protected repository: PostRepository,
|
||||||
|
protected categoryRepository: CategoryRepository,
|
||||||
|
protected categoryService: CategoryService,
|
||||||
|
protected tagRepository: TagRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
async paginate(options: PaginateOptions, callback?: QueryHook<PostEntity>) {
|
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
|
||||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
||||||
return paginate(qb, options);
|
return paginate(qb, options);
|
||||||
}
|
}
|
||||||
@ -36,7 +50,15 @@ export class PostService {
|
|||||||
if (!isNil(data.publish)) {
|
if (!isNil(data.publish)) {
|
||||||
publishedAt = data.publish ? new Date() : null;
|
publishedAt = data.publish ? new Date() : null;
|
||||||
}
|
}
|
||||||
const item = await this.repository.save({ ...omit(data, ['publish']), publishedAt });
|
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);
|
||||||
return this.detail(item.id);
|
return this.detail(item.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,8 +67,22 @@ export class PostService {
|
|||||||
if (!isNil(data.publish)) {
|
if (!isNil(data.publish)) {
|
||||||
publishedAt = data.publish ? new Date() : null;
|
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, {
|
await this.repository.update(data.id, {
|
||||||
...omit(data, ['id', 'publish']),
|
...omit(data, ['id', 'publish', 'tags', 'category']),
|
||||||
publishedAt,
|
publishedAt,
|
||||||
});
|
});
|
||||||
return this.detail(data.id);
|
return this.detail(data.id);
|
||||||
@ -59,16 +95,22 @@ export class PostService {
|
|||||||
|
|
||||||
protected async buildListQuery(
|
protected async buildListQuery(
|
||||||
qb: SelectQueryBuilder<PostEntity>,
|
qb: SelectQueryBuilder<PostEntity>,
|
||||||
options: RecordAny,
|
options: FindParams,
|
||||||
callback?: QueryHook<PostEntity>,
|
callback?: QueryHook<PostEntity>,
|
||||||
) {
|
) {
|
||||||
const { orderBy, isPublished } = options;
|
const { orderBy, isPublished, category, tag } = options;
|
||||||
if (typeof isPublished === 'boolean') {
|
if (typeof isPublished === 'boolean') {
|
||||||
isPublished
|
isPublished
|
||||||
? qb.where({ publishedAt: Not(IsNull()) })
|
? qb.where({ publishedAt: Not(IsNull()) })
|
||||||
: qb.where({ publishedAt: IsNull() });
|
: qb.where({ publishedAt: IsNull() });
|
||||||
}
|
}
|
||||||
this.queryOrderBy(qb, orderBy);
|
this.queryOrderBy(qb, orderBy);
|
||||||
|
if (category) {
|
||||||
|
await this.queryByCategory(category, qb);
|
||||||
|
}
|
||||||
|
if (tag) {
|
||||||
|
qb.where('tags.id = :id', { id: tag });
|
||||||
|
}
|
||||||
if (callback) {
|
if (callback) {
|
||||||
return callback(qb);
|
return callback(qb);
|
||||||
}
|
}
|
||||||
@ -85,6 +127,8 @@ export class PostService {
|
|||||||
return qb.orderBy('post.publishedAt', 'DESC');
|
return qb.orderBy('post.publishedAt', 'DESC');
|
||||||
case PostOrder.CUSTOM:
|
case PostOrder.CUSTOM:
|
||||||
return qb.orderBy('post.customOrder', 'DESC');
|
return qb.orderBy('post.customOrder', 'DESC');
|
||||||
|
case PostOrder.COMMENTCOUNT:
|
||||||
|
return qb.orderBy('post.commentCount', 'DESC');
|
||||||
default:
|
default:
|
||||||
return qb
|
return qb
|
||||||
.orderBy('post.createdAt', 'DESC')
|
.orderBy('post.createdAt', 'DESC')
|
||||||
@ -92,4 +136,12 @@ export class PostService {
|
|||||||
.addOrderBy('post.publishedAt', '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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
37
src/modules/content/services/tag.service.ts
Normal file
37
src/modules/content/services/tag.service.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { omit } from 'lodash';
|
||||||
|
|
||||||
|
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
|
||||||
|
import { TagRepository } from '@/modules/content/repositories/tag.repository';
|
||||||
|
import { paginate } from '@/modules/database/utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TagService {
|
||||||
|
constructor(protected repository: TagRepository) {}
|
||||||
|
|
||||||
|
async paginate(options: QueryTagDto) {
|
||||||
|
const qb = this.repository.buildBaseQB();
|
||||||
|
return paginate(qb, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string) {
|
||||||
|
const qb = this.repository.buildBaseQB();
|
||||||
|
qb.where(`tag.id = :id`, { id });
|
||||||
|
return qb.getOneOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateTagDto) {
|
||||||
|
const item = await this.repository.save(data);
|
||||||
|
return this.detail(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(data: UpdateTagDto) {
|
||||||
|
await this.repository.update(data.id, omit(data, ['id']));
|
||||||
|
return this.detail(data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const item = await this.repository.findOneByOrFail({ id });
|
||||||
|
return this.repository.remove(item);
|
||||||
|
}
|
||||||
|
}
|
1
typings/global.d.ts
vendored
1
typings/global.d.ts
vendored
@ -1,5 +1,6 @@
|
|||||||
declare type RecordAny = Record<string, any>;
|
declare type RecordAny = Record<string, any>;
|
||||||
declare type RecordNever = Record<never, never>;
|
declare type RecordNever = Record<never, never>;
|
||||||
|
declare type RecordString = Record<string, string>;
|
||||||
declare type RecordAnyOrNever = RecordAny | RecordNever;
|
declare type RecordAnyOrNever = RecordAny | RecordNever;
|
||||||
|
|
||||||
declare type BaseType = boolean | number | string | undefined | null;
|
declare type BaseType = boolean | number | string | undefined | null;
|
||||||
|
Loading…
Reference in New Issue
Block a user