Compare commits

...

25 Commits

Author SHA1 Message Date
d61527709d add content 2025-05-19 21:04:15 +08:00
56f85173cf add content 2025-05-19 20:41:34 +08:00
afb44f4d0c add content 2025-05-19 20:40:18 +08:00
149623c073 add content 2025-05-19 16:33:14 +08:00
06f187b742 add content 2025-05-19 15:10:56 +08:00
f03b55fec4 add content 2025-05-19 14:46:05 +08:00
eddf8795df add content 2025-05-19 14:42:23 +08:00
a198ca62b4 add content 2025-05-19 14:38:28 +08:00
c2f263ae82 add content 2025-05-19 14:14:07 +08:00
6386c3f1b3 add content 2025-05-19 12:39:06 +08:00
34c4ac8f86 add db 2025-05-19 11:12:36 +08:00
d264b515a1 add db 2025-05-19 09:48:45 +08:00
4300e851df add db 2025-05-18 23:26:38 +08:00
64e1602874 add helpers 2025-05-18 23:26:25 +08:00
321b7e0b0c add new module 2025-05-18 21:42:59 +08:00
fd6631aba6 add new module 2025-05-18 21:06:51 +08:00
c196b00708 edit core config 2025-05-12 15:32:07 +08:00
57c72e010b add content service and core config 2025-05-12 15:26:49 +08:00
9ac2fd8f44 del example 2025-05-12 15:08:08 +08:00
6059ff4ed2 add content 2025-05-12 14:03:52 +08:00
65b52d8b6e add content 2025-05-12 09:11:40 +08:00
2e03eca42d add content 2025-05-11 23:45:58 +08:00
57c0318bda add global 2025-05-11 22:50:15 +08:00
840590cad6 change express to fastify 2025-05-11 22:27:51 +08:00
66da3530bf decorator demo 2025-05-11 21:35:09 +08:00
28 changed files with 1716 additions and 317 deletions

View File

