This commit is contained in:
nochill 2023-02-24 15:25:06 +07:00
commit 9f9b1b5e5c
65 changed files with 7168 additions and 0 deletions

8
.env.example Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"editor.tabSize": 2,
"editor.detectIndentation": false,
"editor.minimap.enabled": false,
"breadcrumbs.enabled": false,
}

6
README.md Normal file
View 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
View 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
View File

@ -0,0 +1,9 @@
import { getMigrator } from "./getMigrator";
export async function run() {
const { migrator } = await getMigrator();
migrator.runAsCLI();
console.log('Done');
}
run();

View 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
View File

View File

@ -0,0 +1,14 @@
INSERT INTO
roles(
id,
name
)
VALUES
(
1,
"Admin"
),
(
2,
"User"
);

8
nest-cli.json Normal file
View 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
View 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
View 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
View 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`,
},
};

View 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}`;

View 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
}

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

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

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

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

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

View 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
})
)
}
}

View 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)
})
)
}
}

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

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

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

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

View 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`,
// );
// }
}
}

View File

@ -0,0 +1,4 @@
export * from './aggregate-root.base'
export * from './domain-event.base'
export * from './entity.base'
export * from './value-object.base'

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

View 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
View File

@ -0,0 +1,3 @@
export * from './base';
export * from './interface';
export * from './repository.port';

View File

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

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

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

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

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

View 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
}
}
}

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

View 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
}

View File

@ -0,0 +1,3 @@
export * from './exception';
export * from './exception.base';
export * from './exception.codes';

55
src/libs/guard.ts Normal file
View 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;
}
}

View 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
View File

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

View File

@ -0,0 +1,7 @@
/**
* Interface of the simple literal object with any string keys.
*/
export interface ObjectLiteral {
[key: string]: unknown;
}

View 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
View 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
View File

@ -0,0 +1,2 @@
export * from './dotenv';
export * from './convert-props-to-object.util';

8
src/main.ts Normal file
View 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();

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

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

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

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from "@src/libs/ddd";
export class UserDeletedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<UserDeletedDomainEvent>) {
super(props);
}
}

View 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.");
}
}

View File

View 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'
}

View File

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { Mapper } from "@src/libs/ddd";
@Injectable()
export class UserMapper implements Mapper<

View File

@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class UserModule {}

24
test/app.e2e-spec.ts Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

28
tsconfig.json Normal file
View 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/*"]
}
}
}

5458
yarn.lock Normal file

File diff suppressed because it is too large Load Diff