master
wei 2024-07-18 06:15:41 +00:00
parent 72c393c1b1
commit 7d6b4aaf79
77 changed files with 3473 additions and 110 deletions

View File

@ -20,8 +20,6 @@ interface CustomHeaderProps {
onTypeChange: (e: any) => void; onTypeChange: (e: any) => void;
} }
const CustomHeader: FC<CustomHeaderProps> = ({ value, type, onChange, onTypeChange }) => { const CustomHeader: FC<CustomHeaderProps> = ({ value, type, onChange, onTypeChange }) => {
console.log(onchange, '1', onTypeChange, '2', value, '3', type);
const start = 0; const start = 0;
const end = 12; const end = 12;
const monthOptions = []; const monthOptions = [];

View File

@ -5,8 +5,8 @@
APP_TIMEZONE=Asia/Shanghai APP_TIMEZONE=Asia/Shanghai
APP_LOCALE=zh_CN APP_LOCALE=zh_CN
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
DB_HOST=127.0.0.1 DB_HOST=172.17.0.4
DB_PORT=3306 DB_PORT=3306
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=12345678 DB_PASSWORD=123456
DB_NAME=3rapp DB_NAME=nestapp

View File

@ -27,6 +27,7 @@
"@nestjs/core": "^10.3.8", "@nestjs/core": "^10.3.8",
"@nestjs/platform-fastify": "^10.3.9", "@nestjs/platform-fastify": "^10.3.9",
"@nestjs/swagger": "^7.3.1", "@nestjs/swagger": "^7.3.1",
"@nestjs/typeorm": "^10.0.2",
"chalk": "4", "chalk": "4",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
@ -35,10 +36,14 @@
"find-up": "5", "find-up": "5",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"meilisearch": "^0.41.0",
"mysql2": "^3.10.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-html": "^2.13.0",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"utility-types": "^3.11.0", "utility-types": "^3.11.0",
"validator": "^13.12.0",
"yaml": "^2.4.5" "yaml": "^2.4.5"
}, },
"devDependencies": { "devDependencies": {
@ -50,7 +55,9 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/lodash": "^4.17.5", "@types/lodash": "^4.17.5",
"@types/node": "^20.14.2", "@types/node": "^20.14.2",
"@types/sanitize-html": "^2.11.0",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/validator": "^13.12.0",
"bun": "^1.1.13", "bun": "^1.1.13",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.57.0", "eslint": "^8.57.0",

View File

@ -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>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -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();
}
}

View File

@ -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],
};
}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,5 @@
import { ContentConfig } from '@/modules/content/types';
export const content = (): ContentConfig => ({
searchType: 'meilisearch',
});

View File

@ -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: [],
};
};

View File

@ -1 +1,4 @@
export * from './app.config'; export * from './app.config';
export * from './database.config';
export * from './content.config';
export * from './meili.config';

View File

@ -0,0 +1,8 @@
import { MelliConfig } from '@/modules/meilisearch/types';
export const meili = (): MelliConfig => [
{
name: 'default',
host: 'http://192.168.31.43:7700/',
},
];

View File

@ -213,7 +213,3 @@ export class Configure {
this.config = deepMerge(this.config, this.storage.config, append ? 'merge' : 'replace'); this.config = deepMerge(this.config, this.storage.config, append ? 'merge' : 'replace');
} }
} }
const bb = new Configure();
bb.initilize();
console.log(bb.env);

View File

@ -69,5 +69,3 @@ export class ConfigStorage {
writeFileSync(this.path, JSON.stringify(this._config, null, 4)); writeFileSync(this.path, JSON.stringify(this._config, null, 4));
} }
} }
const a = new ConfigStorage();
console.log(a.path);

View File

@ -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',
}

View File

@ -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<ContentConfig> = {
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)),
],
};
};

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,4 @@
export * from './category.controller';
export * from './tag.controller';
export * from './post.controller';
export * from './comment.controller';

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
export * from './category.dto';
export * from './comment.dto';
export * from './post.dto';
export * from './tag.dto';
// export * from ''

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
## 序列化 响应拦截器
@Exclude() @Expose({ groups: ['post-detail'] })
@SerializeOptions({ groups: ['post-detail'] })
@UseInterceptors(AppInterceptor)

View File