@ -1,144 +1,138 @@
module.exports = { module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
project: 'tsconfig.json', project: 'tsconfig.json',
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
ecmaVersion: 'latest', ecmaVersion: 'latest',
sourceType: 'module', sourceType: 'module',
}, },
root: true, root: true,
env: { env: {
node: true, node: true,
jest: true, jest: true,
}, },
plugins: [ plugins: ['@typescript-eslint', 'jest', 'prettier', 'import', 'unused-imports'],
'@typescript-eslint', extends: [
'jest', // airbnb规范
'prettier', // https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb
'import', 'airbnb-base',
'unused-imports', // 兼容typescript的airbnb规范
], // https://github.com/iamturns/eslint-config-airbnb-typescript
extends: [ 'airbnb-typescript/base',
// airbnb规范
// https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb
'airbnb-base',
// 兼容typescript的airbnb规范
// https://github.com/iamturns/eslint-config-airbnb-typescript
'airbnb-typescript/base',
// typescript的eslint插件 // typescript的eslint插件
// https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md // https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking', 'plugin:@typescript-eslint/recommended-requiring-type-checking',
// 支持jest // 支持jest
'plugin:jest/recommended', 'plugin:jest/recommended',
// 使用prettier格式化代码 // 使用prettier格式化代码
// https://github.com/prettier/eslint-config-prettier#readme // https://github.com/prettier/eslint-config-prettier#readme
'prettier', 'prettier',
// 整合typescript-eslint与prettier // 整合typescript-eslint与prettier
// https://github.com/prettier/eslint-plugin-prettier // https://github.com/prettier/eslint-plugin-prettier
'plugin:prettier/recommended', 'plugin:prettier/recommended',
], ],
rules: { rules: {
/* ********************************** ES6+ ********************************** */ /* ********************************** ES6+ ********************************** */
'no-console': 0, 'no-console': 0,
'no-var-requires': 0, 'no-var-requires': 0,
'no-restricted-syntax': 0, 'no-restricted-syntax': 0,
'no-continue': 0, 'no-continue': 0,
'no-await-in-loop': 0, 'no-await-in-loop': 0,
'no-return-await': 0, 'no-return-await': 0,
'no-unused-vars': 0, 'no-unused-vars': 0,
'no-multi-assign': 0, 'no-multi-assign': 0,
'no-param-reassign': [2, { props: false }], 'no-param-reassign': [2, { props: false }],
'import/prefer-default-export': 0, 'import/prefer-default-export': 0,
'import/no-cycle': 0, 'import/no-cycle': 0,
'import/no-dynamic-require': 0, 'import/no-dynamic-require': 0,
'max-classes-per-file': 0, 'max-classes-per-file': 0,
'class-methods-use-this': 0, 'class-methods-use-this': 0,
'guard-for-in': 0, 'guard-for-in': 0,
'no-underscore-dangle': 0, 'no-underscore-dangle': 0,
'no-plusplus': 0, 'no-plusplus': 0,
'no-lonely-if': 0, 'no-lonely-if': 0,
'no-bitwise': ['error', { allow: ['~'] }], 'no-bitwise': ['error', { allow: ['~'] }],
/* ********************************** Module Import ********************************** */ /* ********************************** Module Import ********************************** */
'import/no-absolute-path': 0, 'import/no-absolute-path': 0,
'import/extensions': 0, 'import/extensions': 0,
'import/no-named-default': 0, 'import/no-named-default': 0,
'no-restricted-exports': 0, 'no-restricted-exports': 0,
// 一部分文件在导入devDependencies的依赖时不报错 // 一部分文件在导入devDependencies的依赖时不报错
'import/no-extraneous-dependencies': [ 'import/no-extraneous-dependencies': [
1, 1,
{ {
devDependencies: [ devDependencies: [
'**/*.test.{ts,js}', '**/*.test.{ts,js}',
'**/*.spec.{ts,js}', '**/*.spec.{ts,js}',
'./test/**.{ts,js}', './test/**.{ts,js}',
'./scripts/**/*.{ts,js}', './scripts/**/*.{ts,js}',
],
},
], ],
}, // 模块导入顺序规则
], 'import/order': [
// 模块导入顺序规则 1,
'import/order': [ {
1, pathGroups: [
{ {
pathGroups: [ pattern: '@/**',
{ group: 'external',
pattern: '@/**', position: 'after',
group: 'external', },
position: 'after', ],
}, alphabetize: { order: 'asc', caseInsensitive: false },
'newlines-between': 'always-and-inside-groups',
warnOnUnassignedImports: true,
},
], ],
alphabetize: { order: 'asc', caseInsensitive: false }, // 自动删除未使用的导入
'newlines-between': 'always-and-inside-groups', // https://github.com/sweepline/eslint-plugin-unused-imports
warnOnUnassignedImports: true, 'unused-imports/no-unused-imports': 1,
}, 'unused-imports/no-unused-vars': [
], 'warn',
// 自动删除未使用的导入 {
// https://github.com/sweepline/eslint-plugin-unused-imports vars: 'all',
'unused-imports/no-unused-imports': 1, args: 'none',
'unused-imports/no-unused-vars': [ ignoreRestSiblings: true,
'error', },
{ ],
vars: 'all', /* ********************************** Typescript ********************************** */
args: 'none', '@typescript-eslint/no-unused-vars': 0,
ignoreRestSiblings: true, '@typescript-eslint/no-empty-interface': 0,
}, '@typescript-eslint/no-this-alias': 0,
], '@typescript-eslint/no-var-requires': 0,
/* ********************************** Typescript ********************************** */ '@typescript-eslint/no-use-before-define': 0,
'@typescript-eslint/no-unused-vars': 0, '@typescript-eslint/explicit-member-accessibility': 0,
'@typescript-eslint/no-empty-interface': 0, '@typescript-eslint/no-non-null-assertion': 0,
'@typescript-eslint/no-this-alias': 0, '@typescript-eslint/no-unnecessary-type-assertion': 0,
'@typescript-eslint/no-var-requires': 0, '@typescript-eslint/require-await': 0,
'@typescript-eslint/no-use-before-define': 0, '@typescript-eslint/no-for-in-array': 0,
'@typescript-eslint/explicit-member-accessibility': 0, '@typescript-eslint/interface-name-prefix': 0,
'@typescript-eslint/no-non-null-assertion': 0, '@typescript-eslint/explicit-function-return-type': 0,
'@typescript-eslint/no-unnecessary-type-assertion': 0, '@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/require-await': 0, '@typescript-eslint/explicit-module-boundary-types': 0,
'@typescript-eslint/no-for-in-array': 0, '@typescript-eslint/no-floating-promises': 0,
'@typescript-eslint/interface-name-prefix': 0, '@typescript-eslint/restrict-template-expressions': 0,
'@typescript-eslint/explicit-function-return-type': 0, '@typescript-eslint/no-unsafe-assignment': 0,
'@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/no-unsafe-return': 0,
'@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-unused-expressions': 0,
'@typescript-eslint/no-floating-promises': 0, '@typescript-eslint/no-misused-promises': 0,
'@typescript-eslint/restrict-template-expressions': 0, '@typescript-eslint/no-unsafe-member-access': 0,
'@typescript-eslint/no-unsafe-assignment': 0, '@typescript-eslint/no-unsafe-call': 0,
'@typescript-eslint/no-unsafe-return': 0, '@typescript-eslint/no-unsafe-argument': 0,
'@typescript-eslint/no-unused-expressions': 0, '@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-misused-promises': 0, '@typescript-eslint/lines-between-class-members': 0,
'@typescript-eslint/no-unsafe-member-access': 0, '@typescript-eslint/no-throw-literal': 0,
'@typescript-eslint/no-unsafe-call': 0, },
'@typescript-eslint/no-unsafe-argument': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/lines-between-class-members': 0,
'@typescript-eslint/no-throw-literal': 0,
},
settings: { settings: {
extensions: ['.ts', '.d.ts', '.cts', '.mts', '.js', '.cjs', 'mjs', '.json'], extensions: ['.ts', '.d.ts', '.cts', '.mts', '.js', '.cjs', 'mjs', '.json'],
}, },
}; };

