init
This commit is contained in:
commit
9f9b1b5e5c
8
.env.example
Normal file
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
DB_HOST='localhost'
|
||||
DB_PORT=5432
|
||||
DB_USERNAME='postgres'
|
||||
DB_PASSWORD='postgres'
|
||||
DB_NAME='db'
|
||||
|
||||
REDIS_HOST='localhost'
|
||||
REDIS_PORT='6379'
|
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
.env
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.detectIndentation": false,
|
||||
"editor.minimap.enabled": false,
|
||||
"breadcrumbs.enabled": false,
|
||||
}
|
6
README.md
Normal file
6
README.md
Normal file
@ -0,0 +1,6 @@
|
||||
## NestJS scaffold
|
||||
|
||||
My NestJS Boilerplate, still under construction 👍
|
||||
|
||||
* Use [slonik]() for postgresql database client
|
||||
* Use [cache-manager-redis-store]() for in-memory cache
|
27
database/getMigrator.ts
Normal file
27
database/getMigrator.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { SlonikMigrator } from '@slonik/migrator';
|
||||
import { createPool } from 'slonik';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// use .env or .env.test depending on NODE_ENV variable
|
||||
const envPath = path.resolve(
|
||||
__dirname,
|
||||
process.env.NODE_ENV === 'test' ? '../.env.test' : '../.env',
|
||||
);
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
export async function getMigrator() {
|
||||
const pool = await createPool(
|
||||
`postgres://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}/${process.env.DB_NAME}`,
|
||||
);
|
||||
|
||||
const migrator = new SlonikMigrator({
|
||||
migrationsPath: path.resolve(__dirname, 'migrations'),
|
||||
migrationTableName: 'migration',
|
||||
slonik: pool,
|
||||
} as any);
|
||||
|
||||
return { pool, migrator };
|
||||
}
|
9
database/migrate.ts
Normal file
9
database/migrate.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { getMigrator } from "./getMigrator";
|
||||
|
||||
export async function run() {
|
||||
const { migrator } = await getMigrator();
|
||||
migrator.runAsCLI();
|
||||
console.log('Done');
|
||||
}
|
||||
|
||||
run();
|
18
database/migrations/2023.02.22T05.24.58.create_users.sql
Normal file
18
database/migrations/2023.02.22T05.24.58.create_users.sql
Normal file
@ -0,0 +1,18 @@
|
||||
CREATE TABLE "users" (
|
||||
"id" VARCHAR PRIMARY KEY,
|
||||
"phone_number" TEXT NOT NULL UNIQUE,
|
||||
"email" TEXT NOT NULL UNIQUE,
|
||||
"fullname" TEXT,
|
||||
"avatar" TEXT,
|
||||
"latitude" TEXT,
|
||||
"longitude" TEXT,
|
||||
"auth_token" TEXT,
|
||||
"verify_token" TEXT,
|
||||
"fcm_token" TEXT,
|
||||
"is_verified" TEXT,
|
||||
"is_merchant" BOOLEAN,
|
||||
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
|
||||
)
|
0
database/seed.ts
Normal file
0
database/seed.ts
Normal file
14
database/seeds/roles.seed.sql
Normal file
14
database/seeds/roles.seed.sql
Normal file
@ -0,0 +1,14 @@
|
||||
INSERT INTO
|
||||
roles(
|
||||
id,
|
||||
name
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
"Admin"
|
||||
),
|
||||
(
|
||||
2,
|
||||
"User"
|
||||
);
|
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
94
package.json
Normal file
94
package.json
Normal file
@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "nest_scaffold",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"migration:create": "ts-node database/migrate create --name",
|
||||
"migration:up": "ts-node database/migrate up",
|
||||
"migration:up:tests": "NODE_ENV=test ts-node database/migrate up",
|
||||
"migration:down": "ts-node database/migrate down",
|
||||
"migration:down:tests": "NODE_ENV=test ts-node database/migrate down",
|
||||
"migration:executed": "ts-node database/migrate executed",
|
||||
"migration:executed:tests": "NODE_ENV=test ts-node database/migrate executed",
|
||||
"migration:pending": "ts-node database/migrate pending",
|
||||
"migration:pending:tests": "NODE_ENV=test ts-node database/migrate pending",
|
||||
"seed:up": "ts-node database/seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/event-emitter": "^1.4.1",
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"@slonik/migrator": "^0.11.3",
|
||||
"cache-manager": "^5.1.6",
|
||||
"cache-manager-redis-store": "^3.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"env-var": "^7.3.0",
|
||||
"ioredis": "^5.3.1",
|
||||
"nestjs-request-context": "^2.1.0",
|
||||
"nestjs-slonik": "^9.0.0",
|
||||
"oxide.ts": "^1.1.0",
|
||||
"redis": "^3.1.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.2.0",
|
||||
"slonik": "31.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "29.2.4",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "29.3.1",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "29.0.3",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
20
src/app.module.ts
Normal file
20
src/app.module.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { CacheModule, Module } from '@nestjs/common';
|
||||
import { SlonikModule } from 'nestjs-slonik';
|
||||
import { RedisClientOptions } from 'redis';
|
||||
import { postgresConnectionUrl } from './config/database.config';
|
||||
import { redisConfig } from './config/redis.config';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SlonikModule.forRoot({
|
||||
connectionUri: postgresConnectionUrl,
|
||||
}),
|
||||
CacheModule.register<RedisClientOptions>(redisConfig),
|
||||
UserModule
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
23
src/config/app.routes.ts
Normal file
23
src/config/app.routes.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Application routes with its version
|
||||
* https://github.com/Sairyss/backend-best-practices#api-versioning
|
||||
*/
|
||||
|
||||
// Root
|
||||
const usersRoot = 'users';
|
||||
const walletsRoot = 'wallets';
|
||||
|
||||
// Api Versions
|
||||
const v1 = 'v1';
|
||||
|
||||
export const routesV1 = {
|
||||
version: v1,
|
||||
user: {
|
||||
root: usersRoot,
|
||||
delete: `api/${usersRoot}/:id`,
|
||||
},
|
||||
wallet: {
|
||||
root: walletsRoot,
|
||||
delete: `api/${walletsRoot}/:id`,
|
||||
},
|
||||
};
|
13
src/config/database.config.ts
Normal file
13
src/config/database.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { get } from "env-var";
|
||||
import '../libs/utils';
|
||||
|
||||
export const databaseConfig = {
|
||||
type: 'postgres',
|
||||
host: get('DB_HOST').required().asString(),
|
||||
port: get('DB_PORT').required().asString(),
|
||||
username: get('DB_USERNAME').required().asString(),
|
||||
password: get('DB_PASSWORD').required().asString(),
|
||||
database: get('DB_NAME').required().asString(),
|
||||
}
|
||||
|
||||
export const postgresConnectionUrl = `postgres://${databaseConfig.username}:${databaseConfig.password}@${databaseConfig.host}/${databaseConfig.database}`;
|
8
src/config/redis.config.ts
Normal file
8
src/config/redis.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
import { get } from 'env-var';
|
||||
|
||||
export const redisConfig = {
|
||||
store: redisStore,
|
||||
host: get('REDIS_HOST').asString,
|
||||
port: get('REDIS_PORT').asString
|
||||
}
|
15
src/libs/api/api-error.response.ts
Normal file
15
src/libs/api/api-error.response.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export class ApiErrorResponse {
|
||||
readonly statusCode: number;
|
||||
readonly message: string;
|
||||
readonly error: string
|
||||
readonly correlationId: string;
|
||||
readonly subErrors?: string[];
|
||||
|
||||
constructor(body: ApiErrorResponse) {
|
||||
this.statusCode = body.statusCode;
|
||||
this.message = body.message;
|
||||
this.error = body.error;
|
||||
this.correlationId = body.correlationId;
|
||||
this.subErrors = body.subErrors;
|
||||
}
|
||||
}
|
18
src/libs/api/paginated-query.request.dto.ts
Normal file
18
src/libs/api/paginated-query.request.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class PaginatedQueryRequestDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(99999)
|
||||
@Type(() => Number)
|
||||
readonly limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(99999)
|
||||
@Type(() => Number)
|
||||
readonly page?: number;
|
||||
}
|
11
src/libs/api/paginated.response.base.ts
Normal file
11
src/libs/api/paginated.response.base.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Paginated } from '../ddd';
|
||||
|
||||
export abstract class PaginatedResponseDto<T> extends Paginated<T> {
|
||||
readonly count: number;
|
||||
|
||||
readonly limit: number;
|
||||
|
||||
readonly page: number;
|
||||
|
||||
abstract readonly data: readonly T[];
|
||||
}
|
25
src/libs/api/response.base.ts
Normal file
25
src/libs/api/response.base.ts
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
export interface BaseResponseProps {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most of our response objects will have properties like
|
||||
* id, createdAt and updatedAt so we can move them to a
|
||||
* separate class and extend it to avoid duplication.
|
||||
*/
|
||||
export class ResponseBase {
|
||||
constructor(props: BaseResponseProps) {
|
||||
this.id = props.id;
|
||||
this.createdAt = new Date(props.createdAt).toISOString();
|
||||
this.updatedAt = new Date(props.updatedAt).toISOString();
|
||||
}
|
||||
|
||||
readonly createdAt: string;
|
||||
|
||||
readonly updatedAt: string;
|
||||
|
||||
readonly id: string;
|
||||
}
|
40
src/libs/application/context/AppRequestContext.ts
Normal file
40
src/libs/application/context/AppRequestContext.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { RequestContext } from "nestjs-request-context";
|
||||
import { DatabaseTransactionConnection } from "slonik";
|
||||
|
||||
export class AppRequestContext extends RequestContext {
|
||||
requestId: string;
|
||||
transactionConnection?: DatabaseTransactionConnection;
|
||||
}
|
||||
|
||||
export class RequestContextService {
|
||||
static getContext(): AppRequestContext {
|
||||
const ctx: AppRequestContext = RequestContext.currentContext.req;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
static setRequestId(id: string): void {
|
||||
const ctx = this.getContext();
|
||||
ctx.requestId = id;
|
||||
}
|
||||
|
||||
static getRequestId(): string {
|
||||
return this.getContext().requestId;
|
||||
}
|
||||
|
||||
static getTransactionConnection(): DatabaseTransactionConnection | undefined {
|
||||
const ctx = this.getContext();
|
||||
return ctx.transactionConnection;
|
||||
}
|
||||
|
||||
static setTransactionConnection(
|
||||
transactionConnection?: DatabaseTransactionConnection
|
||||
): void {
|
||||
const ctx = this.getContext();
|
||||
ctx.transactionConnection = transactionConnection;
|
||||
}
|
||||
|
||||
static cleanTransactionConnection(): void {
|
||||
const ctx = this.getContext();
|
||||
ctx.transactionConnection = undefined;
|
||||
}
|
||||
}
|
20
src/libs/application/context/ContextInterceptor.ts
Normal file
20
src/libs/application/context/ContextInterceptor.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
|
||||
import { Observable, tap } from "rxjs";
|
||||
import { RequestContextService } from './AppRequestContext';
|
||||
|
||||
@Injectable()
|
||||
export class ContextInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const requestId = request?.body?.requestId;
|
||||
|
||||
RequestContextService.setRequestId(requestId);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
// perform cleaning if needed
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
51
src/libs/application/interceptors/exception.interceptors.ts
Normal file
51
src/libs/application/interceptors/exception.interceptors.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { BadRequestException, CallHandler, ExecutionContext, Logger, NestInterceptor } from "@nestjs/common";
|
||||
import { catchError, Observable, throwError } from "rxjs";
|
||||
import { ApiErrorResponse } from "src/libs/api/api-error.response";
|
||||
import { ExceptionBase } from "src/libs/exceptions/exception.base";
|
||||
import { RequestContextService } from "../context/AppRequestContext";
|
||||
|
||||
export class ExceptionInterceptor implements NestInterceptor {
|
||||
private readonly logger: Logger = new Logger(ExceptionInterceptor.name);
|
||||
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler
|
||||
): Observable<ExceptionBase> {
|
||||
return next.handle().pipe(
|
||||
catchError((err) => {
|
||||
if (err.status >= 400 && err.status < 500) {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] ${err.message}`
|
||||
);
|
||||
|
||||
const isClassValidatorError =
|
||||
Array.isArray(err?.response?.message) &&
|
||||
typeof err?.response?.error === 'string' &&
|
||||
err.status === 400;
|
||||
|
||||
if(isClassValidatorError) {
|
||||
err = new BadRequestException(
|
||||
new ApiErrorResponse({
|
||||
statusCode: err.status,
|
||||
message: 'Bad request error',
|
||||
error: err?.response?.error,
|
||||
subErrors: err?.response?.message,
|
||||
correlationId: RequestContextService.getRequestId()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if(!err.correlationId) {
|
||||
err.correlationId = RequestContextService.getRequestId();
|
||||
}
|
||||
|
||||
if(err.response) {
|
||||
err.response.correlationId = err.correlationId;
|
||||
}
|
||||
|
||||
return throwError(err)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
241
src/libs/db/sql-repository.base.ts
Normal file
241
src/libs/db/sql-repository.base.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import { RequestContextService } from '@libs/application/context/AppRequestContext';
|
||||
import { AggregateRoot, PaginatedQueryParams, Paginated } from '@libs/ddd';
|
||||
import { Mapper } from '@libs/ddd';
|
||||
import { RepositoryPort } from '@libs/ddd';
|
||||
import { ConflictException } from '@libs/exceptions';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { None, Option, Some } from 'oxide.ts';
|
||||
import {
|
||||
DatabasePool,
|
||||
DatabaseTransactionConnection,
|
||||
IdentifierSqlToken,
|
||||
MixedRow,
|
||||
PrimitiveValueExpression,
|
||||
QueryResult,
|
||||
QueryResultRow,
|
||||
sql,
|
||||
SqlSqlToken,
|
||||
UniqueIntegrityConstraintViolationError,
|
||||
} from 'slonik';
|
||||
import { ZodTypeAny, TypeOf, ZodObject } from 'zod';
|
||||
import { LoggerPort } from '../ports/logger.port';
|
||||
import { ObjectLiteral } from '../types';
|
||||
|
||||
export abstract class SqlRepositoryBase<
|
||||
Aggregate extends AggregateRoot<any>,
|
||||
DbModel extends ObjectLiteral,
|
||||
> implements RepositoryPort<Aggregate>
|
||||
{
|
||||
protected abstract tableName: string;
|
||||
|
||||
protected abstract schema: ZodObject<any>;
|
||||
|
||||
protected constructor(
|
||||
private readonly _pool: DatabasePool,
|
||||
protected readonly mapper: Mapper<Aggregate, DbModel>,
|
||||
protected readonly eventEmitter: EventEmitter2,
|
||||
protected readonly logger: LoggerPort,
|
||||
) {}
|
||||
|
||||
async findOneById(id: string): Promise<Option<Aggregate>> {
|
||||
const query = sql.type(this.schema)`SELECT * FROM ${sql.identifier([
|
||||
this.tableName,
|
||||
])} WHERE id = ${id}`;
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
return result.rows[0] ? Some(this.mapper.toDomain(result.rows[0])) : None;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Aggregate[]> {
|
||||
const query = sql.type(this.schema)`SELECT * FROM ${sql.identifier([
|
||||
this.tableName,
|
||||
])}`;
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
|
||||
return result.rows.map(this.mapper.toDomain);
|
||||
}
|
||||
|
||||
async findAllPaginated(
|
||||
params: PaginatedQueryParams,
|
||||
): Promise<Paginated<Aggregate>> {
|
||||
const query = sql.type(this.schema)`
|
||||
SELECT * FROM ${sql.identifier([this.tableName])}
|
||||
LIMIT ${params.limit}
|
||||
OFFSET ${params.offset}
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
|
||||
const entities = result.rows.map(this.mapper.toDomain);
|
||||
return new Paginated({
|
||||
data: entities,
|
||||
count: result.rowCount,
|
||||
limit: params.limit,
|
||||
page: params.page,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(entity: Aggregate): Promise<boolean> {
|
||||
entity.validate();
|
||||
const query = sql`DELETE FROM ${sql.identifier([
|
||||
this.tableName,
|
||||
])} WHERE id = ${entity.id}`;
|
||||
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] deleting entities ${
|
||||
entity.id
|
||||
} from ${this.tableName}`,
|
||||
);
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
|
||||
await entity.publishEvents(this.logger, this.eventEmitter);
|
||||
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts an entity to a database
|
||||
* (also publishes domain events and waits for completion)
|
||||
*/
|
||||
async insert(entity: Aggregate | Aggregate[]): Promise<void> {
|
||||
const entities = Array.isArray(entity) ? entity : [entity];
|
||||
|
||||
const records = entities.map(this.mapper.toPersistence);
|
||||
|
||||
const query = this.generateInsertQuery(records);
|
||||
|
||||
try {
|
||||
await this.writeQuery(query, entities);
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueIntegrityConstraintViolationError) {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] ${
|
||||
(error.originalError as any).detail
|
||||
}`,
|
||||
);
|
||||
throw new ConflictException('Record already exists', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method for write queries when you need to mutate an entity.
|
||||
* Executes entity validation, publishes events,
|
||||
* and does some debug logging.
|
||||
* For read queries use `this.pool` directly
|
||||
*/
|
||||
protected async writeQuery<T>(
|
||||
sql: SqlSqlToken<
|
||||
T extends MixedRow ? T : Record<string, PrimitiveValueExpression>
|
||||
>,
|
||||
entity: Aggregate | Aggregate[],
|
||||
): Promise<
|
||||
QueryResult<
|
||||
T extends MixedRow
|
||||
? T extends ZodTypeAny
|
||||
? TypeOf<ZodTypeAny & MixedRow & T>
|
||||
: T
|
||||
: T
|
||||
>
|
||||
> {
|
||||
const entities = Array.isArray(entity) ? entity : [entity];
|
||||
entities.forEach((entity) => entity.validate());
|
||||
const entityIds = entities.map((e) => e.id);
|
||||
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] writing ${
|
||||
entities.length
|
||||
} entities to "${this.tableName}" table: ${entityIds}`,
|
||||
);
|
||||
|
||||
const result = await this.pool.query(sql);
|
||||
|
||||
await Promise.all(
|
||||
entities.map((entity) =>
|
||||
entity.publishEvents(this.logger, this.eventEmitter),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate insert query for any objects.
|
||||
* Use carefully and don't accept non-validated objects.
|
||||
*
|
||||
* Passing object with { name: string, email: string } will generate
|
||||
* a query: INSERT INTO "table" (name, email) VALUES ($1, $2)
|
||||
*/
|
||||
protected generateInsertQuery(
|
||||
models: DbModel[],
|
||||
): SqlSqlToken<QueryResultRow> {
|
||||
// TODO: generate query from an entire array to insert multiple records at once
|
||||
const entries = Object.entries(models[0]);
|
||||
const values: any = [];
|
||||
const propertyNames: IdentifierSqlToken[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry[0] && entry[1] !== undefined) {
|
||||
propertyNames.push(sql.identifier([entry[0]]));
|
||||
if (entry[1] instanceof Date) {
|
||||
values.push(sql.timestamp(entry[1]));
|
||||
} else {
|
||||
values.push(entry[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const query = sql`INSERT INTO ${sql.identifier([
|
||||
this.tableName,
|
||||
])} (${sql.join(propertyNames, sql`, `)}) VALUES (${sql.join(
|
||||
values,
|
||||
sql`, `,
|
||||
)})`;
|
||||
|
||||
const parsedQuery = query;
|
||||
return parsedQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* start a global transaction to save
|
||||
* results of all event handlers in one operation
|
||||
*/
|
||||
public async transaction<T>(handler: () => Promise<T>): Promise<T> {
|
||||
return this.pool.transaction(async (connection) => {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] transaction started`,
|
||||
);
|
||||
if (!RequestContextService.getTransactionConnection()) {
|
||||
RequestContextService.setTransactionConnection(connection);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler();
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] transaction committed`,
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] transaction aborted`,
|
||||
);
|
||||
throw e;
|
||||
} finally {
|
||||
RequestContextService.cleanTransactionConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database pool.
|
||||
* If global request transaction is started,
|
||||
* returns a transaction pool.
|
||||
*/
|
||||
protected get pool(): DatabasePool | DatabaseTransactionConnection {
|
||||
return (
|
||||
RequestContextService.getContext().transactionConnection ?? this._pool
|
||||
);
|
||||
}
|
||||
}
|
40
src/libs/ddd/base/aggregate-root.base.ts
Normal file
40
src/libs/ddd/base/aggregate-root.base.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
import { Entity } from './entity.base';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { LoggerPort } from '@libs/ports/logger.port';
|
||||
import { RequestContextService } from '../../application/context/AppRequestContext';
|
||||
|
||||
export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
get domainEvents(): DomainEvent[] {
|
||||
return this._domainEvents;
|
||||
}
|
||||
|
||||
protected addEvent(domainEvent: DomainEvent): void {
|
||||
this._domainEvents.push(domainEvent);
|
||||
}
|
||||
|
||||
public clearEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
|
||||
public async publishEvents(
|
||||
logger: LoggerPort,
|
||||
eventEmitter: EventEmitter2,
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
this.domainEvents.map(async (event) => {
|
||||
logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] "${
|
||||
event.constructor.name
|
||||
}" event published for aggregate ${this.constructor.name} : ${
|
||||
this.id
|
||||
}`,
|
||||
);
|
||||
return eventEmitter.emitAsync(event.constructor.name, event);
|
||||
}),
|
||||
);
|
||||
this.clearEvents();
|
||||
}
|
||||
}
|
54
src/libs/ddd/base/command.base.ts
Normal file
54
src/libs/ddd/base/command.base.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { RequestContextService } from '@libs/application/context/AppRequestContext';
|
||||
import { v4 } from 'uuid';
|
||||
import { ArgumentNotProvidedException } from '../../exceptions';
|
||||
import { Guard } from '../../guard';
|
||||
|
||||
export type CommandProps<T> = Omit<T, 'id' | 'metadata'> & Partial<Command>;
|
||||
|
||||
type CommandMetadata = {
|
||||
/** ID for correlation purposes (for commands that
|
||||
* arrive from other microservices,logs correlation, etc). */
|
||||
readonly correlationId: string;
|
||||
|
||||
/**
|
||||
* Causation id to reconstruct execution order if needed
|
||||
*/
|
||||
readonly causationId?: string;
|
||||
|
||||
/**
|
||||
* ID of a user who invoker the command. Can be useful for
|
||||
* logging and tracking execution of commands and events
|
||||
*/
|
||||
readonly userId?: string;
|
||||
|
||||
/**
|
||||
* Time when the command occurred. Mostly for tracing purposes
|
||||
*/
|
||||
readonly timestamp: number;
|
||||
};
|
||||
|
||||
export class Command {
|
||||
/**
|
||||
* Command id, in case if we want to save it
|
||||
* for auditing purposes and create a correlation/causation chain
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
readonly metadata: CommandMetadata;
|
||||
|
||||
constructor(props: CommandProps<unknown>) {
|
||||
if (Guard.isEmpty(props)) {
|
||||
throw new ArgumentNotProvidedException(
|
||||
'Command props should not be empty',
|
||||
);
|
||||
}
|
||||
const ctx = RequestContextService.getContext();
|
||||
this.id = props.id || v4();
|
||||
this.metadata = {
|
||||
correlationId: props?.metadata?.correlationId || ctx.requestId,
|
||||
causationId: props?.metadata?.causationId,
|
||||
timestamp: props?.metadata?.timestamp || Date.now(),
|
||||
userId: props?.metadata?.userId,
|
||||
};
|
||||
}
|
||||
}
|
54
src/libs/ddd/base/domain-event.base.ts
Normal file
54
src/libs/ddd/base/domain-event.base.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { ArgumentNotProvidedException } from '../../exceptions';
|
||||
import { Guard } from '../../guard';
|
||||
import { v4 } from 'uuid';
|
||||
import { RequestContextService } from '@libs/application/context/AppRequestContext';
|
||||
|
||||
type DomainEventMetadata = {
|
||||
/** Timestamp when this domain event occurred */
|
||||
readonly timestamp: number;
|
||||
|
||||
/** ID for correlation purposes (for Integration Events,logs correlation, etc).
|
||||
*/
|
||||
readonly correlationId: string;
|
||||
|
||||
/**
|
||||
* Causation id used to reconstruct execution order if needed
|
||||
*/
|
||||
readonly causationId?: string;
|
||||
|
||||
/**
|
||||
* User ID for debugging and logging purposes
|
||||
*/
|
||||
readonly userId?: string;
|
||||
};
|
||||
|
||||
export type DomainEventProps<T> = Omit<T, 'id' | 'metadata'> & {
|
||||
aggregateId: string;
|
||||
metadata?: DomainEventMetadata;
|
||||
};
|
||||
|
||||
export abstract class DomainEvent {
|
||||
public readonly id: string;
|
||||
|
||||
/** Aggregate ID where domain event occurred */
|
||||
public readonly aggregateId: string;
|
||||
|
||||
public readonly metadata: DomainEventMetadata;
|
||||
|
||||
constructor(props: DomainEventProps<unknown>) {
|
||||
if (Guard.isEmpty(props)) {
|
||||
throw new ArgumentNotProvidedException(
|
||||
'DomainEvent props should not be empty',
|
||||
);
|
||||
}
|
||||
this.id = v4();
|
||||
this.aggregateId = props.aggregateId;
|
||||
this.metadata = {
|
||||
correlationId:
|
||||
props?.metadata?.correlationId || RequestContextService.getRequestId(),
|
||||
causationId: props?.metadata?.causationId,
|
||||
timestamp: props?.metadata?.timestamp || Date.now(),
|
||||
userId: props?.metadata?.userId,
|
||||
};
|
||||
}
|
||||
}
|
153
src/libs/ddd/base/entity.base.ts
Normal file
153
src/libs/ddd/base/entity.base.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import {
|
||||
ArgumentNotProvidedException,
|
||||
ArgumentInvalidException,
|
||||
} from '../../exceptions';
|
||||
import { Guard } from '../../guard';
|
||||
import { convertPropsToObject } from '../../utils';
|
||||
|
||||
export type AggregateID = string;
|
||||
|
||||
export interface BaseEntityProps {
|
||||
id: AggregateID;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateEntityProps<T> {
|
||||
id: AggregateID;
|
||||
props: T;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export abstract class Entity<EntityProps> {
|
||||
constructor({
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
props,
|
||||
}: CreateEntityProps<EntityProps>) {
|
||||
this.setId(id);
|
||||
this.validateProps(props);
|
||||
const now = new Date();
|
||||
this._createdAt = createdAt || now;
|
||||
this._updatedAt = updatedAt || now;
|
||||
this.props = props;
|
||||
this.validate();
|
||||
}
|
||||
|
||||
protected readonly props: EntityProps;
|
||||
|
||||
/**
|
||||
* ID is set in the concrete entity implementation to support
|
||||
* different ID types depending on your needs.
|
||||
* For example it could be a UUID for aggregate root,
|
||||
* and shortid / nanoid for child entities.
|
||||
*/
|
||||
protected abstract _id: AggregateID;
|
||||
|
||||
private readonly _createdAt: Date;
|
||||
|
||||
private _updatedAt: Date;
|
||||
|
||||
get id(): AggregateID {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
private setId(id: AggregateID): void {
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this._updatedAt;
|
||||
}
|
||||
|
||||
static isEntity(entity: unknown): entity is Entity<unknown> {
|
||||
return entity instanceof Entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two entities are the same Entity by comparing ID field.
|
||||
* @param object Entity
|
||||
*/
|
||||
public equals(object?: Entity<EntityProps>): boolean {
|
||||
if (object === null || object === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this === object) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Entity.isEntity(object)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.id ? this.id === object.id : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current **copy** of entity's props.
|
||||
* Modifying entity's state won't change previously created
|
||||
* copy returned by this method since it doesn't return a reference.
|
||||
* If a reference to a specific property is needed create a getter in parent class.
|
||||
*
|
||||
* @return {*} {Props & EntityProps}
|
||||
* @memberof Entity
|
||||
*/
|
||||
public getPropsCopy(): EntityProps & BaseEntityProps {
|
||||
const propsCopy = {
|
||||
id: this._id,
|
||||
createdAt: this._createdAt,
|
||||
updatedAt: this._updatedAt,
|
||||
...this.props,
|
||||
};
|
||||
return Object.freeze(propsCopy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an Entity and all sub-entities/Value Objects it
|
||||
* contains to a plain object with primitive types. Can be
|
||||
* useful when logging an entity during testing/debugging
|
||||
*/
|
||||
public toObject(): unknown {
|
||||
const plainProps = convertPropsToObject(this.props);
|
||||
|
||||
const result = {
|
||||
id: this._id,
|
||||
createdAt: this._createdAt,
|
||||
updatedAt: this._updatedAt,
|
||||
...plainProps,
|
||||
};
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* There are certain rules that always have to be true (invariants)
|
||||
* for each entity. Validate method is called every time before
|
||||
* saving an entity to the database to make sure those rules are respected.
|
||||
*/
|
||||
public abstract validate(): void;
|
||||
|
||||
private validateProps(props: EntityProps): void {
|
||||
const MAX_PROPS = 50;
|
||||
|
||||
if (Guard.isEmpty(props)) {
|
||||
throw new ArgumentNotProvidedException(
|
||||
'Entity props should not be empty',
|
||||
);
|
||||
}
|
||||
if (typeof props !== 'object') {
|
||||
throw new ArgumentInvalidException('Entity props should be an object');
|
||||
}
|
||||
// if (Object.keys(props as any).length > MAX_PROPS) {
|
||||
// throw new ArgumentOutOfRangeException(
|
||||
// `Entity props should not have more than ${MAX_PROPS} properties`,
|
||||
// );
|
||||
// }
|
||||
}
|
||||
}
|
4
src/libs/ddd/base/index.ts
Normal file
4
src/libs/ddd/base/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './aggregate-root.base'
|
||||
export * from './domain-event.base'
|
||||
export * from './entity.base'
|
||||
export * from './value-object.base'
|
31
src/libs/ddd/base/query.base.ts
Normal file
31
src/libs/ddd/base/query.base.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { OrderBy, PaginatedQueryParams } from '../repository.port';
|
||||
|
||||
/**
|
||||
* Base class for regular queries
|
||||
*/
|
||||
export abstract class QueryBase {}
|
||||
|
||||
/**
|
||||
* Base class for paginated queries
|
||||
*/
|
||||
export abstract class PaginatedQueryBase extends QueryBase {
|
||||
limit: number;
|
||||
offset: number;
|
||||
orderBy: OrderBy;
|
||||
page: number;
|
||||
|
||||
constructor(props: PaginatedParams<PaginatedQueryBase>) {
|
||||
super();
|
||||
this.limit = props.limit || 20;
|
||||
this.offset = props.page ? props.page * this.limit : 0;
|
||||
this.page = props.page || 0;
|
||||
this.orderBy = props.orderBy || { field: true, param: 'desc' };
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated query parameters
|
||||
export type PaginatedParams<T> = Omit<
|
||||
T,
|
||||
'limit' | 'offset' | 'orderBy' | 'page'
|
||||
> &
|
||||
Partial<Omit<PaginatedQueryParams, 'offset'>>;
|
71
src/libs/ddd/base/value-object.base.ts
Normal file
71
src/libs/ddd/base/value-object.base.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { ArgumentNotProvidedException } from '../../exceptions';
|
||||
import { Guard } from '../../guard';
|
||||
import { convertPropsToObject } from '../../utils';
|
||||
|
||||
/**
|
||||
* Domain Primitive is an object that contains only a single value
|
||||
*/
|
||||
export type Primitives = string | number | boolean;
|
||||
export interface DomainPrimitive<T extends Primitives | Date> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
type ValueObjectProps<T> = T extends Primitives | Date ? DomainPrimitive<T> : T;
|
||||
|
||||
export abstract class ValueObject<T> {
|
||||
protected readonly props: ValueObjectProps<T>;
|
||||
|
||||
constructor(props: ValueObjectProps<T>) {
|
||||
this.checkIfEmpty(props);
|
||||
this.validate(props);
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
protected abstract validate(props: ValueObjectProps<T>): void;
|
||||
|
||||
static isValueObject(obj: unknown): obj is ValueObject<unknown> {
|
||||
return obj instanceof ValueObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two Value Objects are equal. Checks structural equality.
|
||||
* @param vo ValueObject
|
||||
*/
|
||||
public equals(vo?: ValueObject<T>): boolean {
|
||||
if (vo === null || vo === undefined) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(this) === JSON.stringify(vo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack a value object to get its raw properties
|
||||
*/
|
||||
public unpack(): T {
|
||||
if (this.isDomainPrimitive(this.props)) {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
const propsCopy = convertPropsToObject(this.props);
|
||||
|
||||
return Object.freeze(propsCopy);
|
||||
}
|
||||
|
||||
private checkIfEmpty(props: ValueObjectProps<T>): void {
|
||||
if (
|
||||
Guard.isEmpty(props) ||
|
||||
(this.isDomainPrimitive(props) && Guard.isEmpty(props.value))
|
||||
) {
|
||||
throw new ArgumentNotProvidedException('Property cannot be empty');
|
||||
}
|
||||
}
|
||||
|
||||
private isDomainPrimitive(
|
||||
obj: unknown,
|
||||
): obj is DomainPrimitive<T & (Primitives | Date)> {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, 'value')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
3
src/libs/ddd/index.ts
Normal file
3
src/libs/ddd/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './base';
|
||||
export * from './interface';
|
||||
export * from './repository.port';
|
1
src/libs/ddd/interface/index.ts
Normal file
1
src/libs/ddd/interface/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './mapper.interface';
|
11
src/libs/ddd/interface/mapper.interface.ts
Normal file
11
src/libs/ddd/interface/mapper.interface.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Entity } from '../base/entity.base';
|
||||
|
||||
export interface Mapper<
|
||||
DomainEntity extends Entity<any>,
|
||||
DbRecord,
|
||||
Response = any
|
||||
> {
|
||||
toPersistence(entity: DomainEntity): DbRecord;
|
||||
toDomain(record: any): DomainEntity;
|
||||
toResponse(entity: DomainEntity): Response;
|
||||
}
|
41
src/libs/ddd/repository.port.ts
Normal file
41
src/libs/ddd/repository.port.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Option } from 'oxide.ts';
|
||||
|
||||
/* Most of repositories will probably need generic
|
||||
save/find/delete operations, so it's easier
|
||||
to have some shared interfaces.
|
||||
More specific queries should be defined
|
||||
in a respective repository.
|
||||
*/
|
||||
|
||||
export class Paginated<T> {
|
||||
readonly count: number;
|
||||
readonly limit: number;
|
||||
readonly page: number;
|
||||
readonly data: readonly T[];
|
||||
|
||||
constructor(props: Paginated<T>) {
|
||||
this.count = props.count;
|
||||
this.limit = props.limit;
|
||||
this.page = props.page;
|
||||
this.data = props.data;
|
||||
}
|
||||
}
|
||||
|
||||
export type OrderBy = { field: string | true; param: 'asc' | 'desc' };
|
||||
|
||||
export type PaginatedQueryParams = {
|
||||
limit: number;
|
||||
page: number;
|
||||
offset: number;
|
||||
orderBy: OrderBy;
|
||||
};
|
||||
|
||||
export interface RepositoryPort<Entity> {
|
||||
insert(entity: Entity): Promise<void>;
|
||||
findOneById(id: string): Promise<Option<Entity>>;
|
||||
findAll(): Promise<Entity[]>;
|
||||
findAllPaginated(params: PaginatedQueryParams): Promise<Paginated<Entity>>;
|
||||
delete(entity: Entity): Promise<boolean>;
|
||||
|
||||
transaction<T>(handler: () => Promise<T>): Promise<T>;
|
||||
}
|
18
src/libs/decorators/final.decorator.ts
Normal file
18
src/libs/decorators/final.decorator.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Prevents other classes extending a class marked by this decorator.
|
||||
*/
|
||||
export function final<T extends { new (...args: any[]): object }>(
|
||||
target: T,
|
||||
): T {
|
||||
return class Final extends target {
|
||||
constructor(...args: any[]) {
|
||||
if (new.target !== Final) {
|
||||
throw new Error(`Cannot extend a final class "${target.name}"`);
|
||||
}
|
||||
super(...args);
|
||||
}
|
||||
};
|
||||
}
|
10
src/libs/decorators/frozen.decorator.ts
Normal file
10
src/libs/decorators/frozen.decorator.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
/**
|
||||
* Applies Object.freeze() to a class and it's prototype.
|
||||
* Does not freeze all the properties of a class created
|
||||
* using 'new' keyword, only static properties and prototype.
|
||||
*/
|
||||
export function frozen(constructor: Function): void {
|
||||
Object.freeze(constructor);
|
||||
Object.freeze(constructor.prototype);
|
||||
}
|
39
src/libs/exceptions/exception.base.ts
Normal file
39
src/libs/exceptions/exception.base.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { RequestContextService } from "../application/context/AppRequestContext";
|
||||
|
||||
export interface SerializedException {
|
||||
message: string;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
stack?: string;
|
||||
cause?: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
export abstract class ExceptionBase extends Error {
|
||||
abstract code: string;
|
||||
|
||||
public readonly correlationId: string;
|
||||
|
||||
constructor(
|
||||
readonly message: string,
|
||||
readonly cause?: Error,
|
||||
readonly metadata?: unknown
|
||||
) {
|
||||
super(message)
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
const ctx = RequestContextService.getContext();
|
||||
this.correlationId = ctx.requestId;
|
||||
}
|
||||
|
||||
|
||||
toJSON(): SerializedException {
|
||||
return {
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
stack: this.stack,
|
||||
correlationId: this.correlationId,
|
||||
cause: JSON.stringify(this.cause),
|
||||
metadata: this.metadata
|
||||
}
|
||||
}
|
||||
}
|
5
src/libs/exceptions/exception.codes.ts
Normal file
5
src/libs/exceptions/exception.codes.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const ARGUMENT_INVALID = 'GENERIC.ARGUMENT_INVALID';
|
||||
export const ARGUMENT_NOT_PROVIDED = 'GENERIC.ARGUMENT_NOT_PROVIDED';
|
||||
export const NOT_FOUND = 'GENERIC.NOT_FOUND';
|
||||
export const CONFLICT = 'GENERIC.CONFLICT';
|
||||
export const INTERNAL_SERVER_ERROR = 'GENERIC.INTERNAL_SERVER_ERROR';
|
69
src/libs/exceptions/exception.ts
Normal file
69
src/libs/exceptions/exception.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { ExceptionBase } from "./exception.base";
|
||||
import { ARGUMENT_INVALID, ARGUMENT_NOT_PROVIDED, CONFLICT, INTERNAL_SERVER_ERROR, NOT_FOUND } from "./exception.codes";
|
||||
|
||||
/**
|
||||
* Used to indicate that an incorrect argument was provided to a method/function/class constructor
|
||||
*
|
||||
* @class ArgumentInvalidException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
|
||||
export class ArgumentInvalidException extends ExceptionBase {
|
||||
readonly code = ARGUMENT_INVALID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate that an argument was not provided (is empty object/array, null of undefined).
|
||||
*
|
||||
* @class ArgumentNotProvidedException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
|
||||
export class ArgumentNotProvidedException extends ExceptionBase {
|
||||
readonly code = ARGUMENT_NOT_PROVIDED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate conflicting entities (usually in the database)
|
||||
*
|
||||
* @class ConflictException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
|
||||
export class ConflictException extends ExceptionBase {
|
||||
readonly code = CONFLICT
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate that entity is not found
|
||||
*
|
||||
* @class NotFoundException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
|
||||
export class NotFoundException extends ExceptionBase {
|
||||
static readonly message = 'Not found';
|
||||
|
||||
constructor(message = NotFoundException.message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
readonly code = NOT_FOUND
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate that other error (usually in code)
|
||||
*
|
||||
* @class NotFoundException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
|
||||
export class InternalServerErrorException extends ExceptionBase {
|
||||
static readonly message = 'Internal server error';
|
||||
|
||||
constructor(message = InternalServerErrorException.message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
readonly code = INTERNAL_SERVER_ERROR
|
||||
}
|
3
src/libs/exceptions/index.ts
Normal file
3
src/libs/exceptions/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './exception';
|
||||
export * from './exception.base';
|
||||
export * from './exception.codes';
|
55
src/libs/guard.ts
Normal file
55
src/libs/guard.ts
Normal file
@ -0,0 +1,55 @@
|
||||
export class Guard {
|
||||
/**
|
||||
* Checks if value is empty. Accepts strings, numbers, booleans, objects and arrays.
|
||||
*/
|
||||
static isEmpty(value: unknown): boolean {
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
return true;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof Object && !Object.keys(value).length) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (value.every((item) => Guard.isEmpty(item))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks length range of a provided number/string/array
|
||||
*/
|
||||
static lengthIsBetween(
|
||||
value: number | string | Array<unknown>,
|
||||
min: number,
|
||||
max: number,
|
||||
): boolean {
|
||||
if (Guard.isEmpty(value)) {
|
||||
throw new Error(
|
||||
'Cannot check length of a value. Provided value is empty',
|
||||
);
|
||||
}
|
||||
const valueLength =
|
||||
typeof value === 'number'
|
||||
? Number(value).toString().length
|
||||
: value.length;
|
||||
if (valueLength >= min && valueLength <= max) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
6
src/libs/ports/logger.port.ts
Normal file
6
src/libs/ports/logger.port.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface LoggerPort {
|
||||
log(message: string, ...meta: unknown[]): void;
|
||||
error(message: string, trace?: unknown, ...meta: unknown[]): void;
|
||||
warn(message: string, ...meta: unknown[]): void;
|
||||
debug(message: string, ...meta: unknown[]): void;
|
||||
}
|
1
src/libs/types/index.ts
Normal file
1
src/libs/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './object-literal';
|
7
src/libs/types/object-literal.ts
Normal file
7
src/libs/types/object-literal.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Interface of the simple literal object with any string keys.
|
||||
*/
|
||||
|
||||
export interface ObjectLiteral {
|
||||
[key: string]: unknown;
|
||||
}
|
47
src/libs/utils/convert-props-to-object.util.ts
Normal file
47
src/libs/utils/convert-props-to-object.util.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Entity } from '../ddd/base/entity.base';
|
||||
import { ValueObject } from '../ddd/base/value-object.base';
|
||||
|
||||
function isEntity(obj: unknown): obj is Entity<unknown> {
|
||||
/**
|
||||
* 'instanceof Entity' causes error here for some reason.
|
||||
* Probably creates some circular dependency. This is a workaround
|
||||
* until I find a solution :)
|
||||
*/
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(obj, 'toObject') &&
|
||||
Object.prototype.hasOwnProperty.call(obj, 'id') &&
|
||||
ValueObject.isValueObject((obj as Entity<unknown>).id)
|
||||
);
|
||||
}
|
||||
|
||||
function convertToPlainObject(item: any): any {
|
||||
if (ValueObject.isValueObject(item)) {
|
||||
return item.unpack();
|
||||
}
|
||||
if (isEntity(item)) {
|
||||
return item.toObject();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Entity/Value Objects props to a plain object.
|
||||
* Useful for testing and debugging.
|
||||
* @param props
|
||||
*/
|
||||
export function convertPropsToObject(props: any): any {
|
||||
const propsCopy = { ...props };
|
||||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const prop in propsCopy) {
|
||||
if (Array.isArray(propsCopy[prop])) {
|
||||
propsCopy[prop] = (propsCopy[prop] as Array<unknown>).map((item) => {
|
||||
return convertToPlainObject(item);
|
||||
});
|
||||
}
|
||||
propsCopy[prop] = convertToPlainObject(propsCopy[prop]);
|
||||
}
|
||||
|
||||
return propsCopy;
|
||||
}
|
9
src/libs/utils/dotenv.ts
Normal file
9
src/libs/utils/dotenv.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { config } from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// Initializing dotenv
|
||||
const envPath: string = path.resolve(
|
||||
__dirname,
|
||||
process.env.NODE_ENV === 'test' ? '../../../.env.test' : '../../../../.env',
|
||||
);
|
||||
config({ path: envPath });
|
2
src/libs/utils/index.ts
Normal file
2
src/libs/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dotenv';
|
||||
export * from './convert-props-to-object.util';
|
8
src/main.ts
Normal file
8
src/main.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
6
src/modules/user/database/user.repository.port.ts
Normal file
6
src/modules/user/database/user.repository.port.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { RepositoryPort } from "@src/libs/ddd";
|
||||
import { UserEntity } from "../domain/user.entity";
|
||||
|
||||
export interface UserRepositoryPort extends RepositoryPort<UserEntity> {
|
||||
findOneByEmail(email: string): Promise<UserEntity | null>;
|
||||
}
|
58
src/modules/user/database/user.repository.ts
Normal file
58
src/modules/user/database/user.repository.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { SqlRepositoryBase } from '@src/libs/db/sql-repository.base';
|
||||
import { InjectPool } from 'nestjs-slonik';
|
||||
import { DatabasePool } from 'slonik';
|
||||
import { z } from 'zod';
|
||||
import { UserEntity } from '../domain/user.entity';
|
||||
import { UserMapper } from '../user.mapper';
|
||||
import { UserRepositoryPort } from './user.repository.port';
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
phone_number: z.string().min(10).max(15),
|
||||
fullname: z.string(),
|
||||
avatar: z.string(),
|
||||
latitude: z.string(),
|
||||
longitude: z.string(),
|
||||
auth_token: z.string(),
|
||||
verify_token: z.string(),
|
||||
fcm_token: z.string(),
|
||||
is_verified: z.string(),
|
||||
is_merchant: z.string(),
|
||||
created_at: z.preprocess((val: any) => new Date(val), z.date()),
|
||||
updated_at: z.preprocess((val: any) => new Date(val), z.date()),
|
||||
});
|
||||
|
||||
|
||||
export type UserModel = z.TypeOf<typeof userSchema>;
|
||||
|
||||
/**
|
||||
* Repository is used for retrieving/saving domain entities
|
||||
* */
|
||||
@Injectable()
|
||||
export class UserRepository
|
||||
extends SqlRepositoryBase<UserEntity, UserModel>
|
||||
implements UserRepositoryPort
|
||||
{
|
||||
findOneByEmail(email: string): Promise<UserEntity> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
protected tableName: 'users';
|
||||
protected schema = userSchema;
|
||||
|
||||
constructor(
|
||||
@InjectPool()
|
||||
pool: DatabasePool,
|
||||
mapper: UserMapper,
|
||||
eventEmitter: EventEmitter2
|
||||
) {
|
||||
super(pool, mapper, eventEmitter, new Logger(UserRepository.name));
|
||||
}
|
||||
|
||||
async updateAddress(user: UserEntity): Promise<void> {
|
||||
const address = user.getPropsCopy().address;
|
||||
}
|
||||
|
||||
}
|
16
src/modules/user/domain/events/user-created.domain-event.ts
Normal file
16
src/modules/user/domain/events/user-created.domain-event.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { DomainEvent, DomainEventProps } from "@src/libs/ddd";
|
||||
|
||||
export class UserCreatedDomainEvent extends DomainEvent {
|
||||
readonly email: string;
|
||||
readonly phone_number: string;
|
||||
readonly password: string;
|
||||
readonly fullname: string;
|
||||
|
||||
constructor(props: DomainEventProps<UserCreatedDomainEvent>) {
|
||||
super(props);
|
||||
this.email = props.email;
|
||||
this.phone_number = props.phone_number;
|
||||
this.password = props.password;
|
||||
this.fullname = props.fullname;
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { DomainEvent, DomainEventProps } from "@src/libs/ddd";
|
||||
|
||||
export class UserDeletedDomainEvent extends DomainEvent {
|
||||
constructor(props: DomainEventProps<UserDeletedDomainEvent>) {
|
||||
super(props);
|
||||
}
|
||||
}
|
41
src/modules/user/domain/user.entity.ts
Normal file
41
src/modules/user/domain/user.entity.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { AggregateID, AggregateRoot } from "@src/libs/ddd";
|
||||
import { UserCreatedDomainEvent } from "./events/user-created.domain-event";
|
||||
import { CreateUserProps, UserProps, UserRoles } from "./user.types";
|
||||
import { v4 } from 'uuid';
|
||||
import { UserDeletedDomainEvent } from "./events/user-deleted.domain.event";
|
||||
|
||||
export class UserEntity extends AggregateRoot<UserProps> {
|
||||
protected readonly _id: AggregateID;
|
||||
|
||||
static create(create: CreateUserProps): UserEntity {
|
||||
const id = v4();
|
||||
const props: UserProps = { ...create, role: UserRoles.user };
|
||||
const user = new UserEntity({ id, props });
|
||||
user.addEvent(
|
||||
new UserCreatedDomainEvent({
|
||||
aggregateId: id,
|
||||
email: props.email,
|
||||
password: props.password,
|
||||
phone_number: props.password,
|
||||
fullname: props.fullname
|
||||
})
|
||||
);
|
||||
return user;
|
||||
}
|
||||
|
||||
get role(): UserRoles {
|
||||
return this.props.role;
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
this.addEvent(
|
||||
new UserDeletedDomainEvent({
|
||||
aggregateId: this.id,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
public validate(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
0
src/modules/user/domain/user.error.ts
Normal file
0
src/modules/user/domain/user.error.ts
Normal file
25
src/modules/user/domain/user.types.ts
Normal file
25
src/modules/user/domain/user.types.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export interface UserProps {
|
||||
password: string;
|
||||
phone_number: string;
|
||||
role: UserRoles;
|
||||
email: string;
|
||||
fullname: string;
|
||||
}
|
||||
|
||||
export interface CreateUserProps {
|
||||
email: string;
|
||||
phone_number: string;
|
||||
password: string;
|
||||
fullname: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserProps {
|
||||
fullname: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
}
|
||||
|
||||
export enum UserRoles {
|
||||
admin = 'admin',
|
||||
user = 'user'
|
||||
}
|
0
src/modules/user/user.di-tokens.ts
Normal file
0
src/modules/user/user.di-tokens.ts
Normal file
5
src/modules/user/user.mapper.ts
Normal file
5
src/modules/user/user.mapper.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Mapper } from "@src/libs/ddd";
|
||||
|
||||
@Injectable()
|
||||
export class UserMapper implements Mapper<
|
4
src/modules/user/user.module.ts
Normal file
4
src/modules/user/user.module.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class UserModule {}
|
24
test/app.e2e-spec.ts
Normal file
24
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2019",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@src/*": ["src/*"],
|
||||
"@mdoules/*": ["src/modules/*"],
|
||||
"@libs/*": ["src/libs/*"],
|
||||
"@tests/*": ["tests/*"]
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user