@ -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<PostEntity[]>;
@Expose({ groups: ['category-list'] })
depth = 0;
@Expose({ groups: ['category-list', 'category-detail'] })
@TreeParent({ onDelete: 'NO ACTION' })
parent: Relation<CategoryEntity> | null;
@Expose({ groups: ['category-tree'] })
@TreeChildren({ cascade: true })
children: Relation<CategoryEntity>[];
@Expose()
@Type(() => Date)
@DeleteDateColumn({ comment: '删除时间', nullable: true })
deletedAt: Date;
}

View File

@ -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<PostEntity>;
@Expose({ groups: ['comment-list'] })
depth = 0;
@Expose({ groups: ['comment-detail', 'comment-list'] })
@TreeParent({ onDelete: 'CASCADE' })
parent: Relation<CommentEntity> | null;
@Expose({ groups: ['comment-tree'] })
@TreeChildren({ cascade: true })
children: Relation<CommentEntity>[];
}

View File

@ -0,0 +1,4 @@
export * from './category.entity';
export * from './comment.entity';
export * from './post.entity';
export * from './tag.entity';

View File

@ -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<CategoryEntity>;
@Expose()
@JoinTable()
@ManyToMany(() => TagEntity, (tag) => tag.posts, {
cascade: true,
})
tags: Relation<TagEntity[]>;
@Expose()
@OneToMany(() => CommentEntity, (comment) => comment.post, {
cascade: true,
})
comments: Relation<CommentEntity[]>;
}

View File

@ -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[];
}

View File

@ -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],
[],
);

View File

@ -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<CategoryEntity> {
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<CategoryEntity, 'children'>[] = [];
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<CategoryEntity, 'children'>[] = [];
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[];
}
}

View File

@ -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<CommentEntity>) => SelectQueryBuilder<CommentEntity>;
};
@CustomRepository(CommentEntity)
export class CommentRepository extends TreeRepository<CommentEntity> {
/**
*
*/
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
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<CommentEntity> {
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<CommentEntity> {
const qb: SelectQueryBuilder<CommentEntity> = 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<CommentEntity, 'children'>[] = [];
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[];
}
}

View File

@ -0,0 +1,4 @@
export * from './category.repository';
export * from './post.repository';
export * from './comment.repository';
export * from './tag.repository';

View File

@ -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<PostEntity> {
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');
}
}

View File

@ -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<TagEntity> {
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');
}
}

View File

@ -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;
}
}

View File

@ -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<CommentEntity>) => {
const condition: Record<string, string> = {};
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;
}
}

View File

@ -0,0 +1,4 @@
export * from './category.service';
export * from './tag.service';
// export * from './post.service';
export * from './comment.service';

View File

@ -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;
}
}

View File

@ -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, 'limit' | 'page'>]: 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<PostEntity>) {
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<PostEntity>) {
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<PostEntity>,
options: FindParams,
callback?: QueryHook<PostEntity>,
) {
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<PostEntity>, 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<PostEntity>) {
const root = await this.categoryService.detail(id);
const tree = await this.categoryRepository.findDescendantsTree(root);
const flatDes = await this.categoryRepository.toFlatTrees(tree.children);
const ids = [tree.id, ...flatDes.map((item) => item.id)];
return qb.where('category.id IN (:...ids)', {
ids,
});
}
protected async buildSearchQuery(qb: SelectQueryBuilder<PostEntity>, 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;
}
}

View File

@ -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'));
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -1 +1 @@
export const DTOVALIDATION_OPTIONS = Symbol('DTOVALIDATION_OPTIONS'); export const DTO_VALIDATION_OPTIONS = Symbol('DTOVALIDATION_OPTIONS');

View File

@ -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<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [relatedProperty, reverse],
validator: MatchConstraint,
});
};
}

View File

@ -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<string, any>, 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',
},
});
};
}

View File

@ -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<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [model],
validator: IsPasswordConstraint,
});
};
}

View File

@ -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 ?? {});

View File