View File

@ -23,10 +23,19 @@
"dependencies": { "dependencies": {
"@nestjs/common": "^10.0.3", "@nestjs/common": "^10.0.3",
"@nestjs/core": "^10.0.3", "@nestjs/core": "^10.0.3",
"@nestjs/platform-express": "^10.0.3", "@nestjs/platform-fastify": "^10.0.3",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^11.0.0",
"better-sqlite3": "^11.10.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"deepmerge": "^4.3.1",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"sanitize-html": "^2.17.0",
"typeorm": "^0.3.24"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.3", "@nestjs/cli": "^10.0.3",
@ -34,9 +43,10 @@
"@nestjs/testing": "^10.0.3", "@nestjs/testing": "^10.0.3",
"@swc/cli": "^0.1.62", "@swc/cli": "^0.1.62",
"@swc/core": "^1.3.66", "@swc/core": "^1.3.66",
"@types/express": "^4.17.17",
"@types/jest": "29.5.2", "@types/jest": "29.5.2",
"@types/lodash": "^4.17.16",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0", "@typescript-eslint/parser": "^5.60.0",

File diff suppressed because it is too large Load Diff

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,13 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AppController } from './app.controller'; import { database } from './config';
import { AppService } from './app.service';
import { ContentModule } from './modules/content/content.module';
import { CoreModule } from './modules/core/core.module';
import { DatabaseModule } from './modules/database/database.module';
@Module({ @Module({
imports: [], imports: [ContentModule, CoreModule.forRoot(), DatabaseModule.forRoot(database)],
controllers: [AppController],
providers: [AppService],
}) })
export class AppModule {} export class AppModule {}

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,14 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const database = (): TypeOrmModuleOptions => ({
charset: 'utf8mb4',
logging: ['error'],
type: 'mysql',
host: '192.168.50.26',
port: 3306,
username: '3r',
password: '12345678',
database: '3r',
synchronize: true,
autoLoadEntities: true,
});

1
src/config/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './database.config';

View File

@ -1,9 +1,18 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
await app.listen(process.env.PORT ?? 3000); cors: true,
logger: ['error', 'warn'],
});
app.setGlobalPrefix('api');
await app.listen(process.env.PORT ?? 3000, () => {
console.log('api: http://localhost:3000');
});
} }
bootstrap(); bootstrap();

