This commit is contained in:
nochill 2023-02-26 16:08:28 +07:00
parent d4dfdfc5dd
commit ca73c44dfa
22 changed files with 342 additions and 34 deletions

View File

@ -2,6 +2,7 @@ CREATE TABLE "users" (
"id" VARCHAR PRIMARY KEY,
"phone_number" TEXT NOT NULL UNIQUE,
"email" TEXT NOT NULL UNIQUE,
"password" TEXT NOT NULL,
"fullname" TEXT,
"avatar" TEXT,
"latitude" TEXT,
@ -11,6 +12,7 @@ CREATE TABLE "users" (
"fcm_token" TEXT,
"is_verified" TEXT,
"is_merchant" BOOLEAN,
"role_id" INT,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()

View File

@ -32,7 +32,9 @@
"dependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.3",
"@nestjs/event-emitter": "^1.4.1",
"@nestjs/microservices": "^9.3.9",
"@nestjs/platform-express": "^9.0.0",
"@slonik/migrator": "^0.11.3",
"cache-manager": "^5.1.6",
@ -66,13 +68,16 @@
"eslint-plugin-prettier": "^4.0.0",
"jest": "29.3.1",
"prettier": "^2.3.2",
"run-script-webpack-plugin": "^0.1.1",
"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"
"typescript": "^4.7.4",
"webpack": "^5.75.0",
"webpack-node-externals": "^3.0.0"
},
"jest": {
"moduleFileExtensions": [

View File

@ -3,18 +3,37 @@ 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';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ContextInterceptor } from './libs/application/context/ContextInterceptor';
import { ExceptionInterceptor } from './libs/application/interceptors/exception.interceptors';
import { RequestContextModule } from 'nestjs-request-context';
import { CqrsModule } from '@nestjs/cqrs';
import { EventEmitterModule } from '@nestjs/event-emitter';
const interceptors = [
{
provide: APP_INTERCEPTOR,
useClass: ContextInterceptor
},
{
provide: APP_INTERCEPTOR,
useClass: ExceptionInterceptor
}
];
@Module({
imports: [
EventEmitterModule.forRoot(),
RequestContextModule,
SlonikModule.forRoot({
connectionUri: postgresConnectionUrl,
}),
CqrsModule,
CacheModule.register<RedisClientOptions>(redisConfig),
UserModule
],
controllers: [],
providers: [],
providers: [...interceptors],
})
export class AppModule {}

View File

@ -194,6 +194,7 @@ export abstract class SqlRepositoryBase<
sql`, `,
)})`;
const parsedQuery = query;
return parsedQuery;
}

View File

@ -1,8 +1,21 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
declare const module: any;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
await app.listen(3000);
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
}
bootstrap();

View File

@ -0,0 +1,18 @@
import { Command, CommandProps } from "@src/libs/ddd/base/command.base";
export class CreateUserCommand extends Command {
readonly email: string;
readonly password: string;
readonly fullname: string;
readonly phone_number: string;
readonly role_id: number;
constructor(props: CommandProps<CreateUserCommand>) {
super(props);
this.email = props.email;
this.password = props.password;
this.phone_number = props.phone_number;
this.fullname = props.fullname;
this.role_id = props.role_id;
}
}

View File

@ -0,0 +1,32 @@
import { Post, ConflictException as ConflictHttpException, Body, Controller, InternalServerErrorException } from "@nestjs/common";
import { match, Result} from 'oxide.ts';
import { routesV1 } from "@src/config/app.routes";
import { UserAlreadyExistsError } from "../domain/user.error";
import { UserResponseDto } from "../dtos/user.response.dto";
import { CreateUserCommand } from "./create-user.command";
import { CommandBus } from "@nestjs/cqrs";
import { CreateUserRequestDto } from "./create-user.request.dto";
@Controller(routesV1.version)
export class CreateUserHttpController {
constructor(private readonly commandBus: CommandBus) {}
@Post(routesV1.user.root)
async create(@Body() body: CreateUserRequestDto): Promise<UserResponseDto> {
const command = new CreateUserCommand(body);
const result: Result<UserResponseDto, UserAlreadyExistsError> =
await this.commandBus.execute(command);
console.log(result);
return match(result, {
Ok: (res: any) => res,
Err: (error: Error) => {
if(error instanceof UserAlreadyExistsError)
throw new ConflictHttpException(error.message);
throw error;
}
})
}
}

View File

@ -0,0 +1,20 @@
import { Controller } from "@nestjs/common";
import { CommandBus } from "@nestjs/cqrs";
import { MessagePattern } from "@nestjs/microservices";
import { UserResponseDto } from "../dtos/user.response.dto";
import { CreateUserCommand } from "./create-user.command";
import { CreateUserRequestDto } from "./create-user.request.dto";
@Controller()
export class CreateUserMessageController {
constructor(private readonly commandBus: CommandBus) {}
@MessagePattern('user.create')
async create(message: CreateUserRequestDto): Promise<string> {
const command = new CreateUserCommand(message);
const id: string = await this.commandBus.execute(command);
return id;
}
}

View File

@ -0,0 +1,21 @@
import { IsEmail, IsNotEmpty, IsNumber, IsString, MaxLength, MinLength, } from "class-validator";
export class CreateUserRequestDto {
@IsEmail()
readonly email: string;
@IsNotEmpty()
@MaxLength(14)
@MinLength(10)
readonly phone_number: string
@IsNotEmpty()
readonly fullname: string;
@IsNotEmpty()
readonly password: string;
@IsNumber()
@IsNotEmpty()
readonly role_id: number;
}

View File

@ -0,0 +1,46 @@
import { ConflictException, Inject } from "@nestjs/common";
import { CommandHandler, ICommandHandler } from "@nestjs/cqrs";
import { AggregateID } from "@src/libs/ddd";
import { Err, Ok, Result } from "oxide.ts";
import { UserRepositoryPort } from "../database/user.repository.port";
import { UserEntity } from "../domain/user.entity";
import { UserAlreadyExistsError } from "../domain/user.error";
import { UserResponseDto } from "../dtos/user.response.dto";
import { USER_REPOSITORY } from "../user.di-tokens";
import { CreateUserCommand } from "./create-user.command";
@CommandHandler(CreateUserCommand)
export class CreateUserService implements ICommandHandler {
constructor(
@Inject(USER_REPOSITORY)
protected readonly userRepo: UserRepositoryPort
) {}
async execute(
command: CreateUserCommand
): Promise<Result<UserResponseDto, UserAlreadyExistsError>> {
const user = UserEntity.create({
email: command.email,
password: command.password,
fullname: command.fullname,
phone_number: command.phone_number,
role_id: command.role_id
});
try {
await this.userRepo.transaction(async () => this.userRepo.insert(user));
const userResponse = new UserResponseDto(user);
userResponse.email = command.email;
userResponse.fullname = command.fullname;
userResponse.phone_number = command.phone_number;
return Ok(userResponse)
} catch (error: any) {
if(error instanceof ConflictException) {
return Err(new UserAlreadyExistsError(error));
}
throw error;
}
}
}

View File

@ -2,7 +2,7 @@ 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 { DatabasePool, sql } from 'slonik';
import { z } from 'zod';
import { UserEntity } from '../domain/user.entity';
import { UserMapper } from '../user.mapper';
@ -12,15 +12,17 @@ export const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
phone_number: z.string().min(10).max(15),
password: z.string(),
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(),
avatar: z.string().optional(),
latitude: z.string().optional(),
longitude: z.string().optional(),
auth_token: z.string().optional(),
verify_token: z.string().optional(),
fcm_token: z.string().optional(),
is_verified: z.string().optional(),
is_merchant: z.string().optional(),
role_id: z.number(),
created_at: z.preprocess((val: any) => new Date(val), z.date()),
updated_at: z.preprocess((val: any) => new Date(val), z.date()),
});
@ -36,10 +38,8 @@ export class UserRepository
extends SqlRepositoryBase<UserEntity, UserModel>
implements UserRepositoryPort
{
findOneByEmail(email: string): Promise<UserEntity> {
throw new Error('Method not implemented.');
}
protected tableName: 'users';
protected tableName = 'users';
protected schema = userSchema;
constructor(
@ -51,8 +51,12 @@ export class UserRepository
super(pool, mapper, eventEmitter, new Logger(UserRepository.name));
}
async updateAddress(user: UserEntity): Promise<void> {
const address = user.getPropsCopy().address;
async findOneByEmail(email: string): Promise<UserEntity> {
const user = await this.pool.one(
sql.type(userSchema)`SELECT * FROM "users" WHERE email = ${email}`
)
return this.mapper.toDomain(user);
}
}

View File

@ -5,6 +5,7 @@ export class UserCreatedDomainEvent extends DomainEvent {
readonly phone_number: string;
readonly password: string;
readonly fullname: string;
readonly role_id: number;
constructor(props: DomainEventProps<UserCreatedDomainEvent>) {
super(props);
@ -12,5 +13,6 @@ export class UserCreatedDomainEvent extends DomainEvent {
this.phone_number = props.phone_number;
this.password = props.password;
this.fullname = props.fullname;
this.role_id = props.role_id;
}
}

View File

@ -9,7 +9,7 @@ export class UserEntity extends AggregateRoot<UserProps> {
static create(create: CreateUserProps): UserEntity {
const id = v4();
const props: UserProps = { ...create, role: UserRoles.user };
const props: UserProps = { ...create };
const user = new UserEntity({ id, props });
user.addEvent(
new UserCreatedDomainEvent({
@ -17,14 +17,15 @@ export class UserEntity extends AggregateRoot<UserProps> {
email: props.email,
password: props.password,
phone_number: props.password,
fullname: props.fullname
fullname: props.fullname,
role_id: props.role_id
})
);
return user;
}
get role(): UserRoles {
return this.props.role;
return this.props.role_id;
}
delete(): void {
@ -36,6 +37,6 @@ export class UserEntity extends AggregateRoot<UserProps> {
}
public validate(): void {
throw new Error("Method not implemented.");
// throw new Error("Method not implemented.");
}
}

View File

@ -0,0 +1,11 @@
import { ExceptionBase } from "@src/libs/exceptions";
export class UserAlreadyExistsError extends ExceptionBase {
static readonly message = 'User already exists';
public readonly code = 'USER.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(UserAlreadyExistsError.message, cause, metadata);
}
}

View File

@ -1,7 +1,7 @@
export interface UserProps {
password: string;
phone_number: string;
role: UserRoles;
role_id: UserRoles;
email: string;
fullname: string;
}
@ -11,6 +11,7 @@ export interface CreateUserProps {
phone_number: string;
password: string;
fullname: string;
role_id: number;
}
export interface UpdateUserProps {
@ -20,6 +21,6 @@ export interface UpdateUserProps {
}
export enum UserRoles {
admin = 'admin',
user = 'user'
admin = 1,
user = 2
}

View File

@ -0,0 +1,6 @@
import { PaginatedResponseDto } from "@src/libs/api/paginated.response.base";
import { UserResponseDto } from "./user.response.dto";
export class UserPaginatedResponseDto extends PaginatedResponseDto<UserResponseDto> {
readonly data: readonly UserResponseDto[];
}

View File

@ -0,0 +1,7 @@
import { ResponseBase } from "@src/libs/api/response.base";
export class UserResponseDto extends ResponseBase {
email: string;
phone_number: string;
fullname: string;
}

View File

@ -0,0 +1 @@
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

View File

@ -1,5 +1,50 @@
import { Injectable } from "@nestjs/common";
import { Mapper } from "@src/libs/ddd";
import { UserModel, userSchema } from "./database/user.repository";
import { UserEntity } from "./domain/user.entity";
import { UserResponseDto } from "./dtos/user.response.dto";
@Injectable()
export class UserMapper implements Mapper<
export class UserMapper implements Mapper<UserEntity, UserModel, UserResponseDto> {
toPersistence(entity: UserEntity): UserModel {
const copy = entity.getPropsCopy();
const record: UserModel = {
id: copy.id,
email: copy.email,
phone_number: copy.phone_number,
password: copy.password,
role_id: copy.role_id,
fullname: copy.fullname,
created_at: copy.createdAt,
updated_at: copy.updatedAt,
}
return userSchema.parse(record)
}
toDomain(record: UserModel): UserEntity {
const entity = new UserEntity({
id: record.id,
props: {
email: record.email,
fullname: record.fullname,
password: record.password,
phone_number: record.phone_number,
role_id: record.role_id
}
});
return entity;
}
toResponse(entity: UserEntity): UserResponseDto {
const props = entity.getPropsCopy();
const response = new UserResponseDto(entity);
response.email = props.email;
response.fullname = props.fullname;
response.phone_number = props.phone_number;
return response;
}
}

View File

@ -1,4 +1,32 @@
import { Module } from '@nestjs/common';
import { Logger, Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { CreateUserHttpController } from './commands/create-user.http.controller';
import { CreateUserMessageController } from './commands/create-user.message.controller';
import { CreateUserService } from './commands/create-user.service';
import { UserRepository } from './database/user.repository';
import { USER_REPOSITORY } from './user.di-tokens';
import { UserMapper } from './user.mapper';
@Module({})
const httpControllers = [
CreateUserHttpController
];
const messageControllers = [CreateUserMessageController];
const commandHandlers: Provider[] = [CreateUserService];
const mappers: Provider[] = [UserMapper];
const repositories: Provider[] = [
{ provide: USER_REPOSITORY, useClass: UserRepository}
];
@Module({
imports: [CqrsModule],
controllers: [...httpControllers, ...messageControllers],
providers: [
Logger,
...repositories,
...mappers,
...commandHandlers
],
})
export class UserModule {}

View File

@ -724,6 +724,13 @@
path-to-regexp "3.2.0"
tslib "2.5.0"
"@nestjs/cqrs@^9.0.3":
version "9.0.3"
resolved "https://registry.yarnpkg.com/@nestjs/cqrs/-/cqrs-9.0.3.tgz#1ccd87feffebf33b2f3b0170f98eda8c6c74796f"
integrity sha512-hmbrqf51BVdgmnnxErnLVXfPNTEqr4Hz8DyLa9dKLIW3BuOyI5RDwJ/9sKbJ47UDBhumC5nQlNK9qk27mhqHfw==
dependencies:
uuid "9.0.0"
"@nestjs/event-emitter@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@nestjs/event-emitter/-/event-emitter-1.4.1.tgz#a96fae678b0257b9cd8f6c82c9f3f82a0690e221"
@ -731,6 +738,14 @@
dependencies:
eventemitter2 "6.4.9"
"@nestjs/microservices@^9.3.9":
version "9.3.9"
resolved "https://registry.yarnpkg.com/@nestjs/microservices/-/microservices-9.3.9.tgz#dbcecd5c3903ee6433be303eec0415ff9e60fb81"
integrity sha512-G4EsQpOS3l2dWjJID+z/YyDPTx+SEq/5YQ/cC8XV9Hap1S0rEmo+Z1R2OdlUt+ZnkcJp7H0GIHiB0EjaYanmjA==
dependencies:
iterare "1.2.1"
tslib "2.5.0"
"@nestjs/platform-express@^9.0.0":
version "9.3.9"
resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-9.3.9.tgz#557ace8589b54d4ee7bad87a1247a521058395d7"
@ -4571,6 +4586,11 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
run-script-webpack-plugin@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/run-script-webpack-plugin/-/run-script-webpack-plugin-0.1.1.tgz#dad3114be32eb864d2160306e4d9c52a2c1cfd59"
integrity sha512-PrxBRLv1K9itDKMlootSCyGhdTU+KbKGJ2wF6/k0eyo6M0YGPC58HYbS/J/QsDiwM0t7G99WcuCqto0J7omOXA==
rxjs@6.6.7, rxjs@^6.6.0:
version "6.6.7"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
@ -5245,6 +5265,11 @@ uuid@8.3.2, uuid@^8.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
@ -5301,7 +5326,7 @@ webidl-conversions@^4.0.2:
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
webpack-node-externals@3.0.0:
webpack-node-externals@3.0.0, webpack-node-externals@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz#1a3407c158d547a9feb4229a9e3385b7b60c9917"
integrity sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==
@ -5311,7 +5336,7 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@5.75.0:
webpack@5.75.0, webpack@^5.75.0:
version "5.75.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.75.0.tgz#1e440468647b2505860e94c9ff3e44d5b582c152"
integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==