@ -1,6 +1,6 @@
import { Global, Module, ModuleMetadata } from '@nestjs/common'; 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 { useContainer } from 'class-validator';
import { omit } from 'lodash'; import { omit } from 'lodash';
@ -17,7 +17,7 @@ export const createApp = (options: CreateOptions) => async (): Promise<App> => {
const { config, builder } = options; const { config, builder } = options;
await app.configure.initilize(config.factories, config.storage); await app.configure.initilize(config.factories, config.storage);
if (!app.configure.has('app')) { if (!app.configure.has('app')) {
console.log(11); throw new Error('app config not found');
} }
const BootModule = await createBootModule(app.configure, options); const BootModule = await createBootModule(app.configure, options);
app.container = await builder({ configure: app.configure, BootModule }); app.container = await builder({ configure: app.configure, BootModule });
@ -25,8 +25,6 @@ export const createApp = (options: CreateOptions) => async (): Promise<App> => {
app.container.setGlobalPrefix(await app.configure.get('app.prefix')); app.container.setGlobalPrefix(await app.configure.get('app.prefix'));
} }
useContainer(app.container.select(BootModule), { fallbackOnErrors: true }); useContainer(app.container.select(BootModule), { fallbackOnErrors: true });
console.log(app.container);
return app; return app;
}; };
@ -46,21 +44,27 @@ export async function createBootModule(
} }
return item; return item;
}); });
if (globals.pipe !== null) { // if (globals.pipe !== null) {
const pip = globals.pipe // const pip = globals.pipe
? globals.pipe(configure) // ? globals.pipe(configure)
: new AppPipe({ // : 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, transform: true,
whitelist: true, whitelist: true,
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
forbidUnknownValues: true, forbidUnknownValues: true,
validationError: { target: false, value: false }, validationError: { target: false },
}),
}); });
providers.push({ // }
provide: AppPipe,
useValue: pip,
});
}
if (globals.interceptor !== null) { if (globals.interceptor !== null) {
providers.push({ providers.push({
provide: APP_INTERCEPTOR, provide: APP_INTERCEPTOR,

View File

@ -3,12 +3,12 @@ import { ArgumentMetadata, BadRequestException, Paramtype, ValidationPipe } from
import { isObject, omit } from 'lodash'; import { isObject, omit } from 'lodash';
import { DTOVALIDATION_OPTIONS } from '../constants'; import { DTO_VALIDATION_OPTIONS } from '../constants';
export class AppPipe extends ValidationPipe { export class AppPipe extends ValidationPipe {
async transform(value: any, metadate: ArgumentMetadata) { async transform(value: any, metadate: ArgumentMetadata) {
const { metatype, type } = metadate; const { metatype, type } = metadate;
const options = Reflect.getMetadata(DTOVALIDATION_OPTIONS, metatype) || {}; const options = Reflect.getMetadata(DTO_VALIDATION_OPTIONS, metatype) || {};
const originOptions = { ...this.validatorOptions }; const originOptions = { ...this.validatorOptions };
const originTransformOptions = { ...this.transformOptions }; const originTransformOptions = { ...this.transformOptions };
// {a:b} b为a的解构重命名 // {a:b} b为a的解构重命名
@ -40,7 +40,6 @@ export class AppPipe extends ValidationPipe {
this.transformOptions = originTransformOptions; this.transformOptions = originTransformOptions;
return result; return result;
} catch (error: any) { } catch (error: any) {
console.log(error);
this.validatorOptions = originOptions; this.validatorOptions = originOptions;
this.transformOptions = originTransformOptions; this.transformOptions = originTransformOptions;
if ('response' in error) throw new BadRequestException(error.response); if ('response' in error) throw new BadRequestException(error.response);

View File

@ -0,0 +1 @@
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';

View File

@ -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<any>;
/**
* ,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<any>;
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<any>,
validationOptions?: ValidationOptions,
): (object: Record<string, any>, propertyName: string) => void;
/**
*
* @param condition
* @param validationOptions
*/
function IsDataExist(
condition: Condition,
validationOptions?: ValidationOptions,
): (object: Record<string, any>, propertyName: string) => void;
/**
*
* @param condition
* @param validationOptions
*/
function IsDataExist(
condition: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
): (object: Record<string, any>, propertyName: string) => void {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [condition],
validator: DataExistConstraint,
});
};
}
export { IsDataExist };

View File

@ -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';

View File

@ -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<any>;
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<Condition, 'entity'> = {
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<Condition>;
// 需要查询的属性名,默认为当前验证的属性
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<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueTreeConstraint,
});
};
}

View File

@ -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<any>;
/**
* 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<Condition, 'entity'> = {
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<Condition>;
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<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueTreeExistConstraint,
});
};
}

View File

@ -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<any>;
/**
* 使
*/
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<Condition, 'entity'> = {
property: args.property,
};
const condition = ('entity' in args.constraints[0]
? merge(config, args.constraints[0])
: {
...config,
entity: args.constraints[0],
}) as unknown as Required<Condition>;
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<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueConstraint,
});
};
}

View File

@ -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<any>;
/**
* id
*/
ignore?: string;
/**
* DTOignoreDTO
*/
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<Condition, 'entity'> = {
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<Condition>;
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<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueExistContraint,
});
};
}