View File

@ -0,0 +1,11 @@
export enum PostBodyType {
HTML = 'html',
MD = 'markdown',
}
export enum PostOrder {
CREATED = 'createdAt',
UPDATED = 'updatedAt',
PUBLISHED = 'publishedAt',
CUSTOM = 'custom',
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { SanitizeService } from '@/modules/content/services/SanitizeService';
import { PostService } from '@/modules/content/services/post.service';
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
import { DatabaseModule } from '@/modules/database/database.module';
import { PostController } from './controllers/post.controller';
@Module({
imports: [
TypeOrmModule.forFeature([PostEntity]),
DatabaseModule.forRepository([PostRepository]),
],
controllers: [PostController],
providers: [PostService, PostSubscriber, SanitizeService],
exports: [PostService, DatabaseModule.forRepository([PostRepository])],
})
export class ContentModule {}

View File

@ -0,0 +1,50 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { PostService } from '@/modules/content/services/post.service';
import { PaginateOptions } from '@/modules/database/types';
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Get()
async list(@Query() options: PaginateOptions) {
return this.postService.paginate(options);
}
@Get(':id')
async show(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.detail(id);
}
@Post()
async store(
@Body()
data: RecordAny,
) {
return this.postService.create(data);
}
@Patch()
async update(
@Body()
data: RecordAny,
) {
return this.postService.update(data);
}
@Delete(':id')
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.delete(id);
}
}

View File

@ -0,0 +1,94 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { isNil, toNumber } from 'lodash';
import { PostOrder } from '@/modules/content/constants';
import { toBoolean } from '@/modules/core/helpers';
import { PaginateOptions } from '@/modules/database/types';
export class QueryPostDto implements PaginateOptions {
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@IsOptional()
isPublished?: boolean;
@IsEnum(PostOrder, { message: `` })
@IsOptional()
orderBy: PostOrder;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '' })
@IsNumber()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '' })
@IsNumber()
@IsOptional()
limit = 10;
}
export class CreatePostDto {
@MaxLength(255, {
always: true,
message: 'The maximum length of the article title is $constraint1',
})
@IsNotEmpty({ groups: ['create'], message: 'The article title must be filled in.' })
@IsOptional({ groups: ['update'] })
title: string;
@IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' })
@IsOptional({ groups: ['update'] })
body: string;
@MaxLength(500, {
always: true,
message: 'The maximum length of the article description is $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, {
always: true,
each: true,
message: 'The maximum length of each keyword is $constraint1',
})
@IsOptional({ always: true })
keywords?: string[];
@Transform(({ value }) => toNumber(value))
@Min(0, { message: 'The sorted value must be greater than 0.' })
@IsNumber(undefined, { always: true })
@IsOptional({ always: true })
customOrder?: number;
}
export class UpdatePostDto extends PartialType(CreatePostDto) {
@IsUUID(undefined, {
groups: ['update'],
message: 'The format of the article ID is incorrect.',
})
@IsDefined({ groups: ['update'], message: 'The article ID must be specified' })
id: string;
}

View File

@ -0,0 +1,38 @@
import { Expose } from 'class-transformer';
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
@Entity('content_posts')
export class PostEntity extends BaseEntity {
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Column({ comment: '文章标题' })
title: string;
@Column({ comment: '文章内容', type: 'text' })
body: string;
@Column({ comment: '文章描述', nullable: true })
summary?: string;
@Expose()
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
keywords?: [];
@Column({ comment: '文章类型', type: 'enum', enum: PostBodyType })
type: PostBodyType;
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
publishedAt?: Date | null;
@Column({ comment: '自定义文章排序', default: 0 })
customOrder: number;
@CreateDateColumn({ comment: '创建时间' })
createdAt?: Date;
@Column({ comment: '更新时间', nullable: true })
updatedAt?: Date;
}

