diff --git a/apps/admin/src/components/setting/CustomHeader.tsx b/apps/admin/src/components/setting/CustomHeader.tsx index aa7a5df..13e9a6c 100644 --- a/apps/admin/src/components/setting/CustomHeader.tsx +++ b/apps/admin/src/components/setting/CustomHeader.tsx @@ -20,8 +20,6 @@ interface CustomHeaderProps { onTypeChange: (e: any) => void; } const CustomHeader: FC = ({ value, type, onChange, onTypeChange }) => { - console.log(onchange, '1', onTypeChange, '2', value, '3', type); - const start = 0; const end = 12; const monthOptions = []; diff --git a/apps/api/.env.dev b/apps/api/.env.dev index 41ed581..ce62644 100644 --- a/apps/api/.env.dev +++ b/apps/api/.env.dev @@ -5,8 +5,8 @@ APP_TIMEZONE=Asia/Shanghai APP_LOCALE=zh_CN APP_FALLBACK_LOCALE=en - DB_HOST=127.0.0.1 + DB_HOST=172.17.0.4 DB_PORT=3306 DB_USERNAME=root - DB_PASSWORD=12345678 - DB_NAME=3rapp \ No newline at end of file + DB_PASSWORD=123456 + DB_NAME=nestapp \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 4cbc03f..e42da59 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ "@nestjs/core": "^10.3.8", "@nestjs/platform-fastify": "^10.3.9", "@nestjs/swagger": "^7.3.1", + "@nestjs/typeorm": "^10.0.2", "chalk": "4", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", @@ -35,10 +36,14 @@ "find-up": "5", "fs-extra": "^11.2.0", "lodash": "^4.17.21", + "meilisearch": "^0.41.0", + "mysql2": "^3.10.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sanitize-html": "^2.13.0", "typeorm": "^0.3.20", "utility-types": "^3.11.0", + "validator": "^13.12.0", "yaml": "^2.4.5" }, "devDependencies": { @@ -50,7 +55,9 @@ "@types/jest": "^29.5.12", "@types/lodash": "^4.17.5", "@types/node": "^20.14.2", + "@types/sanitize-html": "^2.11.0", "@types/supertest": "^6.0.2", + "@types/validator": "^13.12.0", "bun": "^1.1.13", "cross-env": "^7.0.3", "eslint": "^8.57.0", diff --git a/apps/api/src/app.controller.spec.ts b/apps/api/src/app.controller.spec.ts deleted file mode 100644 index 4964aaf..0000000 --- a/apps/api/src/app.controller.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts deleted file mode 100644 index 62fa14a..0000000 --- a/apps/api/src/app.controller.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { echoTest } from '@3rapp/utils'; -import { Controller, Get } from '@nestjs/common'; - -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - echoTest(); - return this.appService.getHello(); - } -} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts deleted file mode 100644 index eaf25de..0000000 --- a/apps/api/src/app.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { ConfigModule } from './modules/config/config.module'; -import { Configure } from './modules/config/configure'; - -export class AppModule { - static forroot() { - return { - global: false, - module: AppModule, - imports: [ConfigModule.forRoot(new Configure())], - controllers: [AppController], - providers: [AppService], - }; - } -} diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts deleted file mode 100644 index 61b7a5b..0000000 --- a/apps/api/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/apps/api/src/configs/content.config.ts b/apps/api/src/configs/content.config.ts new file mode 100644 index 0000000..5848fb3 --- /dev/null +++ b/apps/api/src/configs/content.config.ts @@ -0,0 +1,5 @@ +import { ContentConfig } from '@/modules/content/types'; + +export const content = (): ContentConfig => ({ + searchType: 'meilisearch', +}); diff --git a/apps/api/src/configs/database.config.ts b/apps/api/src/configs/database.config.ts new file mode 100644 index 0000000..dfcf37a --- /dev/null +++ b/apps/api/src/configs/database.config.ts @@ -0,0 +1,17 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; + +import { Configure } from '@/modules/config/configure'; + +export const database = (configure: Configure): TypeOrmModuleOptions => { + return { + type: 'mysql', + host: configure.env.get('DB_HOST'), + port: configure.env.get('DB_PORT', 3306), + username: configure.env.get('DB_USER', 'root'), + password: configure.env.get('DB_PASSWORD', '123456'), + database: configure.env.get('DB_NAME', 'nestapp'), + synchronize: true, + autoLoadEntities: true, + subscribers: [], + }; +}; diff --git a/apps/api/src/configs/index.ts b/apps/api/src/configs/index.ts index 41e7ed1..e85c404 100644 --- a/apps/api/src/configs/index.ts +++ b/apps/api/src/configs/index.ts @@ -1 +1,4 @@ export * from './app.config'; +export * from './database.config'; +export * from './content.config'; +export * from './meili.config'; diff --git a/apps/api/src/configs/meili.config.ts b/apps/api/src/configs/meili.config.ts new file mode 100644 index 0000000..8cb6dd9 --- /dev/null +++ b/apps/api/src/configs/meili.config.ts @@ -0,0 +1,8 @@ +import { MelliConfig } from '@/modules/meilisearch/types'; + +export const meili = (): MelliConfig => [ + { + name: 'default', + host: 'http://192.168.31.43:7700/', + }, +]; diff --git a/apps/api/src/modules/config/configure.ts b/apps/api/src/modules/config/configure.ts index 05bec36..cc663a7 100644 --- a/apps/api/src/modules/config/configure.ts +++ b/apps/api/src/modules/config/configure.ts @@ -213,7 +213,3 @@ export class Configure { this.config = deepMerge(this.config, this.storage.config, append ? 'merge' : 'replace'); } } - -const bb = new Configure(); -bb.initilize(); -console.log(bb.env); diff --git a/apps/api/src/modules/config/storage.ts b/apps/api/src/modules/config/storage.ts index 67dc608..a6a0809 100644 --- a/apps/api/src/modules/config/storage.ts +++ b/apps/api/src/modules/config/storage.ts @@ -69,5 +69,3 @@ export class ConfigStorage { writeFileSync(this.path, JSON.stringify(this._config, null, 4)); } } -const a = new ConfigStorage(); -console.log(a.path); diff --git a/apps/api/src/modules/content/constants.ts b/apps/api/src/modules/content/constants.ts new file mode 100644 index 0000000..7b8f813 --- /dev/null +++ b/apps/api/src/modules/content/constants.ts @@ -0,0 +1,26 @@ +/** + * 文章内容类型 + */ +export enum PostBodyType { + HTML = 'html', + MD = 'markdown', +} + +/** + * 文章排序类型 + */ +export enum PostOrderType { + CREATED = 'createdAt', + UPDATED = 'updatedAt', + PUBLISHED = 'publishedAt', + COMMENTCOUNT = 'commentCount', + CUSTOM = 'custom', +} +/** + * all - 选择所有 only - 仅选择回收站 none - 不选择回收站 + */ +export enum SelectTrashMode { + ALL = 'all', + ONLY = 'only', + NONE = 'none', +} diff --git a/apps/api/src/modules/content/content.module.ts b/apps/api/src/modules/content/content.module.ts new file mode 100644 index 0000000..a3fcbc8 --- /dev/null +++ b/apps/api/src/modules/content/content.module.ts @@ -0,0 +1,71 @@ +import { DynamicModule, ModuleMetadata } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { DatabaseModule } from '../database/database.module'; + +import * as controllers from './controllers'; +import * as entities from './entities'; +import * as repositories from './repositories'; +import * as services from './services'; +import { MeiliSearchService } from './services/meili.search.service'; +import { PostService } from './services/post.service'; +import { SanitizeService } from './services/sanitize.service'; +import { PostSubscriber } from './subscribers/post.subscriber'; +import { ContentConfig } from './types'; + +export const forRoot = (configRegister: () => ContentConfig): DynamicModule => { + const config: Required = { + searchType: 'mysql', + ...(configRegister ? configRegister() : {}), + }; + const providers: ModuleMetadata['providers'] = [ + ...Object.values(services), + SanitizeService, + PostSubscriber, + { + provide: 'CONTENT_CONFIG', + useValue: config, + }, + { + provide: PostService, + inject: [ + repositories.PostRepository, + repositories.CategoryRepository, + services.CategoryService, + repositories.TagRepository, + MeiliSearchService, + ], + useFactory( + postRepository: repositories.PostRepository, + categoryRepository: repositories.CategoryRepository, + categoryService: services.CategoryService, + tagRepository: repositories.TagRepository, + searchService: MeiliSearchService, + ) { + return new PostService( + postRepository, + categoryRepository, + categoryService, + tagRepository, + searchService, + config.searchType, + ); + }, + }, + ]; + providers.push(MeiliSearchService); + return { + module: class {}, + imports: [ + TypeOrmModule.forFeature(Object.values(entities)), + DatabaseModule.forRepository(Object.values(repositories)), + ], + controllers: Object.values(controllers), + providers, + exports: [ + ...Object.values(services), + PostService, + DatabaseModule.forRepository(Object.values(repositories)), + ], + }; +}; diff --git a/apps/api/src/modules/content/controllers/category.controller.ts b/apps/api/src/modules/content/controllers/category.controller.ts new file mode 100644 index 0000000..5872ce1 --- /dev/null +++ b/apps/api/src/modules/content/controllers/category.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + SerializeOptions, + Query, + Param, + ParseUUIDPipe, + Post, + Body, + Patch, + Delete, +} from '@nestjs/common'; + +import { SelectTrashMode } from '../constants'; +import { QueryCategoryDto, CreateCategoryDto, UpdateCategoryDto } from '../dtos'; +import { CategoryService } from '../services'; + +@Controller('categories') +export class CategoryController { + constructor(protected service: CategoryService) {} + + @Get('tree') + @SerializeOptions({ groups: ['category-tree'] }) + async tree() { + return this.service.findTrees({ trashed: SelectTrashMode.NONE }); + } + + @Get() + @SerializeOptions({ groups: ['category-list'] }) + async list( + @Query() + 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', 'create'] }) + async store( + @Body() + data: CreateCategoryDto, + ) { + return this.service.create(data); + } + + @Patch() + @SerializeOptions({ groups: ['category-detail'] }) + async update( + @Body() + data: UpdateCategoryDto, + ) { + return this.service.update(data); + } + + @Delete() + @SerializeOptions({ groups: ['category-detail'] }) + async delete(@Body() data: { ids: string[] }) { + const { ids } = data; + + return this.service.delete(ids, true); + } + + @Post('restore') + @SerializeOptions({ groups: ['category-detail'] }) + async restore(@Body() data: { ids: string[] }) { + const { ids } = data; + + return this.service.restore(ids); + } +} diff --git a/apps/api/src/modules/content/controllers/comment.controller.ts b/apps/api/src/modules/content/controllers/comment.controller.ts new file mode 100644 index 0000000..e31274f --- /dev/null +++ b/apps/api/src/modules/content/controllers/comment.controller.ts @@ -0,0 +1,56 @@ +import { + UseInterceptors, + Controller, + Get, + SerializeOptions, + Query, + Post, + Body, + Delete, +} from '@nestjs/common'; + +import { AppInterceptor } from '@/modules/core/providers'; + +import { DeleteDto } from '@/modules/restful/delete.dto'; + +import { QueryCommentTreeDto, QueryCommentDto, CreateCommentDto } from '../dtos'; +import { CommentService } from '../services'; + +@UseInterceptors(AppInterceptor) +@Controller('comments') +export class CommentController { + constructor(protected service: CommentService) {} + + @Get('tree') + @SerializeOptions({ groups: ['comment-tree'] }) + async tree( + @Query() + query: QueryCommentTreeDto, + ) { + return this.service.findTrees(query); + } + + @Get() + @SerializeOptions({ groups: ['comment-list'] }) + async list( + @Query() + query: QueryCommentDto, + ) { + return this.service.paginate(query); + } + + @Post() + @SerializeOptions({ groups: ['comment-detail'] }) + async store( + @Body() + data: CreateCommentDto, + ) { + return this.service.create(data); + } + + @Delete() + @SerializeOptions({ groups: ['comment-detail'] }) + async delete(@Body() ids: DeleteDto) { + return this.service.delete(ids.ids); + } +} diff --git a/apps/api/src/modules/content/controllers/index.ts b/apps/api/src/modules/content/controllers/index.ts new file mode 100644 index 0000000..87673cd --- /dev/null +++ b/apps/api/src/modules/content/controllers/index.ts @@ -0,0 +1,4 @@ +export * from './category.controller'; +export * from './tag.controller'; +export * from './post.controller'; +export * from './comment.controller'; diff --git a/apps/api/src/modules/content/controllers/post.controller.ts b/apps/api/src/modules/content/controllers/post.controller.ts new file mode 100644 index 0000000..bf76935 --- /dev/null +++ b/apps/api/src/modules/content/controllers/post.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + SerializeOptions, +} from '@nestjs/common'; + +import { DeleteWithTrashDto, RestoreDto } from '@/modules/restful/delete-with-trash.dto'; + +import { QueryPostDto, UpdatePostDto } from '../dtos/post.dto'; +import { PostService } from '../services/post.service'; + +@Controller('posts') +export class PostController { + constructor(protected postservice: PostService) {} + + @Get() + @SerializeOptions({ groups: ['post-list'] }) + async list( + @Query() + options: QueryPostDto, + ) { + return this.postservice.paginate(options); + } + + @Get(':id') + @SerializeOptions({ groups: ['post-detail'] }) + async detail( + @Param('id', new ParseUUIDPipe()) + id: string, + ) { + return this.postservice.detail(id); + } + + @Post() + @SerializeOptions({ groups: ['post-detail'] }) + async store( + @Body() + data: any, + ) { + return this.postservice.create(data); + } + + @Patch() + @SerializeOptions({ groups: ['post-detail'] }) + async update( + @Body() + data: UpdatePostDto, + ) { + return this.postservice.update(data); + } + + @Post('delete') + @SerializeOptions({ groups: ['post-list'] }) + async delete(@Body() data: DeleteWithTrashDto) { + return this.postservice.delete(data.ids, data.transh); + } + + @Post('restore') + @SerializeOptions({ groups: ['post-list'] }) + async restore(@Body() data: RestoreDto) { + return this.postservice.restore(data.ids); + } +} diff --git a/apps/api/src/modules/content/controllers/tag.controller.ts b/apps/api/src/modules/content/controllers/tag.controller.ts new file mode 100644 index 0000000..aa5d1e7 --- /dev/null +++ b/apps/api/src/modules/content/controllers/tag.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + SerializeOptions, + Query, + Param, + ParseUUIDPipe, + Post, + Body, + Patch, + Delete, +} from '@nestjs/common'; + +import { QueryCategoryDto, CreateTagDto, UpdateTagDto } from '../dtos'; +import { TagService } from '../services'; + +@Controller('tags') +export class TagController { + constructor(protected service: TagService) {} + + @Get() + @SerializeOptions({}) + async list( + @Query() + options: QueryCategoryDto, + ) { + 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() + data: CreateTagDto, + ) { + return this.service.create(data); + } + + @Patch() + @SerializeOptions({}) + async update( + @Body() + data: UpdateTagDto, + ) { + return this.service.update(data); + } + + @Delete(':id') + @SerializeOptions({}) + async delete(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.delete(id); + } +} diff --git a/apps/api/src/modules/content/dtos/category.dto.ts b/apps/api/src/modules/content/dtos/category.dto.ts new file mode 100644 index 0000000..443a1d6 --- /dev/null +++ b/apps/api/src/modules/content/dtos/category.dto.ts @@ -0,0 +1,86 @@ +import { PartialType } from '@nestjs/swagger'; + +import { Transform } from 'class-transformer'; +import { + Min, + IsNumber, + IsOptional, + MaxLength, + IsNotEmpty, + IsUUID, + ValidateIf, + IsDefined, + IsEnum, +} from 'class-validator'; +import { toNumber } from 'lodash'; + +import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator'; +import { IsDataExist } from '@/modules/database/constraints'; +import { PaginateOptions } from '@/modules/database/types'; + +import { SelectTrashMode } from '../constants'; +import { CategoryEntity } from '../entities'; + +/** + * 树形分类查询验证 + */ +@DtoValidation({ type: 'query' }) +export class QueryCategoryTreeDto { + @IsEnum(SelectTrashMode) + @IsOptional() + trashed?: SelectTrashMode; +} + +@DtoValidation({ type: 'query' }) +export class QueryCategoryDto extends QueryCategoryTreeDto implements PaginateOptions { + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '当前页必须大于1' }) + @IsNumber() + @IsOptional() + page = 1; + + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '每页显示数据必须大于1' }) + @IsNumber() + @IsOptional() + limit = 10; +} + +/** + * 分类新增验证 + */ +@DtoValidation({ groups: ['create'] }) +export class CreateCategoryDto { + // @IsTreeUnique(CategoryEntity, { always: true, message: '分类名称已存在' }) + // @IsTreeUniqueExist(CategoryEntity, { always: true, message: '父分类不存在1' }) + @MaxLength(25, { + always: true, + message: '分类名称长度不得超过$constraint1', + }) + @IsNotEmpty({ groups: ['create'], message: '分类名称不得为空' }) + @IsOptional({ groups: ['update'] }) + name: string; + + @IsDataExist(CategoryEntity, { always: true, message: '父分类不存在' }) + @IsUUID(undefined, { always: true, message: '父分类ID格式不正确' }) + @ValidateIf((value) => value.parent !== null && value.parent) + @IsOptional({ always: true }) + @Transform(({ value }) => (value === 'null' ? null : value)) + parent?: string; + + @Transform(({ value }) => toNumber(value)) + @Min(0, { always: true, message: '排序值必须大于0' }) + @IsNumber(undefined, { always: true }) + @IsOptional({ always: true }) + customOrder?: number = 0; +} + +/** + * 分类更新验证 + */ +@DtoValidation({ groups: ['update'] }) +export class UpdateCategoryDto extends PartialType(CreateCategoryDto) { + @IsUUID(undefined, { groups: ['update'], message: 'ID格式错误' }) + @IsDefined({ groups: ['update'], message: 'ID必须指定' }) + id: string; +} diff --git a/apps/api/src/modules/content/dtos/comment.dto.ts b/apps/api/src/modules/content/dtos/comment.dto.ts new file mode 100644 index 0000000..59a0ebe --- /dev/null +++ b/apps/api/src/modules/content/dtos/comment.dto.ts @@ -0,0 +1,72 @@ +import { PickType } from '@nestjs/swagger'; + +import { Transform } from 'class-transformer'; +import { + IsUUID, + IsOptional, + Min, + IsNumber, + MaxLength, + IsNotEmpty, + IsDefined, + ValidateIf, +} from 'class-validator'; +import { toNumber } from 'lodash'; + +import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator'; + +import { IsDataExist } from '@/modules/database/constraints'; +import { PaginateOptions } from '@/modules/database/types'; + +import { CommentEntity, PostEntity } from '../entities'; + +/** + * 评论分页查询验证 + */ +@DtoValidation({ type: 'query' }) +export class QueryCommentDto implements PaginateOptions { + @IsDataExist(PostEntity, { always: true, message: '文章不存在' }) + @IsUUID(undefined, { message: 'ID格式错误' }) + @IsOptional() + post?: string; + + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '当前页必须大于1' }) + @IsNumber() + @IsOptional() + page = 1; + + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '每页显示数据必须大于1' }) + @IsNumber() + @IsOptional() + limit = 10; +} + +/** + * 评论树查询 + */ +@DtoValidation({ type: 'query' }) +export class QueryCommentTreeDto extends PickType(QueryCommentDto, ['post']) {} + +/** + * 评论添加验证 + */ +@DtoValidation() +export class CreateCommentDto { + @MaxLength(1000, { message: '评论内容不能超过$constraint1个字' }) + @IsNotEmpty({ message: '评论内容不能为空' }) + body: string; + + @IsDataExist(PostEntity, { always: true, message: '文章不存在' }) + @IsUUID(undefined, { message: 'ID格式错误' }) + @IsDefined({ message: 'ID必须指定' }) + post: string; + + @IsDataExist(CommentEntity, { always: true, message: '评论ID错误' }) + @IsUUID(undefined, { always: true, message: 'ID格式错误' }) + @ValidateIf((value) => value.parent !== null && value.parent) + @IsOptional({ always: true }) + @Transform(({ value }) => (value === 'null' ? null : value)) + parent?: string; +} diff --git a/apps/api/src/modules/content/dtos/index.ts b/apps/api/src/modules/content/dtos/index.ts new file mode 100644 index 0000000..ef7b75e --- /dev/null +++ b/apps/api/src/modules/content/dtos/index.ts @@ -0,0 +1,5 @@ +export * from './category.dto'; +export * from './comment.dto'; +export * from './post.dto'; +export * from './tag.dto'; +// export * from '' diff --git a/apps/api/src/modules/content/dtos/post.dto.ts b/apps/api/src/modules/content/dtos/post.dto.ts new file mode 100644 index 0000000..7b21cd2 --- /dev/null +++ b/apps/api/src/modules/content/dtos/post.dto.ts @@ -0,0 +1,151 @@ +import { toBoolean } from '@3rapp/utils'; +import { PartialType } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsOptional, + IsEnum, + Min, + IsNumber, + MaxLength, + IsNotEmpty, + ValidateIf, + IsUUID, + IsDefined, + Max, +} from 'class-validator'; +import { toNumber, isNil } from 'lodash'; + +import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator'; +import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint'; +import { PaginateOptions } from '@/modules/database/types'; + +import { PostOrderType, SelectTrashMode } from '../constants'; +import { CategoryEntity, TagEntity } from '../entities'; + +@DtoValidation({ type: 'query' }) +export class QueryPostDto implements PaginateOptions { + @Transform(({ value }) => toBoolean(value)) + @IsBoolean() + @IsOptional() + isPublished?: boolean; + + @IsEnum(PostOrderType, { + message: `排序规则必须是${Object.values(PostOrderType).join(',')}其中一项`, + }) + @IsOptional() + orderBy?: PostOrderType; + + @Transform(({ value }) => { + return toNumber(value); + }) + @Min(1, { message: '当前页必须大于1' }) + @Max(100, { message: '当前页必须小于100' }) + @IsNumber() + @IsOptional() + page: number; + + // DTO 设置默认值是无效的,因为它是一个类,而不是一个对象。如果要设置默认值,可以在构造函数中设置默认值。 + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '每页显示数据必须大于1' }) + @IsNumber() + @IsOptional() + limit: number; + + @IsDataExist(CategoryEntity, { always: true, message: '分类不存在' }) + @IsUUID(undefined, { message: 'ID格式错误' }) + @IsOptional() + category?: string; + + @IsDataExist(TagEntity, { always: true, message: '标签不存在' }) + @IsUUID(undefined, { message: 'ID格式错误' }) + @IsOptional() + tag?: string; + + @MaxLength(100, { + always: true, + message: '搜索关键字最大长度为$constraint1', + }) + @IsOptional({ always: true }) + search?: string; + + @IsEnum(SelectTrashMode) + @IsOptional() + transhed?: SelectTrashMode; +} + +/** + * 文章创建验证 + */ +@DtoValidation({ type: 'body', groups: ['create'] }) +export class CreatePostDto { + @IsDataExist(CategoryEntity, { always: true, message: '分类不存在' }) + @IsUUID(undefined, { + always: true, + message: 'ID格式错误', + }) + @IsOptional({ always: true }) + category?: string; + + /** + * 根据标签ID查询 + */ + @IsDataExist(TagEntity, { always: true, each: true, message: '标签不存在' }) + @IsUUID(undefined, { + always: true, + each: true, + message: 'ID格式错误', + }) + @IsOptional({ always: true }) + tags?: string[]; + + @MaxLength(255, { + always: true, + message: '文章标题长度最大为$constraint1', + }) + @IsNotEmpty({ groups: ['create'], message: '文章标题必须填写' }) + @IsOptional({ groups: ['update'] }) + title: string; + + @MaxLength(4, { always: true, groups: ['create'], message: '文章标题长度最大为$constraint1' }) + @IsNotEmpty({ groups: ['create'], message: '文章内容必须填写' }) + @IsOptional({ groups: ['update'] }) + body: string; + + @MaxLength(500, { + always: true, + message: '文章描述长度最大为$constraint1', + }) + @IsOptional({ always: true }) + summary?: string; + + @Transform(({ value }) => toBoolean(value)) + @IsBoolean({ always: true }) + @ValidateIf((value) => !isNil(value.publish)) + @IsOptional({ always: true }) + publish?: boolean; + + @MaxLength(20, { + each: true, + always: true, + message: '每个关键字长度最大为$constraint1', + }) + @IsOptional({ always: true }) + keywords?: string[]; + + @Transform(({ value }) => toNumber(value)) + @Min(0, { always: true, message: '排序值必须大于0' }) + @IsNumber(undefined, { always: true }) + @IsOptional({ always: true }) + customOrder?: number = 0; +} + +/** + * 文章更新验证 + */ +@DtoValidation({ groups: ['update'] }) +export class UpdatePostDto extends PartialType(CreatePostDto) { + @IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' }) + @IsDefined({ groups: ['update'], message: '文章ID必须指定' }) + id: string; +} diff --git a/apps/api/src/modules/content/dtos/tag.dto.ts b/apps/api/src/modules/content/dtos/tag.dto.ts new file mode 100644 index 0000000..52b51d6 --- /dev/null +++ b/apps/api/src/modules/content/dtos/tag.dto.ts @@ -0,0 +1,68 @@ +import { PartialType } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + Min, + IsNumber, + IsOptional, + MaxLength, + IsNotEmpty, + IsUUID, + IsDefined, +} from 'class-validator'; +import { toNumber } from 'lodash'; + +import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator'; +import { IsUnique } from '@/modules/database/constraints/unique.constraint'; +import { PaginateOptions } from '@/modules/database/types'; + +import { TagEntity } from '../entities'; + +/** + * 标签分页查询验证 + */ +@DtoValidation({ type: 'query' }) +export class QueryTagDto implements PaginateOptions { + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '当前页必须大于1' }) + @IsNumber() + @IsOptional() + page = 1; + + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '每页显示数据必须大于1' }) + @IsNumber() + @IsOptional() + limit = 10; +} + +/** + * 标签创建验证 + */ +@DtoValidation({ groups: ['create'] }) +export class CreateTagDto { + @IsUnique(TagEntity, { groups: ['create'], message: '标签名称已存在' }) + @MaxLength(255, { + always: true, + message: '标签名称长度最大为$constraint1', + }) + @IsNotEmpty({ groups: ['create'], message: '标签名称必须填写' }) + @IsOptional({ groups: ['update'] }) + name: string; + + @MaxLength(500, { + always: true, + message: '标签描述长度最大为$constraint1', + }) + @IsOptional({ always: true }) + description?: string; +} + +/** + * 标签更新验证 + */ +@DtoValidation({ groups: ['update'] }) +export class UpdateTagDto extends PartialType(CreateTagDto) { + @IsUUID(undefined, { groups: ['update'], message: 'ID格式错误' }) + @IsDefined({ groups: ['update'], message: 'ID必须指定' }) + id: string; +} diff --git a/apps/api/src/modules/content/entities/.doc.md b/apps/api/src/modules/content/entities/.doc.md new file mode 100644 index 0000000..0c7898f --- /dev/null +++ b/apps/api/src/modules/content/entities/.doc.md @@ -0,0 +1,4 @@ +## 序列化 响应拦截器 + @Exclude() @Expose({ groups: ['post-detail'] }) + @SerializeOptions({ groups: ['post-detail'] }) + @UseInterceptors(AppInterceptor) \ No newline at end of file diff --git a/apps/api/src/modules/content/entities/category.entity.ts b/apps/api/src/modules/content/entities/category.entity.ts new file mode 100644 index 0000000..18f20a5 --- /dev/null +++ b/apps/api/src/modules/content/entities/category.entity.ts @@ -0,0 +1,53 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { + Entity, + BaseEntity, + PrimaryColumn, + Column, + OneToMany, + Relation, + Tree, + TreeParent, + TreeChildren, + Index, + DeleteDateColumn, +} from 'typeorm'; + +import { PostEntity } from './post.entity'; + +@Exclude() +@Tree('materialized-path') +@Entity('content_categories') +export class CategoryEntity extends BaseEntity { + @Expose() + @PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 }) + id: string; + + @Expose() + @Column({ comment: '分类名称' }) + @Index({ fulltext: true, unique: true }) + name: string; + + @Expose({ groups: ['category-tree', 'category-list', 'category-detail'] }) + @Column({ comment: '分类排序', default: 0 }) + customOrder: number; + + @OneToMany(() => PostEntity, (post) => post.category, { cascade: true }) + posts: Relation; + + @Expose({ groups: ['category-list'] }) + depth = 0; + + @Expose({ groups: ['category-list', 'category-detail'] }) + @TreeParent({ onDelete: 'NO ACTION' }) + parent: Relation | null; + + @Expose({ groups: ['category-tree'] }) + @TreeChildren({ cascade: true }) + children: Relation[]; + + @Expose() + @Type(() => Date) + @DeleteDateColumn({ comment: '删除时间', nullable: true }) + deletedAt: Date; +} diff --git a/apps/api/src/modules/content/entities/comment.entity.ts b/apps/api/src/modules/content/entities/comment.entity.ts new file mode 100644 index 0000000..ca8e3ad --- /dev/null +++ b/apps/api/src/modules/content/entities/comment.entity.ts @@ -0,0 +1,55 @@ +import { Exclude, Expose } from 'class-transformer'; +import { + Entity, + BaseEntity, + PrimaryColumn, + Column, + CreateDateColumn, + Relation, + ManyToOne, + Tree, + TreeChildren, + TreeParent, + Index, +} from 'typeorm'; + +import { PostEntity } from './post.entity'; + +@Exclude() +@Tree('materialized-path') +@Entity('content_comments') +export class CommentEntity extends BaseEntity { + @Expose() + @PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 }) + id: string; + + @Expose() + @Column({ comment: '评论内容', type: 'text' }) + @Index({ fulltext: true }) + body: string; + + @Expose() + @CreateDateColumn({ + comment: '创建时间', + }) + createdAt: Date; + + @Expose() + @ManyToOne(() => PostEntity, (post) => post.comments, { + nullable: false, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + post: Relation; + + @Expose({ groups: ['comment-list'] }) + depth = 0; + + @Expose({ groups: ['comment-detail', 'comment-list'] }) + @TreeParent({ onDelete: 'CASCADE' }) + parent: Relation | null; + + @Expose({ groups: ['comment-tree'] }) + @TreeChildren({ cascade: true }) + children: Relation[]; +} diff --git a/apps/api/src/modules/content/entities/index.ts b/apps/api/src/modules/content/entities/index.ts new file mode 100644 index 0000000..6e55285 --- /dev/null +++ b/apps/api/src/modules/content/entities/index.ts @@ -0,0 +1,4 @@ +export * from './category.entity'; +export * from './comment.entity'; +export * from './post.entity'; +export * from './tag.entity'; diff --git a/apps/api/src/modules/content/entities/post.entity.ts b/apps/api/src/modules/content/entities/post.entity.ts new file mode 100644 index 0000000..d87257c --- /dev/null +++ b/apps/api/src/modules/content/entities/post.entity.ts @@ -0,0 +1,113 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { + BaseEntity, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { PostBodyType } from '../constants'; + +import { CategoryEntity } from './category.entity'; +import { CommentEntity } from './comment.entity'; +import { TagEntity } from './tag.entity'; + +@Exclude() +@Entity('content_posts') +export class PostEntity extends BaseEntity { + @Expose() + @PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 }) + id: string; + + @Expose() + @Column({ comment: '文章标题' }) + @Index({ fulltext: true }) + title: string; + + @Expose() + @Column({ comment: '文章内容', type: 'text' }) + @Index({ fulltext: true }) + body: string; + + @Expose({ groups: ['post-detail'] }) + @Column({ comment: '文章描述', nullable: true }) + @Index({ fulltext: true }) + summary?: string; + + @Expose() + @Column({ comment: '关键字', type: 'simple-array', nullable: true }) + keywords?: string[]; + + @Expose() + @Column({ + comment: '文章类型', + type: 'varchar', + // 如果是mysql或者postgresql你可以使用enum类型 + // enum: PostBodyType, + default: PostBodyType.MD, + }) + type: PostBodyType; + + @Expose() + @Column({ + comment: '发布时间', + type: 'varchar', + nullable: true, + }) + publishedAt?: Date | null; + + @Expose() + @Column({ comment: '自定义文章排序', default: 0 }) + customOrder: number; + + @Expose() + @Type(() => Date) + @CreateDateColumn({ + comment: '创建时间', + }) + createdAt: Date; + + @Expose() + @Type(() => Date) + @UpdateDateColumn({ + comment: '更新时间', + }) + updatedAt: Date; + + @Expose() + @Type(() => Date) + @DeleteDateColumn({ + comment: '删除时间', + nullable: true, + }) + deleteAt: Date; + + @Expose() + @ManyToOne(() => CategoryEntity, (category) => category.posts, { + nullable: true, + onDelete: 'SET NULL', + }) + category: Relation; + + @Expose() + @JoinTable() + @ManyToMany(() => TagEntity, (tag) => tag.posts, { + cascade: true, + }) + tags: Relation; + + @Expose() + @OneToMany(() => CommentEntity, (comment) => comment.post, { + cascade: true, + }) + comments: Relation; +} diff --git a/apps/api/src/modules/content/entities/tag.entity.ts b/apps/api/src/modules/content/entities/tag.entity.ts new file mode 100644 index 0000000..d754db9 --- /dev/null +++ b/apps/api/src/modules/content/entities/tag.entity.ts @@ -0,0 +1,25 @@ +import { Exclude, Expose } from 'class-transformer'; +import { Entity, PrimaryColumn, Column, ManyToMany, Index } from 'typeorm'; + +import { PostEntity } from './post.entity'; + +@Exclude() +@Entity('content_tags') +export class TagEntity { + @Expose() + @PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 }) + id: string; + + @Expose() + @Column({ comment: '标签名称' }) + @Index({ fulltext: true, unique: true }) + name: string; + + @Expose() + @Column({ comment: '标签描述', nullable: true }) + description?: string; + + @Expose() + @ManyToMany(() => PostEntity, (post) => post.tags) + posts: PostEntity[]; +} diff --git a/apps/api/src/modules/content/helpers.ts b/apps/api/src/modules/content/helpers.ts new file mode 100644 index 0000000..3580a25 --- /dev/null +++ b/apps/api/src/modules/content/helpers.ts @@ -0,0 +1,54 @@ +import { instanceToPlain } from 'class-transformer'; + +import { isNil, pick } from 'lodash'; + +import { PostEntity } from './entities'; +import { CategoryRepository, CommentRepository } from './repositories'; + +export async function getSearchItem( + catRepo: CategoryRepository, + cmtRepo: CommentRepository, + post: PostEntity, +) { + const categories = isNil(post.category) + ? [] + : (await catRepo.flatAncestorsTree(post.category)).map((item) => ({ + id: item.id, + name: item.name, + })); + const comments = ( + await cmtRepo.find({ + relations: ['post'], + where: { post: { id: post.id } }, + }) + ).map((item) => ({ id: item.id, body: item.body })); + + return [ + { + ...pick(instanceToPlain(post), [ + 'id', + 'title', + 'body', + 'summary', + 'commentCount', + 'deletedAt', + 'publishedAt', + 'createdAt', + 'updatedAt', + ]), + categories, + tags: post.tags.map((item) => ({ id: item.id, name: item.name })), + comments, + }, + ]; +} + +export const getSearchData = async ( + posts: PostEntity[], + catRepo: CategoryRepository, + cmtRepo: CommentRepository, +) => + (await Promise.all(posts.map(async (post) => getSearchItem(catRepo, cmtRepo, post)))).reduce( + (o, n) => [...o, ...n], + [], + ); diff --git a/apps/api/src/modules/content/repositories/category.repository.ts b/apps/api/src/modules/content/repositories/category.repository.ts new file mode 100644 index 0000000..64f16d3 --- /dev/null +++ b/apps/api/src/modules/content/repositories/category.repository.ts @@ -0,0 +1,202 @@ +import { isNil, pick, unset } from 'lodash'; +import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm'; + +import { CustomRepository } from '@/modules/database/decorators/repository.decorator'; + +import { CategoryEntity } from '../entities'; + +interface CateFindTreeOptions extends FindTreeOptions { + onlyTrashed?: boolean; + withTrashed?: boolean; +} + +@CustomRepository(CategoryEntity) +export class CategoryRepository extends TreeRepository { + buildBaseQB() { + return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent'); + } + + /** + * 树形结构查询 + * @param options + */ + async findTrees(options?: CateFindTreeOptions) { + const roots = await this.findRoots(options); + await Promise.all(roots.map((root) => this.findDescendantsTree(root, options))); + return roots; + } + + /** + * 查询顶级分类 + * @param options + */ + findRoots(options?: CateFindTreeOptions) { + const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias); + const escapeColumn = (column: string) => this.manager.connection.driver.escape(column); + + const joinColumn = this.metadata.treeParentRelation!.joinColumns[0]; + const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName; + const qb = this.buildBaseQB().orderBy('category.customOrder', 'ASC'); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + if (options?.withTrashed) { + qb.withDeleted(); + options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL'); + } + qb.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`); + return qb.getMany(); + } + + /** + * 查询后代分类 + * @param entity + * @param options + */ + findDescendants(entity: CategoryEntity, options?: CateFindTreeOptions) { + const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + if (options?.withTrashed) { + qb.withDeleted(); + options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL'); + } + qb.orderBy('category.customOrder', 'ASC'); + return qb.getMany(); + } + + /** + * 查询祖先分类 + * @param entity + * @param options + */ + findAncestors(entity: CategoryEntity, options?: CateFindTreeOptions) { + const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + if (options?.withTrashed) { + qb.withDeleted(); + options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL'); + } + qb.orderBy('category.customOrder', 'ASC'); + return qb.getMany(); + } + + /** + * 查询后代树 + * @param entity + * @param options + */ + async findDescendantsTree(entity: CategoryEntity, options?: CateFindTreeOptions) { + const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity) + .leftJoinAndSelect('category.parent', 'parent') + .orderBy('category.customOrder', 'ASC'); + if (options?.withTrashed) { + qb.withDeleted(); + options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL'); + } + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth'])); + const entities = await qb.getRawAndEntities(); + const relationMaps = TreeRepositoryUtils.createRelationMaps( + this.manager, + this.metadata, + 'category', + entities.raw, + ); + TreeRepositoryUtils.buildChildrenEntityTree( + this.metadata, + entity, + entities.entities, + relationMaps, + { + depth: -1, + ...pick(options, ['relations']), + }, + ); + + return entity; + } + + /** + * 查询祖先树 + * @param entity + * @param options + */ + async findAncestorsTree(entity: CategoryEntity, options?: CateFindTreeOptions) { + const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity) + .leftJoinAndSelect('category.parent', 'parent') + .orderBy('category.customOrder', 'ASC'); + if (options?.withTrashed) { + qb.withDeleted(); + options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL'); + } + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + + const entities = await qb.getRawAndEntities(); + const relationMaps = TreeRepositoryUtils.createRelationMaps( + this.manager, + this.metadata, + 'category', + entities.raw, + ); + TreeRepositoryUtils.buildParentEntityTree( + this.metadata, + entity, + entities.entities, + relationMaps, + ); + return entity; + } + + /** + * 统计后代元素数量 + * @param entity + */ + async countDescendants(entity: CategoryEntity, options?: CateFindTreeOptions) { + const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); + if (options?.withTrashed) { + qb.withDeleted(); + options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL'); + } + return qb.getCount(); + } + + /** + * 统计祖先元素数量 + * @param entity + */ + async countAncestors(entity: CategoryEntity, options?: CateFindTreeOptions) { + const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity); + if (options?.withTrashed) { + qb.withDeleted(); + options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL'); + } + return qb.getCount(); + } + + /** + * 打平并展开树 + * @param trees + * @param depth + * @param parent + */ + async toFlatTrees(trees: CategoryEntity[], depth = 0, parent: CategoryEntity | null = null) { + const data: Omit[] = []; + for (const item of trees) { + item.depth = depth; + item.parent = parent; + const { children } = item; + unset(item, 'children'); + data.push(item); + data.push(...(await this.toFlatTrees(children, depth + 1, item))); + } + 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, 'parent'); + data.push(item); + if (!isNil(parent)) data = [...(await this.flatAncestorsTree(parent)), ...data]; + return data as CategoryEntity[]; + } +} diff --git a/apps/api/src/modules/content/repositories/comment.repository.ts b/apps/api/src/modules/content/repositories/comment.repository.ts new file mode 100644 index 0000000..edca5fa --- /dev/null +++ b/apps/api/src/modules/content/repositories/comment.repository.ts @@ -0,0 +1,129 @@ +import { pick, unset } from 'lodash'; +import { + FindOptionsUtils, + FindTreeOptions, + SelectQueryBuilder, + TreeRepository, + TreeRepositoryUtils, +} from 'typeorm'; + +import { CustomRepository } from '@/modules/database/decorators/repository.decorator'; + +import { CommentEntity } from '../entities'; + +type FindCommentTreeOptions = FindTreeOptions & { + addQuery?: (query: SelectQueryBuilder) => SelectQueryBuilder; +}; +@CustomRepository(CommentEntity) +export class CommentRepository extends TreeRepository { + /** + * 构建基础查询器 + */ + buildBaseQB(qb: SelectQueryBuilder): SelectQueryBuilder { + return qb + .leftJoinAndSelect(`comment.parent`, 'parent') + .leftJoinAndSelect(`comment.post`, 'post') + .orderBy('comment.createdAt', 'DESC'); + } + + /** + * 查询树 + * @param options + */ + async findTrees(options: FindCommentTreeOptions = {}) { + options.relations = ['parent', 'children']; + const roots = await this.findRoots(options); + await Promise.all(roots.map((root) => this.findDescendantsTree(root, options))); + return roots; + } + + /** + * 查询顶级评论 + * @param options + */ + findRoots(options: FindCommentTreeOptions = {}) { + const { addQuery, ...rest } = options; + const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias); + const escapeColumn = (column: string) => this.manager.connection.driver.escape(column); + + const joinColumn = this.metadata.treeParentRelation!.joinColumns[0]; + const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName; + + let qb = this.buildBaseQB(this.createQueryBuilder('comment')); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest); + qb.where(`${escapeAlias('comment')}.${escapeColumn(parentPropertyName)} IS NULL`); + qb = addQuery ? addQuery(qb) : qb; + return qb.getMany(); + } + + /** + * 创建后代查询器 + * @param closureTableAlias + * @param entity + * @param options + */ + createDtsQueryBuilder( + closureTableAlias: string, + entity: CommentEntity, + options: FindCommentTreeOptions = {}, + ): SelectQueryBuilder { + const { addQuery } = options; + const qb = this.buildBaseQB( + super.createDescendantsQueryBuilder('comment', closureTableAlias, entity), + ); + return addQuery ? addQuery(qb) : qb; + } + + /** + * 查询后代树 + * @param entity + * @param options + */ + async findDescendantsTree( + entity: CommentEntity, + options: FindCommentTreeOptions = {}, + ): Promise { + const qb: SelectQueryBuilder = this.createDtsQueryBuilder( + 'treeClosure', + entity, + options, + ); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth'])); + const entities = await qb.getRawAndEntities(); + const relationMaps = TreeRepositoryUtils.createRelationMaps( + this.manager, + this.metadata, + 'comment', + entities.raw, + ); + TreeRepositoryUtils.buildChildrenEntityTree( + this.metadata, + entity, + entities.entities, + relationMaps, + { + depth: -1, + ...pick(options, ['relations']), + }, + ); + + return entity; + } + + /** + * 打平并展开树 + * @param trees + * @param depth + */ + async toFlatTrees(trees: CommentEntity[], depth = 0) { + const data: Omit[] = []; + for (const item of trees) { + item.depth = depth; + const { children } = item; + unset(item, 'children'); + data.push(item); + data.push(...(await this.toFlatTrees(children, depth + 1))); + } + return data as CommentEntity[]; + } +} diff --git a/apps/api/src/modules/content/repositories/index.ts b/apps/api/src/modules/content/repositories/index.ts new file mode 100644 index 0000000..4271613 --- /dev/null +++ b/apps/api/src/modules/content/repositories/index.ts @@ -0,0 +1,4 @@ +export * from './category.repository'; +export * from './post.repository'; +export * from './comment.repository'; +export * from './tag.repository'; diff --git a/apps/api/src/modules/content/repositories/post.repository.ts b/apps/api/src/modules/content/repositories/post.repository.ts new file mode 100644 index 0000000..8dc4c98 --- /dev/null +++ b/apps/api/src/modules/content/repositories/post.repository.ts @@ -0,0 +1,22 @@ +import { Repository } from 'typeorm'; + +import { CustomRepository } from '@/modules/database/decorators/repository.decorator'; + +import { CommentEntity } from '../entities'; +import { PostEntity } from '../entities/post.entity'; + +@CustomRepository(PostEntity) +export class PostRepository extends Repository { + buildBaseQB() { + return this.createQueryBuilder('post') + .leftJoinAndSelect('post.category', 'category') + .leftJoinAndSelect('post.tags', 'tags') + .addSelect((subQuery) => { + return subQuery + .select('COUNT(c.id)', 'count') + .from(CommentEntity, 'c') + .where('c.post.id = post.id'); + }, 'commentCount') + .loadRelationCountAndMap('post.commentCount', 'post.comments'); + } +} diff --git a/apps/api/src/modules/content/repositories/tag.repository.ts b/apps/api/src/modules/content/repositories/tag.repository.ts new file mode 100644 index 0000000..ce7fca4 --- /dev/null +++ b/apps/api/src/modules/content/repositories/tag.repository.ts @@ -0,0 +1,19 @@ +import { Repository } from 'typeorm'; + +import { CustomRepository } from '@/modules/database/decorators/repository.decorator'; + +import { TagEntity, PostEntity } from '../entities'; + +@CustomRepository(TagEntity) +export class TagRepository extends Repository { + buildBaseQB() { + return this.createQueryBuilder('tag') + .leftJoinAndSelect('tag.posts', 'posts') + .addSelect( + (subQuery) => subQuery.select('COUNT(p.id)', 'count').from(PostEntity, 'p'), + 'postCount', + ) + .orderBy('postCount', 'DESC') + .loadRelationCountAndMap('tag.postCount', 'tag.posts'); + } +} diff --git a/apps/api/src/modules/content/services/category.service.ts b/apps/api/src/modules/content/services/category.service.ts new file mode 100644 index 0000000..71c34c5 --- /dev/null +++ b/apps/api/src/modules/content/services/category.service.ts @@ -0,0 +1,162 @@ +import { Injectable } from '@nestjs/common'; +import { isNil, omit } from 'lodash'; +import { EntityNotFoundError, In } from 'typeorm'; + +import { treePaginate } from '@/modules/database/helpers'; + +import { SelectTrashMode } from '../constants'; +import { + QueryCategoryDto, + CreateCategoryDto, + UpdateCategoryDto, + QueryCategoryTreeDto, +} from '../dtos'; +import { CategoryEntity } from '../entities'; +import { CategoryRepository } from '../repositories'; + +/** + * 分类数据操作 + */ +@Injectable() +export class CategoryService { + constructor(protected repository: CategoryRepository) {} + + /** + * 查询分类树 + */ + async findTrees(options: QueryCategoryTreeDto) { + const { trashed = SelectTrashMode.NONE } = options; + return this.repository.findTrees({ + withTrashed: trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY, + onlyTrashed: trashed === SelectTrashMode.ONLY, + }); + } + + /** + * 获取分页数据 + * @param options 分页选项 + */ + async paginate(options: QueryCategoryDto) { + const { trashed = SelectTrashMode.NONE } = options; + const tree = await this.repository.findTrees({ + withTrashed: trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY, + onlyTrashed: trashed === SelectTrashMode.ONLY, + }); + const data = await this.repository.toFlatTrees(tree); + return treePaginate(options, data); + } + + /** + * 获取数据详情 + * @param id + */ + async detail(id: string) { + return this.repository.findOneOrFail({ + where: { id }, + relations: ['parent'], + }); + } + + /** + * 新增分类 + * @param data + */ + async create(data: CreateCategoryDto) { + const item = await this.repository.save({ + ...data, + parent: await this.getParent(undefined, data.parent), + }); + return this.detail(item.id); + } + + /** + * 更新分类 + * @param data + */ + 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 (parent !== undefined && shouldUpdateParent) { + item.parent = parent; + await this.repository.save(item, { reload: true }); + } + return item; + } + + /** + * 删除分类 + * @param id + */ + async delete(ids: string[], trashed: boolean) { + console.log(ids.values()); + + const items = await this.repository.find({ + where: { id: In(ids) }, + // withDeleted: true, + relations: ['parent', 'children'], + }); + console.log(items); + + // 把子分类提升一级 + for (const item of items) { + if (!isNil(item.children) && item.children.length > 0) { + const nchildren = [...item.children].map((c) => { + c.parent = item.parent; + return item; + }); + + await this.repository.save(nchildren, { reload: true }); + } + } + if (trashed) { + const directs = items.filter((item) => !isNil(item.deletedAt)); + const softs = items.filter((item) => isNil(item.deletedAt)); + return [ + ...(await this.repository.remove(directs)), + ...(await this.repository.softRemove(softs)), + ]; + } + return this.repository.remove(items); + } + + async restore(ids: string[]) { + const items = await this.repository.find({ where: { id: In(ids) }, withDeleted: true }); + const treshedids = items.filter((item) => item.deletedAt !== null).map((item) => item.id); + if (treshedids.length < 1) return []; + this.repository.restore(treshedids); + const qb = this.repository.buildBaseQB(); + qb.where('category.id IN (:...ids)', { ids: treshedids }); + console.log(qb.getMany()); + + return qb.getMany(); + } + + /** + * 获取请求传入的父分类 + * @param current 当前分类的ID + * @param id + */ + protected 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 ${parentId} not exists!`, + ); + } + return parent; + } +} diff --git a/apps/api/src/modules/content/services/comment.service.ts b/apps/api/src/modules/content/services/comment.service.ts new file mode 100644 index 0000000..3451037 --- /dev/null +++ b/apps/api/src/modules/content/services/comment.service.ts @@ -0,0 +1,115 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { isNil } from 'lodash'; +import { SelectQueryBuilder, EntityNotFoundError, In } from 'typeorm'; + +import { treePaginate } from '@/modules/database/helpers'; + +import { QueryCommentTreeDto, QueryCommentDto, CreateCommentDto } from '../dtos'; +import { CommentEntity } from '../entities'; +import { CommentRepository, PostRepository } from '../repositories'; + +/** + * 评论数据操作 + */ +@Injectable() +export class CommentService { + constructor( + protected repository: CommentRepository, + protected postRepository: PostRepository, + ) {} + + /** + * 直接查询评论树 + * @param options + */ + async findTrees(options: QueryCommentTreeDto = {}) { + return this.repository.findTrees({ + addQuery: (qb) => { + return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post }); + }, + }); + } + + /** + * 查找一篇文章的评论并分页 + * @param dto + */ + async paginate(dto: QueryCommentDto) { + const { post, ...query } = dto; + const addQuery = (qb: SelectQueryBuilder) => { + const condition: Record = {}; + 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 c = data[i]; + comments.push( + await this.repository.findDescendantsTree(c, { + addQuery, + }), + ); + } + comments = await this.repository.toFlatTrees(comments); + return treePaginate(query, comments); + } + + /** + * 新增评论 + * @param data + * @param user + */ + 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 } }); + } + + /** + * 删除评论 + * @param id + */ + async delete(ids: string[]) { + const comments = await this.repository.find({ where: { id: In(ids) } }); + return this.repository.remove(comments); + } + + /** + * 获取评论所属文章实例 + * @param id + */ + protected async getPost(id: string) { + return !isNil(id) ? this.postRepository.findOneOrFail({ where: { id } }) : id; + } + + /** + * 获取请求传入的父分类 + * @param current 当前分类的ID + * @param 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({ + relations: ['parent', 'post'], + where: { id }, + }); + if (!parent) { + throw new EntityNotFoundError(CommentEntity, `Parent comment ${id} not exists!`); + } + } + return parent; + } +} diff --git a/apps/api/src/modules/content/services/index.ts b/apps/api/src/modules/content/services/index.ts new file mode 100644 index 0000000..c06ec58 --- /dev/null +++ b/apps/api/src/modules/content/services/index.ts @@ -0,0 +1,4 @@ +export * from './category.service'; +export * from './tag.service'; +// export * from './post.service'; +export * from './comment.service'; diff --git a/apps/api/src/modules/content/services/meili.search.service.ts b/apps/api/src/modules/content/services/meili.search.service.ts new file mode 100644 index 0000000..d7d6409 --- /dev/null +++ b/apps/api/src/modules/content/services/meili.search.service.ts @@ -0,0 +1,86 @@ +import { ForbiddenException, Injectable, OnModuleInit } from '@nestjs/common'; +import { isNil } from 'lodash'; +import { Meilisearch } from 'meilisearch'; + +import { MeiliService } from '@/modules/meilisearch/meilli.service'; + +import { SelectTrashMode } from '../constants'; +import { PostEntity } from '../entities'; +import { getSearchData, getSearchItem } from '../helpers'; +import { CategoryRepository, CommentRepository, PostRepository } from '../repositories'; +import { SearchOption } from '../types'; + +@Injectable() +export class MeiliSearchService implements OnModuleInit { + index = 'content'; + + protected _client: Meilisearch; + + constructor( + protected meilliService: MeiliService, + protected categoryRepository: CategoryRepository, + protected postRepository: PostRepository, + protected commentRepository: CommentRepository, + // private moduleRef: ModuleRef, + ) { + this._client = this.meilliService.getClient(); + } + + async onModuleInit() { + await this.client.deleteIndex(this.index); + await this.client.index(this.index).updateSortableAttributes(['updatedAt', 'commentCount']); + await this.client.index(this.index).updateFilterableAttributes(['publishedAt', 'deleteAt']); + const post = await this.postRepository.buildBaseQB().withDeleted().getMany(); + await this.client + .index(this.index) + .addDocuments( + await getSearchData(post, this.categoryRepository, this.commentRepository), + ); + } + + get client() { + if (isNil(this._client)) throw new ForbiddenException('Has not any meilli search client!'); + return this._client; + } + + async create(post: PostEntity) { + this.client + .index(this.index) + .addDocuments( + await getSearchItem(this.categoryRepository, this.commentRepository, post), + ); + } + + async update(post: PostEntity) { + this.client + .index(this.index) + .updateDocuments( + await getSearchItem(this.categoryRepository, this.commentRepository, post), + ); + } + + async delete(ids: string[]) { + this.client.index(this.index).deleteDocuments(ids); + } + + async search(query: string, options: SearchOption = {}) { + const option = { page: 1, limit: 10, trashed: SelectTrashMode.NONE, ...options }; + const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit; + const page = isNil(option.page) || option.page < 1 ? 1 : option.page; + + const result = await this.client.index(this.index).search(query, { + page, + limit, + sort: ['updatedAt:desc', 'commentCount:desc'], + }); + // return { + // items: result.hits, + // currentPage: result.page, + // perPage: result.hitsPerPage, + // totalItems: result.estimatedTotalHits, + // itemCount: result.totalHits, + // ...omit(result, ['hits', 'page', 'hitsPerPage', 'estimatedTotalHits', 'totalHits']), + // }; + return result; + } +} diff --git a/apps/api/src/modules/content/services/post.service.ts b/apps/api/src/modules/content/services/post.service.ts new file mode 100644 index 0000000..53aefd4 --- /dev/null +++ b/apps/api/src/modules/content/services/post.service.ts @@ -0,0 +1,269 @@ +import { Injectable } from '@nestjs/common'; + +import { isArray, isFunction, isNil, omit, pick } from 'lodash'; +import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm'; + +import { paginate } from '@/modules/database/helpers'; +import { QueryHook } from '@/modules/database/types'; + +import { PostOrderType, SelectTrashMode } from '../constants'; +import { CreatePostDto, QueryPostDto, UpdatePostDto } from '../dtos/post.dto'; +import { PostEntity } from '../entities/post.entity'; +import { CategoryRepository, TagRepository } from '../repositories'; +import { PostRepository } from '../repositories/post.repository'; + +import { SearchType } from '../types'; + +import { CategoryService } from './category.service'; +import { MeiliSearchService } from './meili.search.service'; + +// 文章查询接口 +type FindParams = { + [key in keyof Omit]: QueryPostDto[key]; +}; +@Injectable() +export class PostService { + constructor( + protected postRepository: PostRepository, + protected categoryRepository: CategoryRepository, + protected categoryService: CategoryService, + protected tagRepository: TagRepository, + protected searchService: MeiliSearchService, + protected search_type: SearchType = 'meilisearch', + ) {} + + /** + * 获取分页数据 + * @param options 分页选项 + * @param callback 添加额外的查询 + */ + async paginate(options: QueryPostDto, callback?: QueryHook) { + if (!isNil(options.search) && this.search_type === 'meilisearch') { + const result = this.searchService.search( + options.search, + pick(options, ['trashed', 'page', 'limit', 'isPublished']), + ); + console.log(result); + + return result; + } + const qb = await this.buildListQuery(this.postRepository.buildBaseQB(), options, callback); + return paginate(qb, options); + } + + /** + * 查询单篇文章 + * @param id + * @param callback 添加额外的查询 + */ + async detail(id: string, callback?: QueryHook) { + let qb = this.postRepository.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; + } + + /** + * 创建文章 + * @param data + */ + 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) + ? await this.categoryRepository.findOneOrFail({ where: { id: data.category } }) + : null, + // 文章关联的标签 + tags: isArray(data.tags) + ? await this.tagRepository.findBy({ + id: In(data.tags), + }) + : [], + publishedAt, + }; + const item = await this.postRepository.save(createPostDto); + const result = await this.detail(item.id); + await this.searchService.create(result); + return result; + } + + /** + * 更新文章 + * @param data + */ + 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) { + // 更新分类 + const category = isNil(data.category) + ? null + : await this.categoryRepository.findOneByOrFail({ id: data.category }); + post.category = category; + this.postRepository.save(post, { reload: true }); + } + if (isArray(data.tags)) { + // 更新文章关联标签 + await this.postRepository + .createQueryBuilder('post') + .relation(PostEntity, 'tags') + .of(post) + .addAndRemove(data.tags, post.tags ?? []); + } + await this.postRepository.update(data.id, { + ...omit(data, ['id', 'tags', 'category', 'publish']), + publishedAt, + }); + const result = await this.detail(data.id); + await this.searchService.update(result); + return result; + } + + /** + * 批量删除文章 + * @param ids + * @param trash 是否使用回收站 + */ + async delete(ids: string[], trash: boolean) { + const items = await this.postRepository + .createQueryBuilder('post') + .where('post.id in (:...ids)', { ids }) + .withDeleted() + .getMany(); + const result = []; + if (trash) { + const directs = items.filter((item) => item.deleteAt !== null); + const directIds = directs.map(({ id }) => id); + const softs = items.filter((item) => item.deleteAt === null); + if (directs.length > 0) { + result.push(...(await this.postRepository.remove(directs))); + await this.searchService.delete(directIds); + } + if (softs.length > 0) { + result.push(...(await this.postRepository.softRemove(softs))); + Promise.all(softs.map((item) => this.searchService.update(item))); + } + } else { + await this.searchService.delete(ids); + return this.postRepository.remove(items); + } + return result; + } + + async restore(ids: string[]) { + const items = await this.postRepository + .createQueryBuilder('post') + .whereInIds(ids) + .withDeleted() + .getMany(); + const trashed = items.filter((item) => item.deleteAt !== null); + Promise.all(trashed.map((item) => this.searchService.update(item))); + const trashedIds = trashed.map((v) => v.id); + if (trashedIds.length < 1) return []; + await this.postRepository.restore(trashedIds); + const qb = await this.buildListQuery(this.postRepository.buildBaseQB(), {}, async (qbs) => + qbs.andWhereInIds(trashedIds), + ); + return qb.getMany(); + } + + /** + * 构建文章列表查询器 + * @param qb 初始查询构造器 + * @param options 排查分页选项后的查询选项 + * @param callback 添加额外的查询 + */ + protected async buildListQuery( + qb: SelectQueryBuilder, + options: FindParams, + callback?: QueryHook, + ) { + const { category, tag, orderBy, isPublished, transhed, search } = options; + if (typeof isPublished === 'boolean') { + isPublished + ? qb.where({ + publishedAt: Not(IsNull()), + }) + : qb.where({ + publishedAt: IsNull(), + }); + } + this.queryOrderBy(qb, orderBy); + if (search) { + this.buildSearchQuery(qb, search); + } + if (category) await this.queryByCategory(category, qb); + // 查询某个标签关联的文章 + if (tag) qb.where('tags.id = :id', { id: tag }); + if (transhed === SelectTrashMode.ALL || transhed === SelectTrashMode.ONLY) { + qb.withDeleted(); + if (transhed === SelectTrashMode.ONLY) { + qb.where({ deleteAt: Not(IsNull()) }); + } + } + if (callback) return callback(qb); + return qb; + } + + /** + * 对文章进行排序的Query构建 + * @param qb + * @param orderBy 排序方式 + */ + protected queryOrderBy(qb: SelectQueryBuilder, orderBy?: PostOrderType) { + switch (orderBy) { + case PostOrderType.CREATED: + return qb.orderBy('post.createdAt', 'DESC'); + case PostOrderType.UPDATED: + return qb.orderBy('post.updatedAt', 'DESC'); + case PostOrderType.PUBLISHED: + return qb.orderBy('post.publishedAt', 'DESC'); + case PostOrderType.COMMENTCOUNT: + return qb.orderBy('commentCount', 'DESC'); + case PostOrderType.CUSTOM: + return qb.orderBy('customOrder', 'DESC'); + default: + return qb + .orderBy('post.createdAt', 'DESC') + .addOrderBy('post.updatedAt', 'DESC') + .addOrderBy('post.publishedAt', 'DESC') + .addOrderBy('commentCount', 'DESC'); + } + } + + /** + * 查询出分类及其后代分类下的所有文章的Query构建 + * @param id + * @param qb + */ + protected async queryByCategory(id: string, qb: SelectQueryBuilder) { + 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('category.id IN (:...ids)', { + ids, + }); + } + + protected async buildSearchQuery(qb: SelectQueryBuilder, search: string) { + qb.where('post.title like :search', { search: `%${search}%` }) + .orWhere('post.body like :search', { search: `%${search}%` }) + .orWhere('post.summary like :search', { search: `%${search}%` }) + .orWhere('post.keywords like :search', { search: `%${search}%` }) + .orWhere('tags.name like :search', { search: `%${search}%` }) + .orWhere('category.name like :search', { search: `%${search}%` }); + + return qb; + } +} diff --git a/apps/api/src/modules/content/services/sanitize.service.ts b/apps/api/src/modules/content/services/sanitize.service.ts new file mode 100644 index 0000000..b278714 --- /dev/null +++ b/apps/api/src/modules/content/services/sanitize.service.ts @@ -0,0 +1,25 @@ +import { deepMerge } from '@3rapp/utils'; +import { Injectable } from '@nestjs/common'; +import sanitizeHtml from 'sanitize-html'; + +@Injectable() +export class SanitizeService { + protected config: sanitizeHtml.IOptions = {}; + + constructor() { + this.config = { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'code']), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + '*': ['class', 'style', 'height', 'width'], + }, + parser: { + lowerCaseTags: true, + }, + }; + } + + sanitize(html: string, options?: sanitizeHtml.IOptions): string { + return sanitizeHtml(html, deepMerge(this.config, options ?? {}, 'replace')); + } +} diff --git a/apps/api/src/modules/content/services/tag.service.ts b/apps/api/src/modules/content/services/tag.service.ts new file mode 100644 index 0000000..815e1ab --- /dev/null +++ b/apps/api/src/modules/content/services/tag.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { omit } from 'lodash'; + +import { paginate } from '@/modules/database/helpers'; + +import { QueryTagDto, CreateTagDto, UpdateTagDto } from '../dtos'; +import { TagRepository } from '../repositories'; + +/** + * 标签数据操作 + */ +@Injectable() +export class TagService { + constructor(protected repository: TagRepository) {} + + /** + * 获取标签数据 + * @param options 分页选项 + * @param callback 添加额外的查询 + */ + async paginate(options: QueryTagDto) { + const qb = this.repository.buildBaseQB(); + return paginate(qb, options); + } + + /** + * 查询单个标签信息 + * @param id + * @param callback 添加额外的查询 + */ + async detail(id: string) { + const qb = this.repository.buildBaseQB(); + qb.where(`tag.id = :id`, { id }); + return qb.getOneOrFail(); + } + + /** + * 创建标签 + * @param data + */ + async create(data: CreateTagDto) { + const item = await this.repository.save(data); + return this.detail(item.id); + } + + /** + * 更新标签 + * @param data + */ + async update(data: UpdateTagDto) { + await this.repository.update(data.id, omit(data, ['id'])); + return this.detail(data.id); + } + + /** + * 删除标签 + * @param id + */ + async delete(id: string) { + const item = await this.repository.findOneByOrFail({ id }); + return this.repository.remove(item); + } +} diff --git a/apps/api/src/modules/content/subscribers/post.subscriber.ts b/apps/api/src/modules/content/subscribers/post.subscriber.ts new file mode 100644 index 0000000..da980f3 --- /dev/null +++ b/apps/api/src/modules/content/subscribers/post.subscriber.ts @@ -0,0 +1,30 @@ +import { DataSource, EventSubscriber } from 'typeorm'; + +import { PostBodyType } from '../constants'; +import { PostEntity } from '../entities/post.entity'; +import { SanitizeService } from '../services/sanitize.service'; + +@EventSubscriber() +export class PostSubscriber { + constructor( + protected datasource: DataSource, + protected sanitizeService: SanitizeService, + // protected postRepository: PostRepository, + ) { + datasource.subscribers.push(this); + } + + listenTo() { + return PostEntity; + } + + /** + * 加载文章数据的处理 + * @param entity + */ + async afterLoad(entity: PostEntity) { + if (entity.type === PostBodyType.HTML) { + entity.body = this.sanitizeService.sanitize(entity.body); + } + } +} diff --git a/apps/api/src/modules/content/types.ts b/apps/api/src/modules/content/types.ts new file mode 100644 index 0000000..80b1f2f --- /dev/null +++ b/apps/api/src/modules/content/types.ts @@ -0,0 +1,12 @@ +import { SelectTrashMode } from './constants'; + +export type SearchType = 'mysql' | 'elasticsearch' | 'meilisearch'; +export interface ContentConfig { + searchType?: SearchType; +} +export interface SearchOption { + trashed?: SelectTrashMode; + isPublished?: boolean; + page?: number; + limit?: number; +} diff --git a/apps/api/src/modules/core/constants.ts b/apps/api/src/modules/core/constants.ts index b58da00..d1ed741 100644 --- a/apps/api/src/modules/core/constants.ts +++ b/apps/api/src/modules/core/constants.ts @@ -1 +1 @@ -export const DTOVALIDATION_OPTIONS = Symbol('DTOVALIDATION_OPTIONS'); +export const DTO_VALIDATION_OPTIONS = Symbol('DTOVALIDATION_OPTIONS'); diff --git a/apps/api/src/modules/core/constraints/index,ts b/apps/api/src/modules/core/constraints/index,ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/core/constraints/match.constraint.ts b/apps/api/src/modules/core/constraints/match.constraint.ts new file mode 100644 index 0000000..1d6a911 --- /dev/null +++ b/apps/api/src/modules/core/constraints/match.constraint.ts @@ -0,0 +1,42 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isMatch', async: false }) +export class MatchConstraint implements ValidatorConstraintInterface { + validate(value: any, validationArguments?: ValidationArguments): boolean { + const [relatedProperty, reverse] = validationArguments.constraints; + const relatedValue = (validationArguments.object as any)[relatedProperty]; + return reverse ? value !== relatedValue : value === relatedValue; + } + + defaultMessage(validationArguments?: ValidationArguments): string { + const [relatedProperty, reverse] = validationArguments.constraints; + return `${relatedProperty} and ${validationArguments.property} ${reverse ? `is` : `don't`} match`; + } +} +/** + * 判断DTO中两个属性的值是否相等的验证规则 + * @param relatedProperty 用于对比的属性名称 + * @param reverse 是否反转 + * @param validationOptions class-validator库的选项 + */ +export function IsMatch( + relatedProperty: string, + reverse = false, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [relatedProperty, reverse], + validator: MatchConstraint, + }); + }; +} diff --git a/apps/api/src/modules/core/constraints/match.phone.constraint.ts b/apps/api/src/modules/core/constraints/match.phone.constraint.ts new file mode 100644 index 0000000..20a5afb --- /dev/null +++ b/apps/api/src/modules/core/constraints/match.phone.constraint.ts @@ -0,0 +1,48 @@ +import { + isMobilePhone, + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; +import { IsMobilePhoneOptions, MobilePhoneLocale } from 'validator'; + +/** + * 手机号验证规则,必须是"区域号.手机号"的形式 + */ +export function isMatchPhone( + value: any, + locale?: MobilePhoneLocale, + options?: IsMobilePhoneOptions, +): boolean { + if (!value) return false; + const phoneArr: string[] = value.split('.'); + if (phoneArr.length !== 2) return false; + return isMobilePhone(phoneArr.join(''), locale, options); +} + +/** + * 手机号验证规则,必须是"区域号.手机号"的形式 + * @param locales 区域选项 + * @param options isMobilePhone约束选项 + * @param validationOptions class-validator库的选项 + */ +export function IsMatchPhone( + locales?: MobilePhoneLocale | MobilePhoneLocale[], + options?: IsMobilePhoneOptions, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [locales || 'any', options], + validator: { + validate: (value: any, args: ValidationArguments): boolean => + isMatchPhone(value, args.constraints[0], args.constraints[1]), + defaultMessage: (_args: ValidationArguments) => + '$property must be a phone number,eg: +86.12345678901', + }, + }); + }; +} diff --git a/apps/api/src/modules/core/constraints/password.constraint.ts b/apps/api/src/modules/core/constraints/password.constraint.ts new file mode 100644 index 0000000..49bc637 --- /dev/null +++ b/apps/api/src/modules/core/constraints/password.constraint.ts @@ -0,0 +1,69 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +type ModelType = 1 | 2 | 3 | 4 | 5; + +/** + * 密码验证规则 + */ +@ValidatorConstraint({ name: 'isPassword', async: false }) +export class IsPasswordConstraint implements ValidatorConstraintInterface { + validate(value: string, args: ValidationArguments) { + const validateModel: ModelType = args.constraints[0] ?? 1; + switch (validateModel) { + // 必须由大写或小写字母组成(默认模式) + case 1: + return /\d/.test(value) && /[a-zA-Z]/.test(value); + // 必须由小写字母组成 + case 2: + return /\d/.test(value) && /[a-z]/.test(value); + // 必须由大写字母组成 + case 3: + return /\d/.test(value) && /[A-Z]/.test(value); + // 必须包含数字,小写字母,大写字母 + case 4: + return /\d/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value); + // 必须包含数字,小写字母,大写字母,特殊符号 + case 5: + return ( + /\d/.test(value) && + /[a-z]/.test(value) && + /[A-Z]/.test(value) && + /[!@#$%^&]/.test(value) + ); + default: + return /\d/.test(value) && /[a-zA-Z]/.test(value); + } + } + + defaultMessage(_args: ValidationArguments) { + return "($value) 's format error!"; + } +} + +/** + * 密码复杂度验证 + * 模式1: 必须由大写或小写字母组成(默认模式) + * 模式2: 必须由小写字母组成 + * 模式3: 必须由大写字母组成 + * 模式4: 必须包含数字,小写字母,大写字母 + * 模式5: 必须包含数字,小写字母,大写字母,特殊符号 + * @param model 验证模式 + * @param validationOptions + */ +export function IsPassword(model?: ModelType, validationOptions?: ValidationOptions) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [model], + validator: IsPasswordConstraint, + }); + }; +} diff --git a/apps/api/src/modules/core/decorators/dto-validation.decorator.ts b/apps/api/src/modules/core/decorators/dto-validation.decorator.ts new file mode 100644 index 0000000..af24bf6 --- /dev/null +++ b/apps/api/src/modules/core/decorators/dto-validation.decorator.ts @@ -0,0 +1,15 @@ +import { Paramtype, SetMetadata } from '@nestjs/common'; +import { ClassTransformOptions } from 'class-transformer'; +import { ValidatorOptions } from 'class-validator'; + +import { DTO_VALIDATION_OPTIONS } from '../constants'; + +/** + * 用于配置通过全局验证管道验证数据的DTO类装饰器 + * @param options + */ +export const DtoValidation = ( + options?: ValidatorOptions & { + transformOptions?: ClassTransformOptions; + } & { type?: Paramtype }, +) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {}); diff --git a/apps/api/src/modules/core/helpers/app.ts b/apps/api/src/modules/core/helpers/app.ts index 44c9ed3..38b1f62 100644 --- a/apps/api/src/modules/core/helpers/app.ts +++ b/apps/api/src/modules/core/helpers/app.ts @@ -1,6 +1,6 @@ import { Global, Module, ModuleMetadata } from '@nestjs/common'; -import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { useContainer } from 'class-validator'; import { omit } from 'lodash'; @@ -17,7 +17,7 @@ export const createApp = (options: CreateOptions) => async (): Promise => { const { config, builder } = options; await app.configure.initilize(config.factories, config.storage); if (!app.configure.has('app')) { - console.log(11); + throw new Error('app config not found'); } const BootModule = await createBootModule(app.configure, options); app.container = await builder({ configure: app.configure, BootModule }); @@ -25,8 +25,6 @@ export const createApp = (options: CreateOptions) => async (): Promise => { app.container.setGlobalPrefix(await app.configure.get('app.prefix')); } useContainer(app.container.select(BootModule), { fallbackOnErrors: true }); - console.log(app.container); - return app; }; @@ -46,21 +44,27 @@ export async function createBootModule( } return item; }); - if (globals.pipe !== null) { - const pip = globals.pipe - ? globals.pipe(configure) - : new AppPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false, value: false }, - }); - providers.push({ - provide: AppPipe, - useValue: pip, - }); - } + // if (globals.pipe !== null) { + // const pip = globals.pipe + // ? globals.pipe(configure) + // : new AppPipe({ + // transform: true, + // whitelist: true, + // forbidNonWhitelisted: true, + // forbidUnknownValues: true, + // validationError: { target: false, value: false }, + // }); + providers.push({ + provide: APP_PIPE, + useValue: new AppPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + validationError: { target: false }, + }), + }); + // } if (globals.interceptor !== null) { providers.push({ provide: APP_INTERCEPTOR, diff --git a/apps/api/src/modules/core/providers/app.pipe.ts b/apps/api/src/modules/core/providers/app.pipe.ts index d151cfe..54ae699 100644 --- a/apps/api/src/modules/core/providers/app.pipe.ts +++ b/apps/api/src/modules/core/providers/app.pipe.ts @@ -3,12 +3,12 @@ import { ArgumentMetadata, BadRequestException, Paramtype, ValidationPipe } from import { isObject, omit } from 'lodash'; -import { DTOVALIDATION_OPTIONS } from '../constants'; +import { DTO_VALIDATION_OPTIONS } from '../constants'; export class AppPipe extends ValidationPipe { async transform(value: any, metadate: ArgumentMetadata) { const { metatype, type } = metadate; - const options = Reflect.getMetadata(DTOVALIDATION_OPTIONS, metatype) || {}; + const options = Reflect.getMetadata(DTO_VALIDATION_OPTIONS, metatype) || {}; const originOptions = { ...this.validatorOptions }; const originTransformOptions = { ...this.transformOptions }; // {a:b} b为a的解构重命名 @@ -40,7 +40,6 @@ export class AppPipe extends ValidationPipe { this.transformOptions = originTransformOptions; return result; } catch (error: any) { - console.log(error); this.validatorOptions = originOptions; this.transformOptions = originTransformOptions; if ('response' in error) throw new BadRequestException(error.response); diff --git a/apps/api/src/modules/database/constants.ts b/apps/api/src/modules/database/constants.ts new file mode 100644 index 0000000..b3495e8 --- /dev/null +++ b/apps/api/src/modules/database/constants.ts @@ -0,0 +1 @@ +export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA'; diff --git a/apps/api/src/modules/database/constraints/data.exist.constraint.ts b/apps/api/src/modules/database/constraints/data.exist.constraint.ts new file mode 100644 index 0000000..6fcf2a2 --- /dev/null +++ b/apps/api/src/modules/database/constraints/data.exist.constraint.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, + ValidationOptions, + registerDecorator, +} from 'class-validator'; +import { ObjectType, DataSource, Repository } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 用于查询的比对字段,默认id + */ + map?: string; +}; +/** + * 查询某个字段的值是否在数据表中存在 + */ +@ValidatorConstraint({ name: 'dataExist', async: true }) +@Injectable() +export class DataExistConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: string, args: ValidationArguments) { + let repo: Repository; + if (!value) return true; + // 默认对比字段是id + let map = 'id'; + // 通过传入的entity获取其repository + if ('entity' in args.constraints[0]) { + map = args.constraints[0].map ?? 'id'; + repo = this.dataSource.getRepository(args.constraints[0].entity); + } else { + repo = this.dataSource.getRepository(args.constraints[0]); + } + // 通过查询记录是否存在进行验证 + const item = await repo.findOne({ where: { [map]: value } }); + return !!item; + } + + defaultMessage(args: ValidationArguments) { + if (!args.constraints[0]) { + return 'Model not been specified!'; + } + return `All instance of ${args.constraints[0].name} must been exists in databse!`; + } +} + +/** + * 模型存在性验证 + * @param entity + * @param validationOptions + */ +function IsDataExist( + entity: ObjectType, + validationOptions?: ValidationOptions, +): (object: Record, propertyName: string) => void; + +/** + * 模型存在性验证 + * @param condition + * @param validationOptions + */ +function IsDataExist( + condition: Condition, + validationOptions?: ValidationOptions, +): (object: Record, propertyName: string) => void; + +/** + * 模型存在性验证 + * @param condition + * @param validationOptions + */ +function IsDataExist( + condition: ObjectType | Condition, + validationOptions?: ValidationOptions, +): (object: Record, propertyName: string) => void { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [condition], + validator: DataExistConstraint, + }); + }; +} + +export { IsDataExist }; diff --git a/apps/api/src/modules/database/constraints/index.ts b/apps/api/src/modules/database/constraints/index.ts new file mode 100644 index 0000000..5e17887 --- /dev/null +++ b/apps/api/src/modules/database/constraints/index.ts @@ -0,0 +1,5 @@ +export * from './data.exist.constraint'; +export * from './tree.unique.constraint'; +export * from './tree.unique.exist.constraint'; +export * from './unique.constraint'; +export * from './unique.exist.constraint'; diff --git a/apps/api/src/modules/database/constraints/tree.unique.constraint.ts b/apps/api/src/modules/database/constraints/tree.unique.constraint.ts new file mode 100644 index 0000000..8599e11 --- /dev/null +++ b/apps/api/src/modules/database/constraints/tree.unique.constraint.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { isNil, merge } from 'lodash'; +import { DataSource, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + parentKey?: string; + property?: string; +}; + +/** + * 验证树形模型下同父节点同级别某个字段的唯一性 + */ +@Injectable() +@ValidatorConstraint({ name: 'treeDataUnique', async: true }) +export class UniqueTreeConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + const config: Omit = { + parentKey: 'parent', + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + // 需要查询的属性名,默认为当前验证的属性 + const argsObj = args.object as any; + if (!condition.entity) return false; + + try { + // 获取repository + const repo = this.dataSource.getTreeRepository(condition.entity); + + if (isNil(value)) return true; + const collection = await repo.find({ + where: { + parent: !argsObj[condition.parentKey] + ? null + : { id: argsObj[condition.parentKey] }, + }, + withDeleted: true, + }); + // 对比每个子分类的queryProperty值是否与当前验证的dto属性相同,如果有相同的则验证失败 + return collection.every((item) => item[condition.property] !== value); + } catch (err) { + return false; + } + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique with siblings element!`; + } +} + +/** + * 树形模型下同父节点同级别某个字段的唯一性验证 + * @param params + * @param validationOptions + */ +export function IsTreeUnique( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueTreeConstraint, + }); + }; +} diff --git a/apps/api/src/modules/database/constraints/tree.unique.exist.constraint.ts b/apps/api/src/modules/database/constraints/tree.unique.exist.constraint.ts new file mode 100644 index 0000000..0afa379 --- /dev/null +++ b/apps/api/src/modules/database/constraints/tree.unique.exist.constraint.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { merge } from 'lodash'; +import { DataSource, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 默认忽略字段为id + */ + ignore?: string; + /** + * 查询条件字段,默认为指定的ignore + */ + findKey?: string; + /** + * 需要查询的属性名,默认为当前验证的属性 + */ + property?: string; +}; + +/** + * 在更新时验证树形数据同父节点同级别某个字段的唯一性,通过ignore指定忽略的字段 + */ +@Injectable() +@ValidatorConstraint({ name: 'treeDataUniqueExist', async: true }) +export class UniqueTreeExistConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + const config: Omit = { + ignore: 'id', + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + if (!condition.findKey) { + condition.findKey = condition.ignore; + } + if (!condition.entity) return false; + // 在传入的dto数据中获取需要忽略的字段的值 + const ignoreValue = (args.object as any)[condition.ignore]; + // 查询条件字段的值 + const keyValue = (args.object as any)[condition.findKey]; + if (!ignoreValue || !keyValue) return false; + const repo = this.dataSource.getTreeRepository(condition.entity); + // 根据查询条件查询出当前验证的数据 + const item = await repo.findOne({ + where: { [condition.findKey]: keyValue }, + relations: ['parent'], + }); + // 没有此数据则验证失败 + if (!item) return false; + // 如果验证数据没有parent则把所有顶级分类作为验证数据否则就把同一个父分类下的子分类作为验证数据 + const rows: any[] = await repo.find({ + where: { + parent: !item.parent ? null : { id: item.parent.id }, + }, + withDeleted: true, + }); + // 在忽略本身数据后如果同级别其它数据与验证的queryProperty的值相同则验证失败 + return !rows.find( + (row) => row[condition.property] === value && row[condition.ignore] !== ignoreValue, + ); + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique with siblings element!`; + } +} + +/** + * 树形数据同父节点同级别某个字段的唯一性验证 + * @param params + * @param validationOptions + */ +export function IsTreeUniqueExist( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueTreeExistConstraint, + }); + }; +} diff --git a/apps/api/src/modules/database/constraints/unique.constraint.ts b/apps/api/src/modules/database/constraints/unique.constraint.ts new file mode 100644 index 0000000..331af3d --- /dev/null +++ b/apps/api/src/modules/database/constraints/unique.constraint.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { isNil, merge } from 'lodash'; +import { DataSource, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 如果没有指定字段则使用当前验证的属性作为查询依据 + */ + property?: string; +}; + +/** + * 验证某个字段的唯一性 + */ +@ValidatorConstraint({ name: 'dataUnique', async: true }) +@Injectable() +export class UniqueConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + // 获取要验证的模型和字段 + const config: Omit = { + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + if (!condition.entity) return false; + try { + // 查询是否存在数据,如果已经存在则验证失败 + const repo = this.dataSource.getRepository(condition.entity); + return isNil( + await repo.findOne({ where: { [condition.property]: value }, withDeleted: true }), + ); + } catch (err) { + // 如果数据库操作异常则验证失败 + return false; + } + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!(args.object as any).getManager) { + return 'getManager function not been found!'; + } + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique!`; + } +} + +/** + * 数据唯一性验证 + * @param params Entity类或验证条件对象 + * @param validationOptions + */ +export function IsUnique( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueConstraint, + }); + }; +} diff --git a/apps/api/src/modules/database/constraints/unique.exist.constraint.ts b/apps/api/src/modules/database/constraints/unique.exist.constraint.ts new file mode 100644 index 0000000..47238aa --- /dev/null +++ b/apps/api/src/modules/database/constraints/unique.exist.constraint.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { isNil, merge } from 'lodash'; +import { DataSource, Not, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 默认忽略字段为id + */ + ignore?: string; + /** + * 忽略的字段在DTO类中的属性,默认与ignore相同,用于处理忽略字段在DTO中取值时的属性名与数据库中的字段名不同的特殊情况 + */ + ignoreKey?: string; + /** + * 如果没有指定字段则使用当前验证的属性作为查询依据 + */ + property?: string; +}; + +/** + * 在更新时验证唯一性,通过指定ignore忽略忽略的字段 + */ +@ValidatorConstraint({ name: 'dataUniqueExist', async: true }) +@Injectable() +export class UniqueExistContraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + const config: Omit = { + ignore: 'id', + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + if (!condition.entity) return false; + // 在传入的dto数据中获取需要忽略的字段的值 + const ignoreValue = (args.object as any)[ + isNil(condition.ignoreKey) ? condition.ignore : condition.ignoreKey + ]; + // 如果忽略字段不存在则验证失败 + if (ignoreValue === undefined) return false; + // 通过entity获取repository + const repo = this.dataSource.getRepository(condition.entity); + // 查询忽略字段之外的数据是否对queryProperty的值唯一 + return isNil( + await repo.findOne({ + where: { + [condition.property]: value, + [condition.ignore]: Not(ignoreValue), + }, + withDeleted: true, + }), + ); + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!(args.object as any).getManager) { + return 'getManager function not been found!'; + } + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique!`; + } +} + +/** + * 更新数据时的唯一性验证 + * @param params Entity类或验证条件对象 + * @param validationOptions + */ +export function IsUniqueExist( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueExistContraint, + }); + }; +} diff --git a/apps/api/src/modules/database/database.module.ts b/apps/api/src/modules/database/database.module.ts new file mode 100644 index 0000000..85284d6 --- /dev/null +++ b/apps/api/src/modules/database/database.module.ts @@ -0,0 +1,60 @@ +import { DynamicModule, Module, ModuleMetadata, Type } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; + +import { DataSource } from 'typeorm'; + +import { database } from '@/configs'; + +import { Configure } from '../config/configure'; + +import { CUSTOM_REPOSITORY_METADATA } from './constants'; +import { + DataExistConstraint, + UniqueConstraint, + UniqueExistContraint, + UniqueTreeConstraint, + UniqueTreeExistConstraint, +} from './constraints'; + +@Module({}) +export class DatabaseModule { + static async forRoot(configure: Configure) { + return { + global: true, + module: DatabaseModule, + imports: [TypeOrmModule.forRoot(database(configure))], + }; + } + + static forRepository>( + repositories: T[], + dataSourceName?: string, + ): DynamicModule { + const providers: ModuleMetadata['providers'] = [ + DataExistConstraint, + UniqueConstraint, + UniqueExistContraint, + UniqueTreeConstraint, + UniqueTreeExistConstraint, + ]; + for (const Repo of repositories) { + const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo); + if (!entity) { + continue; + } + providers.push({ + inject: [getDataSourceToken(dataSourceName)], + provide: Repo, + useFactory: (dataSource: DataSource) => { + const base = dataSource.getRepository(entity); + return new Repo(base.target, base.manager, base.queryRunner); + }, + }); + } + return { + exports: providers, + module: DatabaseModule, + providers, + }; + } +} diff --git a/apps/api/src/modules/database/decorators/repository.decorator.ts b/apps/api/src/modules/database/decorators/repository.decorator.ts new file mode 100644 index 0000000..d12af72 --- /dev/null +++ b/apps/api/src/modules/database/decorators/repository.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +import { CUSTOM_REPOSITORY_METADATA } from '../constants'; + +export const CustomRepository = (entity: T): ClassDecorator => + SetMetadata(CUSTOM_REPOSITORY_METADATA, entity); diff --git a/apps/api/src/modules/database/helpers.ts b/apps/api/src/modules/database/helpers.ts new file mode 100644 index 0000000..dd311f8 --- /dev/null +++ b/apps/api/src/modules/database/helpers.ts @@ -0,0 +1,64 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { PaginateOptions, PaginateReturn } from './types'; + +/** + * 分页函数 + * @param qb queryBuilder实例 + * @param options 分页选项 + */ +export const paginate = async ( + qb: SelectQueryBuilder, + options: PaginateOptions, +): Promise> => { + const { page = 1, limit = 10 } = options; + qb.take(limit).skip((page - 1) * limit); + const items = await qb.getMany(); + const totalItems = await qb.getCount(); + const totalPages = + totalItems % limit === 0 ? totalItems / limit : Math.floor(totalItems / limit) + 1; + const remainder = totalItems % limit !== 0 ? totalItems % limit : limit; + const itemCount = page < totalPages ? limit : remainder; + return { + items, + meta: { + totalItems, + itemCount, + perPage: limit, + totalPages, + currentPage: page, + }, + }; +}; +/** + * 数据手动分页函数 + * @param options 分页选项 + * @param data 数据列表 + */ +export function treePaginate( + options: PaginateOptions, + data: E[], +): PaginateReturn { + const { page, limit } = options; + let items: E[] = []; + const totalItems = data.length; + const totalRst = totalItems / limit; + const totalPages = + totalRst > Math.floor(totalRst) ? Math.floor(totalRst) + 1 : Math.floor(totalRst); + let itemCount = 0; + if (page <= totalPages) { + itemCount = page === totalPages ? totalItems - (totalPages - 1) * limit : limit; + const start = (page - 1) * limit; + items = data.slice(start, start + itemCount); + } + return { + items, + meta: { + itemCount, + totalItems, + perPage: limit, + totalPages, + currentPage: page, + }, + }; +} diff --git a/apps/api/src/modules/database/types.ts b/apps/api/src/modules/database/types.ts new file mode 100644 index 0000000..30b68fe --- /dev/null +++ b/apps/api/src/modules/database/types.ts @@ -0,0 +1,50 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +export type QueryHook = (qb: SelectQueryBuilder) => Promise>; +/** + * 分页原数据 + */ +export interface PaginateMeta { + /** + * 当前页项目数量 + */ + itemCount: number; + /** + * 项目总数量 + */ + totalItems?: number; + /** + * 每页显示数量 + */ + perPage: number; + /** + * 总页数 + */ + totalPages?: number; + /** + * 当前页数 + */ + currentPage: number; +} + +/** + * 分页选项 + */ +export interface PaginateOptions { + /** + * 当前页数 + */ + page?: number; + /** + * 每页显示数量 + */ + limit?: number; +} + +/** + * 分页返回数据 + */ +export interface PaginateReturn { + meta: PaginateMeta; + items: E[]; +} diff --git a/apps/api/src/modules/meilisearch/helpers.ts b/apps/api/src/modules/meilisearch/helpers.ts new file mode 100644 index 0000000..41a1b57 --- /dev/null +++ b/apps/api/src/modules/meilisearch/helpers.ts @@ -0,0 +1,17 @@ +import { MelliConfig } from './types'; + +export const createMeilliOptions = async ( + config: MelliConfig, +): Promise => { + if (config.length === 0) return config; + let options: MelliConfig = [...config]; + const names = options.map(({ name }) => name); + if (!names.includes('default')) options[0].name = 'default'; + else if (names.filter((name) => name === 'default').length > 0) { + options = options.reduce( + (o, n) => (o.map(({ name }) => name).includes('default') ? o : [...o, n]), + [], + ); + } + return options; +}; diff --git a/apps/api/src/modules/meilisearch/meilli.service.ts b/apps/api/src/modules/meilisearch/meilli.service.ts new file mode 100644 index 0000000..3d512af --- /dev/null +++ b/apps/api/src/modules/meilisearch/meilli.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; + +import MeiliSearch from 'meilisearch'; + +import { MelliConfig } from './types'; + +@Injectable() +export class MeiliService { + protected options: MelliConfig; + + protected clients: Map = new Map(); + + constructor(options: MelliConfig) { + this.options = options; + } + + getOptions() { + return this.options; + } + + async createClients() { + this.options.forEach(async (option) => + this.clients.set(option.name, new MeiliSearch(option)), + ); + } + + getClient(name: string = 'default') { + !this.clients.has(name) && new Error(`Client ${name} not found`); + return this.clients.get(name); + } + + getClients() { + return this.clients; + } +} diff --git a/apps/api/src/modules/meilisearch/melli.module.ts b/apps/api/src/modules/meilisearch/melli.module.ts new file mode 100644 index 0000000..fa0f346 --- /dev/null +++ b/apps/api/src/modules/meilisearch/melli.module.ts @@ -0,0 +1,24 @@ +import { DynamicModule } from '@nestjs/common'; + +import { createMeilliOptions } from './helpers'; +import { MeiliService } from './meilli.service'; +import { MelliConfig } from './types'; + +export const meiliForRoot = (configregister: () => MelliConfig): DynamicModule => { + return { + module: class MeiliModule {}, + providers: [ + { + provide: MeiliService, + useFactory: async () => { + const service = new MeiliService(await createMeilliOptions(configregister())); + service.createClients(); + return service; + }, + }, + ], + imports: [], + exports: [MeiliService], + global: true, + }; +}; diff --git a/apps/api/src/modules/meilisearch/types.ts b/apps/api/src/modules/meilisearch/types.ts new file mode 100644 index 0000000..3524088 --- /dev/null +++ b/apps/api/src/modules/meilisearch/types.ts @@ -0,0 +1,7 @@ +import { Config } from 'meilisearch'; + +// MelliSearch模块的配置 +export type MelliConfig = MelliOption[]; + +// MeilliSearch的连接节点配置 +export type MelliOption = Config & { name: string }; diff --git a/apps/api/src/modules/restful/delete-with-trash.dto.ts b/apps/api/src/modules/restful/delete-with-trash.dto.ts new file mode 100644 index 0000000..007ad8f --- /dev/null +++ b/apps/api/src/modules/restful/delete-with-trash.dto.ts @@ -0,0 +1,18 @@ +import { toBoolean } from '@3rapp/utils'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +import { DtoValidation } from '../core/decorators/dto-validation.decorator'; + +import { DeleteDto } from './delete.dto'; + +@DtoValidation() +export class DeleteWithTrashDto extends DeleteDto { + @Transform(({ value }) => toBoolean(value)) + @IsBoolean() + @IsOptional() + transh?: boolean; +} + +@DtoValidation() +export class RestoreDto extends DeleteDto {} diff --git a/apps/api/src/modules/restful/delete.dto.ts b/apps/api/src/modules/restful/delete.dto.ts new file mode 100644 index 0000000..4e1c91b --- /dev/null +++ b/apps/api/src/modules/restful/delete.dto.ts @@ -0,0 +1,19 @@ +import { IsDefined, IsUUID } from 'class-validator'; + +import { DtoValidation } from '../core/decorators/dto-validation.decorator'; + +/** + * 批量删除 + */ +DtoValidation(); +export class DeleteDto { + @IsUUID(undefined, { + each: true, + message: 'ID格式错误', + }) + @IsDefined({ + each: true, + message: 'ID不能为空', + }) + ids: string[]; +} diff --git a/apps/api/src/modules/restful/index.ts b/apps/api/src/modules/restful/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/restful/restore.dto.ts b/apps/api/src/modules/restful/restore.dto.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/options.ts b/apps/api/src/options.ts index 5f64a71..9f6d0c9 100644 --- a/apps/api/src/options.ts +++ b/apps/api/src/options.ts @@ -4,14 +4,21 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify import * as configs from '@/configs'; -import { AppModule } from './app.module'; import { ConfigModule } from './modules/config/config.module'; +import { forRoot } from './modules/content/content.module'; import { CreateOptions } from './modules/core/core.types'; +import { DatabaseModule } from './modules/database/database.module'; +import { meiliForRoot } from './modules/meilisearch/melli.module'; export const createOptions: CreateOptions = { config: { factories: configs as any, storage: { enabled: true } }, modules: async (configure) => { - return [ConfigModule.forRoot(configure), AppModule.forroot()]; + return [ + ConfigModule.forRoot(configure), + DatabaseModule.forRoot(configure), + forRoot(configs.content), + meiliForRoot(configs.meili), + ]; }, globals: {}, builder: async ({ configure, BootModule }) => { diff --git a/packages/utils/src/utils/nesttools.ts b/packages/utils/src/utils/nesttools.ts index 4ba94d9..f4db0e0 100644 --- a/packages/utils/src/utils/nesttools.ts +++ b/packages/utils/src/utils/nesttools.ts @@ -1,6 +1,29 @@ export function isAsyncFn>(callback: (...asgs: A) => Promise | R) { return callback.constructor.name === 'AsyncFunction'; } -export function toBoolean(value: any): boolean { - return value === 'true' || value === true; + +/** + * 用于请求验证中的boolean数据转义 + * @param value + */ +export function toBoolean(value?: string | boolean): boolean { + if (!value) return false; + if (typeof value === 'boolean') return value; + try { + return JSON.parse(value.toLowerCase()); + } catch (error) { + return value as unknown as boolean; + } +} + +export async function toFlatTrees(trees: any[], depth = 0) { + const data: any[] = []; + for (const item of trees) { + item.depth = depth; + const { children } = item; + // unset(item, 'children'); + data.push(item); + data.push(...(await toFlatTrees(children, depth + 1))); + } + return data; } diff --git a/packages/utils/src/utils/tools.ts b/packages/utils/src/utils/tools.ts index 29c28c3..8f19a64 100644 --- a/packages/utils/src/utils/tools.ts +++ b/packages/utils/src/utils/tools.ts @@ -22,8 +22,6 @@ export const deepMerge = ( return deepmerge(x, y, options) as T2 extends T1 ? T1 : T1 & T2; }; -export const echoTest = () => 'changed'; - /** * 获取dir参数相对于当前调用这个函数的文件的绝对路径 * 比如调用者是scripts/test.ts,而src与scripts同级别.那么,pathResolve('../src')就是src的绝对路径 @@ -36,3 +34,10 @@ export const pathResolve = (dir: string) => { const callerFilePath = callerLine.match(/\(([^:)]+):/)[1]; return resolve(callerFilePath, dir); }; +/** + * 用于请求验证中转义null + * @param value + */ +export function toNull(value?: string | null): string | null | undefined { + return value === 'null' ? null : value; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 489d6c6..805cdd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,7 +125,7 @@ importers: version: 16.6.1(typescript@5.4.5) tailwindcss: specifier: ^3.4.3 - version: 3.4.4(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + version: 3.4.4(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)) typescript: specifier: ^5.4.5 version: 5.4.5 @@ -150,6 +150,9 @@ importers: '@nestjs/swagger': specifier: ^7.3.1 version: 7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + '@nestjs/typeorm': + specifier: ^10.0.2 + version: 10.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(mysql2@3.10.2)(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5))) chalk: specifier: '4' version: 4.1.2 @@ -174,18 +177,30 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + meilisearch: + specifier: ^0.41.0 + version: 0.41.0 + mysql2: + specifier: ^3.10.2 + version: 3.10.2 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 rxjs: specifier: ^7.8.1 version: 7.8.1 + sanitize-html: + specifier: ^2.13.0 + version: 2.13.0 typeorm: specifier: ^0.3.20 - version: 0.3.20(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)) + version: 0.3.20(mysql2@3.10.2)(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)) utility-types: specifier: ^3.11.0 version: 3.11.0 + validator: + specifier: ^13.12.0 + version: 13.12.0 yaml: specifier: ^2.4.5 version: 2.4.5 @@ -214,9 +229,15 @@ importers: '@types/node': specifier: ^20.14.2 version: 20.14.2 + '@types/sanitize-html': + specifier: ^2.11.0 + version: 2.11.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.2 + '@types/validator': + specifier: ^13.12.0 + version: 13.12.0 bun: specifier: ^1.1.13 version: 1.1.13 @@ -358,7 +379,7 @@ importers: version: 16.6.1(typescript@5.4.5) tailwindcss: specifier: ^3.4.3 - version: 3.4.4(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + version: 3.4.4(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5) @@ -1125,6 +1146,7 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -1132,6 +1154,7 @@ packages: '@humanwhocodes/object-schema@2.0.2': resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -1359,6 +1382,15 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/typeorm@10.0.2': + resolution: {integrity: sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + rxjs: ^7.2.0 + typeorm: ^0.3.0 + '@next/env@14.2.3': resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==} @@ -2156,6 +2188,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/sanitize-html@2.11.0': + resolution: {integrity: sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ==} + '@types/semver@7.5.0': resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} @@ -2656,6 +2691,7 @@ packages: bun@1.1.13: resolution: {integrity: sha512-yrujTLEspzQJfh7hd2xkYM5skQsjBwVm/wq0cyYBkR5x4FRveOUqeAqtFOPH4aHvsDgwVN+dO0uIrlvE7dDsDQ==} + cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true @@ -2933,6 +2969,9 @@ packages: engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} hasBin: true + cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -3046,6 +3085,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3094,6 +3137,19 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -3135,6 +3191,10 @@ packages: resolution: {integrity: sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3637,6 +3697,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3814,6 +3877,9 @@ packages: resolution: {integrity: sha512-QY6S+hZ0f5m1WT8WffYN+Hg+xm/w5I8XeUcAq/ZYP5wVC8xbKi4Whhru3FtrAebD5EhBW8rmFzkDI6eCAuFe2w==} hasBin: true + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -3829,6 +3895,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -4003,6 +4073,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -4385,6 +4458,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -4400,6 +4476,14 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lru-cache@8.0.5: + resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==} + engines: {node: '>=16.14'} + lunar-typescript@1.7.5: resolution: {integrity: sha512-AlOwYrxHRCR9Plba5TlZY0NVv6aFobmR1gxfiAE1KxjDzBXoDtT2KrsBYCVaErGokiL8qLMS1FNwGPiPRX2a4g==} @@ -4433,6 +4517,9 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + meilisearch@0.41.0: + resolution: {integrity: sha512-5KcGLxEXD7E+uNO7R68rCbGSHgCqeM3Q3RFFLSsN7ZrIgr8HPDXVAIlP4LHggAZfk0FkSzo8VSXifHCwa2k80g==} + memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -4550,9 +4637,17 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mysql2@3.10.2: + resolution: {integrity: sha512-KCXPEvAkO0RcHPr362O5N8tFY2fXvbjfkPvRY/wGumh4EOemo9Hm5FjQZqv/pCmrnuxGu5OxnSENG0gTXqKMgQ==} + engines: {node: '>= 8.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.3: + resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} + engines: {node: '>=12.0.0'} + nano-css@5.6.1: resolution: {integrity: sha512-T2Mhc//CepkTa3X4pUhKgbEheJHYAxD0VptuqFhDbGMUWVV2m+lkNiW/Ieuj35wrfC8Zm0l7HvssQh7zcEttSw==} peerDependencies: @@ -4749,6 +4844,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -5506,6 +5604,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.13.0: + resolution: {integrity: sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -5541,6 +5642,9 @@ packages: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -5656,6 +5760,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} @@ -7615,6 +7723,15 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9) + '@nestjs/typeorm@10.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(mysql2@3.10.2)(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)))': + dependencies: + '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1) + reflect-metadata: 0.2.2 + rxjs: 7.8.1 + typeorm: 0.3.20(mysql2@3.10.2)(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)) + uuid: 9.0.1 + '@next/env@14.2.3': {} '@next/eslint-plugin-next@14.2.3': @@ -8283,6 +8400,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/sanitize-html@2.11.0': + dependencies: + htmlparser2: 8.0.2 + '@types/semver@7.5.0': {} '@types/stack-utils@2.0.3': {} @@ -9328,6 +9449,12 @@ snapshots: dependencies: cross-spawn: 7.0.3 + cross-fetch@3.1.8: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -9427,6 +9554,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: optional: true @@ -9464,6 +9593,24 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.0.3: {} dotenv@16.4.5: {} @@ -9495,6 +9642,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 + entities@4.5.0: {} + env-paths@2.2.1: {} error-ex@1.3.2: @@ -10271,6 +10420,10 @@ snapshots: functions-have-names@1.2.3: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -10452,6 +10605,13 @@ snapshots: readable-stream: 1.0.34 through2: 0.4.2 + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -10469,6 +10629,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore-by-default@1.0.1: {} @@ -10649,6 +10813,8 @@ snapshots: is-plain-object@5.0.0: {} + is-property@1.0.2: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.5 @@ -11206,6 +11372,8 @@ snapshots: chalk: 5.3.0 is-unicode-supported: 1.3.0 + long@5.2.3: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -11220,6 +11388,10 @@ snapshots: dependencies: yallist: 4.0.0 + lru-cache@7.18.3: {} + + lru-cache@8.0.5: {} + lunar-typescript@1.7.5: {} magic-string@0.30.10: @@ -11249,6 +11421,12 @@ snapshots: media-typer@0.3.0: optional: true + meilisearch@0.41.0: + dependencies: + cross-fetch: 3.1.8 + transitivePeerDependencies: + - encoding + memfs@3.5.3: dependencies: fs-monkey: 1.0.6 @@ -11352,12 +11530,27 @@ snapshots: mute-stream@1.0.0: {} + mysql2@3.10.2: + dependencies: + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.6.3 + long: 5.2.3 + lru-cache: 8.0.5 + named-placeholders: 1.1.3 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + named-placeholders@1.1.3: + dependencies: + lru-cache: 7.18.3 + nano-css@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@jridgewell/sourcemap-codec': 1.4.15 @@ -11595,6 +11788,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-srcset@1.0.2: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -11686,7 +11881,7 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.4.38 - postcss-load-config@4.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)): + postcss-load-config@4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)): dependencies: lilconfig: 3.1.2 yaml: 2.4.5 @@ -12457,6 +12652,15 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.13.0: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.4.38 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -12502,6 +12706,8 @@ snapshots: - supports-color optional: true + seq-queue@0.0.5: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -12628,6 +12834,8 @@ snapshots: sprintf-js@1.0.3: {} + sqlstring@2.3.3: {} + stack-generator@2.0.10: dependencies: stackframe: 1.3.4 @@ -12934,9 +13142,9 @@ snapshots: tailwindcss-animate@1.0.7(tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5))): dependencies: - tailwindcss: 3.4.4(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)) - tailwindcss@3.4.4(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)): + tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -12955,7 +13163,7 @@ snapshots: postcss: 8.4.38 postcss-import: 15.1.0(postcss@8.4.38) postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)) postcss-nested: 6.0.1(postcss@8.4.38) postcss-selector-parser: 6.1.0 resolve: 1.22.8 @@ -13225,7 +13433,7 @@ snapshots: typedarray@0.0.6: optional: true - typeorm@0.3.20(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)): + typeorm@0.3.20(mysql2@3.10.2)(ts-node@10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)): dependencies: '@sqltools/formatter': 1.2.5 app-root-path: 3.1.0 @@ -13243,6 +13451,7 @@ snapshots: uuid: 9.0.1 yargs: 17.7.2 optionalDependencies: + mysql2: 3.10.2 ts-node: 10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5) transitivePeerDependencies: - supports-color