View File

@ -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<T extends Type<any>>(
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,
};
}
}

View File

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { CUSTOM_REPOSITORY_METADATA } from '../constants';
export const CustomRepository = <T>(entity: T): ClassDecorator =>
SetMetadata(CUSTOM_REPOSITORY_METADATA, entity);

View File

@ -0,0 +1,64 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaginateOptions, PaginateReturn } from './types';
/**
*
* @param qb queryBuilder
* @param options
*/
export const paginate = async <E extends ObjectLiteral>(
qb: SelectQueryBuilder<E>,
options: PaginateOptions,
): Promise<PaginateReturn<E>> => {
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<E extends ObjectLiteral>(
options: PaginateOptions,
data: E[],
): PaginateReturn<E> {
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,
},
};
}

View File

@ -0,0 +1,50 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
export type QueryHook<E> = (qb: SelectQueryBuilder<E>) => Promise<SelectQueryBuilder<E>>;
/**
*
*/
export interface PaginateMeta {
/**
*
*/
itemCount: number;
/**
*
*/
totalItems?: number;
/**
*
*/
perPage: number;
/**
*
*/
totalPages?: number;
/**
*
*/
currentPage: number;
}
/**
*
*/
export interface PaginateOptions {
/**
*
*/
page?: number;
/**
*
*/
limit?: number;
}
/**
*
*/
export interface PaginateReturn<E extends ObjectLiteral> {
meta: PaginateMeta;
items: E[];
}

View File

@ -0,0 +1,17 @@
import { MelliConfig } from './types';
export const createMeilliOptions = async (
config: MelliConfig,
): Promise<MelliConfig | undefined> => {
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;
};

View File

@ -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<string, MeiliSearch> = 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;
}
}

View File

@ -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,
};
};

View File

@ -0,0 +1,7 @@
import { Config } from 'meilisearch';
// MelliSearch模块的配置
export type MelliConfig = MelliOption[];
// MeilliSearch的连接节点配置
export type MelliOption = Config & { name: string };

View File

@ -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 {}

View File

@ -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[];
}

View File