View File

@ -0,0 +1,11 @@
import { Repository } from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
@CustomRepository(PostEntity)
export class PostRepository extends Repository<PostEntity> {
buildBaseQB() {
return this.createQueryBuilder('post');
}
}

View File

@ -0,0 +1,23 @@
import sanitizeHtml from 'sanitize-html';
import { deepMerge } from '@/modules/core/helpers';
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(body: string, options: sanitizeHtml.IOptions = {}) {
return sanitizeHtml(body, deepMerge(this.config, options ?? {}, 'replace'));
}
}

View File

@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { isNil } from '@nestjs/common/utils/shared.utils';
import { isFunction, omit } from 'lodash';
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostOrder } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { PaginateOptions, QueryHook } from '@/modules/database/types';
import { paginate } from '@/modules/database/utils';
@Injectable()
export class PostService {
constructor(protected repository: PostRepository) {}
async paginate(options: PaginateOptions, callback?: QueryHook<PostEntity>) {
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
return paginate(qb, options);
}
async detail(id: string, callback?: QueryHook<PostEntity>) {
let qb = this.repository.buildBaseQB();
qb.where(`post.id = :id`, { id });
qb = !isNil(callback) && isFunction(callback) ? await callback(qb) : qb;
const item = await qb.getOne();
if (!item) {
throw new EntityNotFoundError(PostEntity, `The post ${id} not exists!`);
}
return item;
}
async create(data: RecordAny) {
const item = await this.repository.save(data);
return this.detail(item.id);
}
async update(data: RecordAny) {
data.updatedAt = new Date();
await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id);
}
async delete(id: string) {
const item = await this.repository.findOneByOrFail({ id });
return this.repository.remove(item);
}
protected async buildListQuery(
qb: SelectQueryBuilder<PostEntity>,
options: RecordAny,
callback?: QueryHook<PostEntity>,
) {
const { orderBy, isPublished } = options;
if (typeof isPublished === 'boolean') {
isPublished
? qb.where({ publishedAt: Not(IsNull) })
: qb.where({ publishedAt: IsNull() });
}
this.queryOrderBy(qb, orderBy);
if (callback) {
return callback(qb);
}
return qb;
}
protected queryOrderBy(qb: SelectQueryBuilder<PostEntity>, orderBy?: PostOrder) {
switch (orderBy) {
case PostOrder.CREATED:
return qb.orderBy('post.createdAt', 'DESC');
case PostOrder.UPDATED:
return qb.orderBy('post.updatedAt', 'DESC');
case PostOrder.PUBLISHED:
return qb.orderBy('post.publishedAt', 'DESC');
case PostOrder.CUSTOM:
return qb.orderBy('post.custom', 'DESC');
default:
return qb
.orderBy('post.createdAt', 'DESC')
.addOrderBy('post.updatedAt', 'DESC')
.addOrderBy('post.publishedAt', 'DESC');
}
}
}

View File

@ -0,0 +1,26 @@
import { DataSource, EventSubscriber } from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { SanitizeService } from '@/modules/content/services/SanitizeService';
@EventSubscriber()
export class PostSubscriber {
constructor(
protected dataSource: DataSource,
protected sanitizeService: SanitizeService,
protected postRepository: PostRepository,
) {
dataSource.subscribers.push(this);
}
listenTo() {
return PostEntity;
}
async afterLoad(entity: PostEntity) {
if (entity.type === PostBodyType.HTML) {
entity.body = this.sanitizeService.sanitize(entity.body);
}
}
}

View File

@ -0,0 +1,13 @@
import { DynamicModule, Module } from '@nestjs/common';
@Module({})
export class CoreModule {
static forRoot(): DynamicModule {
return {
module: CoreModule,
global: true,
providers: [],
exports: [],
};
}
}

View File

@ -0,0 +1 @@
export * from './utils';

View File

@ -0,0 +1,34 @@
import deepmerge from 'deepmerge';
import { isNil } from 'lodash';
export function toBoolean(value?: string | boolean): boolean {
if (isNil(value)) {
return false;
}
if (typeof value === 'boolean') {
return value;
}
try {
return JSON.parse(value.toLowerCase());
} catch (error) {
return value as unknown as boolean;
}
}
export function toNull(value?: string | null): string | null | undefined {
return value === null ? null : value;
}
export const deepMerge = <T, P>(
x: Partial<T>,
y: Partial<P>,
arrayMode: 'replace' | 'merge' = 'merge',
) => {
const options: deepmerge.Options = {};
if (arrayMode === 'replace') {
options.arrayMerge = (_d, s, _o) => s;
} else if (arrayMode === 'merge') {
options.arrayMerge = (_d, s, _o) => Array.from(new Set([..._d, ...s]));
}
return deepmerge(x, y, options) as P extends T ? T : T & P;
};

View File

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

View File

@ -0,0 +1,42 @@
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
import { getDataSourceToken, TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSource, ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
@Module({})
export class DatabaseModule {
static forRoot(configRegister: () => TypeOrmModuleOptions): DynamicModule {
return {
global: true,
module: DatabaseModule,
imports: [TypeOrmModule.forRoot(configRegister())],
};
}
static forRepository<T extends Type<any>>(
repositories: T[],
datasourceName?: string,
): DynamicModule {
const providers: Provider[] = [];
for (const Repository of repositories) {
const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repository);
if (!entity) {
continue;
}
providers.push({
inject: [getDataSourceToken(datasourceName)],
provide: Repository,
useFactory: (datasource: DataSource): InstanceType<typeof Repository> => {
const base = datasource.getRepository<ObjectType<any>>(entity);
return new Repository(base.target, base.manager, base.queryRunner);
},
});
}
return {
exports: providers,
module: DatabaseModule,
providers,
};
}
}

View File

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

View File

@ -0,0 +1,23 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
export type QueryHook<Entity> = (
qb: SelectQueryBuilder<Entity>,
) => Promise<SelectQueryBuilder<Entity>>;
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,32 @@
import { isNil } from 'lodash';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaginateOptions, PaginateReturn } from '@/modules/database/types';
export const paginate = async <T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
options: PaginateOptions,
): Promise<PaginateReturn<T>> => {
const limit = isNil(options.limit) || options.limit < 1 ? 1 : options.limit;
const page = isNil(options.page) || options.page < 1 ? 1 : options.page;
const start = page >= 1 ? page - 1 : 0;
const totalItems = await qb.getCount();
qb.take(limit).skip(start * limit);
const items = await qb.getMany();
const totalPages =
totalItems % limit === 0
? Math.floor(totalItems / limit)
: Math.floor(totalItems / limit) + 1;
const remainder = totalItems % limit === 0 ? limit : totalItems % limit;
const itemCount = page < totalPages ? limit : remainder;
return {
items,
meta: {
totalItems,
itemCount,
perPage: limit,
totalPages,
currentPage: page,
},
};
};

33
typings/global.d.ts vendored Normal file
View File

@ -0,0 +1,33 @@
declare type RecordAny = Record<string, any>;
declare type RecordNever = Record<never, never>;
declare type RecordAnyOrNever = RecordAny | RecordNever;
declare type BaseType = boolean | number | string | undefined | null;
declare type ParseType<T extends BaseType = string> = (value: string) => T;
declare type ClassToPlain<T> = { [key in keyof T]: T[key] };
declare type ClassType<T> = { new (...args: any[]): T };
declare type RePartial<T> = {
[P in keyof T]: T[P] extends (infer U)[] | undefined
? RePartial<U>[]
: T[P] extends object | undefined
? T[P] extends ((...args: any[]) => any) | ClassType<T[P]> | undefined
? T[P]
: RePartial<T[P]>
: T[P];
};
declare type ReRequired<T> = {
[P in keyof T]-?: T[P] extends (infer U)[] | undefined
? ReRequired<U>[]
: T[P] extends object | undefined
? T[P] extends ((...args: any[]) => any) | ClassType<T[P]> | undefined
? T[P]
: ReRequired<T[P]>
: T[P];
};
declare type WrapperType<T> = T;