@ -4,14 +4,21 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify
import * as configs from '@/configs'; import * as configs from '@/configs';
import { AppModule } from './app.module';
import { ConfigModule } from './modules/config/config.module'; import { ConfigModule } from './modules/config/config.module';
import { forRoot } from './modules/content/content.module';
import { CreateOptions } from './modules/core/core.types'; 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 = { export const createOptions: CreateOptions = {
config: { factories: configs as any, storage: { enabled: true } }, config: { factories: configs as any, storage: { enabled: true } },
modules: async (configure) => { modules: async (configure) => {
return [ConfigModule.forRoot(configure), AppModule.forroot()]; return [
ConfigModule.forRoot(configure),
DatabaseModule.forRoot(configure),
forRoot(configs.content),
meiliForRoot(configs.meili),
];
}, },
globals: {}, globals: {},
builder: async ({ configure, BootModule }) => { builder: async ({ configure, BootModule }) => {

View File

@ -1,6 +1,29 @@
export function isAsyncFn<R, A extends Array<any>>(callback: (...asgs: A) => Promise<R> | R) { export function isAsyncFn<R, A extends Array<any>>(callback: (...asgs: A) => Promise<R> | R) {
return callback.constructor.name === 'AsyncFunction'; 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;
} }

View File

@ -22,8 +22,6 @@ export const deepMerge = <T1, T2>(
return deepmerge(x, y, options) as T2 extends T1 ? T1 : T1 & T2; return deepmerge(x, y, options) as T2 extends T1 ? T1 : T1 & T2;
}; };
export const echoTest = () => 'changed';
/** /**
* dir * dir
* scripts/test.ts,srcscripts.,pathResolve('../src')src * scripts/test.ts,srcscripts.,pathResolve('../src')src
@ -36,3 +34,10 @@ export const pathResolve = (dir: string) => {
const callerFilePath = callerLine.match(/\(([^:)]+):/)[1]; const callerFilePath = callerLine.match(/\(([^:)]+):/)[1];
return resolve(callerFilePath, dir); return resolve(callerFilePath, dir);
}; };
/**
* null
* @param value
*/
export function toNull(value?: string | null): string | null | undefined {
return value === 'null' ? null : value;
}

View File

@ -125,7 +125,7 @@ importers:
version: 16.6.1(typescript@5.4.5) version: 16.6.1(typescript@5.4.5)
tailwindcss: tailwindcss:
specifier: ^3.4.3 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: typescript:
specifier: ^5.4.5 specifier: ^5.4.5
version: 5.4.5 version: 5.4.5
@ -150,6 +150,9 @@ importers:
'@nestjs/swagger': '@nestjs/swagger':
specifier: ^7.3.1 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) 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: chalk:
specifier: '4' specifier: '4'
version: 4.1.2 version: 4.1.2
@ -174,18 +177,30 @@ importers:
lodash: lodash:
specifier: ^4.17.21 specifier: ^4.17.21
version: 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: reflect-metadata:
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2 version: 0.2.2
rxjs: rxjs:
specifier: ^7.8.1 specifier: ^7.8.1
version: 7.8.1 version: 7.8.1
sanitize-html:
specifier: ^2.13.0
version: 2.13.0
typeorm: typeorm:
specifier: ^0.3.20 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: utility-types:
specifier: ^3.11.0 specifier: ^3.11.0
version: 3.11.0 version: 3.11.0
validator:
specifier: ^13.12.0
version: 13.12.0
yaml: yaml:
specifier: ^2.4.5 specifier: ^2.4.5
version: 2.4.5 version: 2.4.5
@ -214,9 +229,15 @@ importers:
'@types/node': '@types/node':
specifier: ^20.14.2 specifier: ^20.14.2
version: 20.14.2 version: 20.14.2
'@types/sanitize-html':
specifier: ^2.11.0
version: 2.11.0
'@types/supertest': '@types/supertest':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2 version: 6.0.2
'@types/validator':
specifier: ^13.12.0
version: 13.12.0
bun: bun:
specifier: ^1.1.13 specifier: ^1.1.13
version: 1.1.13 version: 1.1.13
@ -358,7 +379,7 @@ importers:
version: 16.6.1(typescript@5.4.5) version: 16.6.1(typescript@5.4.5)
tailwindcss: tailwindcss:
specifier: ^3.4.3 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: ts-node:
specifier: ^10.9.2 specifier: ^10.9.2
version: 10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5) 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': '@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1': '@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@ -1132,6 +1154,7 @@ packages:
'@humanwhocodes/object-schema@2.0.2': '@humanwhocodes/object-schema@2.0.2':
resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==}
deprecated: Use @eslint/object-schema instead
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
@ -1359,6 +1382,15 @@ packages:
'@nestjs/platform-express': '@nestjs/platform-express':
optional: true 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': '@next/env@14.2.3':
resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==} resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==}
@ -2156,6 +2188,9 @@ packages:
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} 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': '@types/semver@7.5.0':
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
@ -2656,6 +2691,7 @@ packages:
bun@1.1.13: bun@1.1.13:
resolution: {integrity: sha512-yrujTLEspzQJfh7hd2xkYM5skQsjBwVm/wq0cyYBkR5x4FRveOUqeAqtFOPH4aHvsDgwVN+dO0uIrlvE7dDsDQ==} resolution: {integrity: sha512-yrujTLEspzQJfh7hd2xkYM5skQsjBwVm/wq0cyYBkR5x4FRveOUqeAqtFOPH4aHvsDgwVN+dO0uIrlvE7dDsDQ==}
cpu: [arm64, x64]
os: [darwin, linux, win32] os: [darwin, linux, win32]
hasBin: true hasBin: true
@ -2933,6 +2969,9 @@ packages:
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
hasBin: true hasBin: true
cross-fetch@3.1.8:
resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==}
cross-spawn@7.0.3: cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -3046,6 +3085,10 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -3094,6 +3137,19 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'} 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: dotenv@16.0.3:
resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -3135,6 +3191,10 @@ packages:
resolution: {integrity: sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==} resolution: {integrity: sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
env-paths@2.2.1: env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -3637,6 +3697,9 @@ packages:
functions-have-names@1.2.3: functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
generate-function@2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
gensync@1.0.0-beta.2: gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -3814,6 +3877,9 @@ packages:
resolution: {integrity: sha512-QY6S+hZ0f5m1WT8WffYN+Hg+xm/w5I8XeUcAq/ZYP5wVC8xbKi4Whhru3FtrAebD5EhBW8rmFzkDI6eCAuFe2w==} resolution: {integrity: sha512-QY6S+hZ0f5m1WT8WffYN+Hg+xm/w5I8XeUcAq/ZYP5wVC8xbKi4Whhru3FtrAebD5EhBW8rmFzkDI6eCAuFe2w==}
hasBin: true hasBin: true
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
http-errors@2.0.0: http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -3829,6 +3895,10 @@ packages:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} 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: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@ -4003,6 +4073,9 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
is-reference@1.2.1: is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@ -4385,6 +4458,9 @@ packages:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
long@5.2.3:
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
loose-envify@1.4.0: loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
@ -4400,6 +4476,14 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'} 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: lunar-typescript@1.7.5:
resolution: {integrity: sha512-AlOwYrxHRCR9Plba5TlZY0NVv6aFobmR1gxfiAE1KxjDzBXoDtT2KrsBYCVaErGokiL8qLMS1FNwGPiPRX2a4g==} resolution: {integrity: sha512-AlOwYrxHRCR9Plba5TlZY0NVv6aFobmR1gxfiAE1KxjDzBXoDtT2KrsBYCVaErGokiL8qLMS1FNwGPiPRX2a4g==}
@ -4433,6 +4517,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
meilisearch@0.41.0:
resolution: {integrity: sha512-5KcGLxEXD7E+uNO7R68rCbGSHgCqeM3Q3RFFLSsN7ZrIgr8HPDXVAIlP4LHggAZfk0FkSzo8VSXifHCwa2k80g==}
memfs@3.5.3: memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
@ -4550,9 +4637,17 @@ packages:
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} 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: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} 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: nano-css@5.6.1:
resolution: {integrity: sha512-T2Mhc//CepkTa3X4pUhKgbEheJHYAxD0VptuqFhDbGMUWVV2m+lkNiW/Ieuj35wrfC8Zm0l7HvssQh7zcEttSw==} resolution: {integrity: sha512-T2Mhc//CepkTa3X4pUhKgbEheJHYAxD0VptuqFhDbGMUWVV2m+lkNiW/Ieuj35wrfC8Zm0l7HvssQh7zcEttSw==}
peerDependencies: peerDependencies:
@ -4749,6 +4844,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'} engines: {node: '>=8'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parse5-htmlparser2-tree-adapter@6.0.1: parse5-htmlparser2-tree-adapter@6.0.1:
resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==}
@ -5506,6 +5604,9 @@ packages:
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-html@2.13.0:
resolution: {integrity: sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==}
scheduler@0.23.2: scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@ -5541,6 +5642,9 @@ packages:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serialize-javascript@6.0.2: serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
@ -5656,6 +5760,10 @@ packages:
sprintf-js@1.0.3: sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
stack-generator@2.0.10: stack-generator@2.0.10:
resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==}
@ -7615,6 +7723,15 @@ snapshots:
optionalDependencies: 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/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/env@14.2.3': {}
'@next/eslint-plugin-next@14.2.3': '@next/eslint-plugin-next@14.2.3':
@ -8283,6 +8400,10 @@ snapshots:
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/sanitize-html@2.11.0':
dependencies:
htmlparser2: 8.0.2
'@types/semver@7.5.0': {} '@types/semver@7.5.0': {}
'@types/stack-utils@2.0.3': {} '@types/stack-utils@2.0.3': {}
@ -9328,6 +9449,12 @@ snapshots:
dependencies: dependencies:
cross-spawn: 7.0.3 cross-spawn: 7.0.3
cross-fetch@3.1.8:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.3: cross-spawn@7.0.3:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -9427,6 +9554,8 @@ snapshots:
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
denque@2.1.0: {}
depd@2.0.0: depd@2.0.0:
optional: true optional: true
@ -9464,6 +9593,24 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 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.0.3: {}
dotenv@16.4.5: {} dotenv@16.4.5: {}
@ -9495,6 +9642,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.2.1 tapable: 2.2.1
entities@4.5.0: {}
env-paths@2.2.1: {} env-paths@2.2.1: {}
error-ex@1.3.2: error-ex@1.3.2:
@ -10271,6 +10420,10 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
generate-function@2.3.1:
dependencies:
is-property: 1.0.2
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
@ -10452,6 +10605,13 @@ snapshots:
readable-stream: 1.0.34 readable-stream: 1.0.34
through2: 0.4.2 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: http-errors@2.0.0:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@ -10469,6 +10629,10 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {} ieee754@1.2.1: {}
ignore-by-default@1.0.1: {} ignore-by-default@1.0.1: {}
@ -10649,6 +10813,8 @@ snapshots:
is-plain-object@5.0.0: {} is-plain-object@5.0.0: {}
is-property@1.0.2: {}
is-reference@1.2.1: is-reference@1.2.1:
dependencies: dependencies:
'@types/estree': 1.0.5 '@types/estree': 1.0.5
@ -11206,6 +11372,8 @@ snapshots:
chalk: 5.3.0 chalk: 5.3.0
is-unicode-supported: 1.3.0 is-unicode-supported: 1.3.0
long@5.2.3: {}
loose-envify@1.4.0: loose-envify@1.4.0:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
@ -11220,6 +11388,10 @@ snapshots:
dependencies: dependencies:
yallist: 4.0.0 yallist: 4.0.0
lru-cache@7.18.3: {}
lru-cache@8.0.5: {}
lunar-typescript@1.7.5: {} lunar-typescript@1.7.5: {}
magic-string@0.30.10: magic-string@0.30.10:
@ -11249,6 +11421,12 @@ snapshots:
media-typer@0.3.0: media-typer@0.3.0:
optional: true optional: true
meilisearch@0.41.0:
dependencies:
cross-fetch: 3.1.8
transitivePeerDependencies:
- encoding
memfs@3.5.3: memfs@3.5.3:
dependencies: dependencies:
fs-monkey: 1.0.6 fs-monkey: 1.0.6
@ -11352,12 +11530,27 @@ snapshots:
mute-stream@1.0.0: {} 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: mz@2.7.0:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
object-assign: 4.1.1 object-assign: 4.1.1
thenify-all: 1.6.0 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): nano-css@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
@ -11595,6 +11788,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1 json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4 lines-and-columns: 1.2.4
parse-srcset@1.0.2: {}
parse5-htmlparser2-tree-adapter@6.0.1: parse5-htmlparser2-tree-adapter@6.0.1:
dependencies: dependencies:
parse5: 6.0.1 parse5: 6.0.1
@ -11686,7 +11881,7 @@ snapshots:
camelcase-css: 2.0.1 camelcase-css: 2.0.1
postcss: 8.4.38 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: dependencies:
lilconfig: 3.1.2 lilconfig: 3.1.2
yaml: 2.4.5 yaml: 2.4.5
@ -12457,6 +12652,15 @@ snapshots:
safer-buffer@2.1.2: {} 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: scheduler@0.23.2:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@ -12502,6 +12706,8 @@ snapshots:
- supports-color - supports-color
optional: true optional: true
seq-queue@0.0.5: {}
serialize-javascript@6.0.2: serialize-javascript@6.0.2:
dependencies: dependencies:
randombytes: 2.1.0 randombytes: 2.1.0
@ -12628,6 +12834,8 @@ snapshots:
sprintf-js@1.0.3: {} sprintf-js@1.0.3: {}
sqlstring@2.3.3: {}
stack-generator@2.0.10: stack-generator@2.0.10:
dependencies: dependencies:
stackframe: 1.3.4 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))): 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: 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: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0
arg: 5.0.2 arg: 5.0.2
@ -12955,7 +13163,7 @@ snapshots:
postcss: 8.4.38 postcss: 8.4.38
postcss-import: 15.1.0(postcss@8.4.38) postcss-import: 15.1.0(postcss@8.4.38)
postcss-js: 4.0.1(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-nested: 6.0.1(postcss@8.4.38)
postcss-selector-parser: 6.1.0 postcss-selector-parser: 6.1.0
resolve: 1.22.8 resolve: 1.22.8
@ -13225,7 +13433,7 @@ snapshots:
typedarray@0.0.6: typedarray@0.0.6:
optional: true 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: dependencies:
'@sqltools/formatter': 1.2.5 '@sqltools/formatter': 1.2.5
app-root-path: 3.1.0 app-root-path: 3.1.0
@ -13243,6 +13451,7 @@ snapshots:
uuid: 9.0.1 uuid: 9.0.1
yargs: 17.7.2 yargs: 17.7.2
optionalDependencies: optionalDependencies:
mysql2: 3.10.2
ts-node: 10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5) ts-node: 10.9.2(@swc/core@1.5.28)(@types/node@20.14.2)(typescript@5.4.5)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color