├── .gitignore ├── src ├── tasks │ ├── task.const.ts │ ├── task.module.ts │ ├── init-salary-worker-job.task.ts │ └── init-salary-job.task.ts ├── modules │ ├── worker-report │ │ ├── worker-report.const.ts │ │ ├── dto │ │ │ └── get-salary-history.dto.ts │ │ ├── worker-report.module.ts │ │ ├── worker-report.service.ts │ │ └── worker-report.controller.ts │ ├── wallet │ │ ├── wallet.const.ts │ │ ├── dto │ │ │ ├── transfer-money.dto.ts │ │ │ └── withdraw-money.dto.ts │ │ ├── wallet.module.ts │ │ ├── wallet.controller.ts │ │ └── wallet.service.ts │ ├── partner-config │ │ ├── dto │ │ │ ├── de-active-worker-salary-config.dto.ts │ │ │ ├── list-worker.dto.ts │ │ │ ├── lock-or-unlock-worker-wallet.dto.ts │ │ │ ├── config-worker-salary.dto.ts │ │ │ └── partner-update-info.dto.ts │ │ ├── partner-config.const.ts │ │ ├── partner-config.module.ts │ │ ├── partner-config.controller.ts │ │ └── partner-config.service.ts │ ├── authentication │ │ ├── dto │ │ │ ├── refresh-token.dto.ts │ │ │ ├── login.dto.ts │ │ │ ├── change-password.dto.ts │ │ │ └── register.dto.ts │ │ ├── auth.const.ts │ │ ├── auth.module.ts │ │ ├── auth.console.ts │ │ ├── auth.controller.ts │ │ └── auth.service.ts │ └── salary-calculation │ │ ├── salary-calculation.module.ts │ │ └── salary-calculation.console.ts ├── configs │ ├── response-to-client.config.ts │ ├── database.config.ts │ ├── kafka.config.ts │ ├── ormconfig.ts │ └── all-exceptions.filter.ts ├── models │ ├── repositories │ │ ├── worker_wallet_history.repository.ts │ │ ├── worker_wallet.repository.ts │ │ ├── job.repository.ts │ │ ├── user.repository.ts │ │ ├── worker_salary_config.repository.ts │ │ ├── company_info.repository.ts │ │ └── worker_salary_history.repository.ts │ └── entities │ │ ├── company_info.entity.ts │ │ ├── worker_wallet.entity.ts │ │ ├── user.entity.ts │ │ ├── worker_salary_config.entity.ts │ │ ├── worker_salary_history.entity.ts │ │ ├── worker_wallet_history.entity.ts │ │ └── job.entity.ts ├── app.module.ts ├── guards │ ├── jwt.strategy.ts │ ├── jwt-auth.guard.ts │ └── roles.guard.ts ├── seeding │ ├── seeding.module.ts │ └── seeding.console.ts ├── decorators │ ├── user-id.decorator.ts │ └── user-email.decorator.ts ├── console.ts ├── modules.ts ├── shares │ └── common.ts ├── migrations │ ├── 1711008548306-worker_wallet.ts │ ├── 1710859679683-users.ts │ ├── 1710870445190-companies_info.ts │ ├── 1711009720195-worker_wallet_histories.ts │ ├── 1711014723466-jobs.ts │ ├── 1710863933870-worker_salary_configs.ts │ └── 1711007153515-worker_salary_histories.ts └── main.ts ├── tsconfig.build.json ├── docs ├── images │ ├── basic-step │ │ ├── step-1.png │ │ ├── step-2.png │ │ ├── step-3.png │ │ ├── step-4.png │ │ ├── step-5.png │ │ ├── step-6.png │ │ ├── step-7.png │ │ └── step-8.png │ └── overview-system │ │ ├── high-level-design.png │ │ └── low-level-design-with-aws-cloud.png └── README.md ├── docker └── init-database │ └── init-test-database.sql ├── test ├── dotenv-config.js ├── mock │ ├── mock-user-entity.ts │ ├── mock-company-info-entity.ts │ ├── mock-worker-salary-config.ts │ └── mock-worker-salary-history.ts ├── e2e │ └── partner-config │ │ └── partner-config.e2e.spec.ts └── unit │ ├── salary-calculation │ └── salary-calculation.unit.spec.ts │ ├── authentication │ └── authentication.unit.spec.ts │ ├── partner-config │ └── partner-config.unit.spec.ts │ └── task │ └── init-salary-job.unit.spec.ts ├── jest.config.js ├── nest-cli.json ├── DockerfileTaskSchedule ├── DockerfileApp ├── tsconfig.json ├── quick_start.sh ├── ecosystem.config.js ├── .env.example ├── .env.production ├── .env.test ├── LICENSE ├── docker-compose.yml ├── .github └── workflows │ └── test.yml ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | .env 5 | yarn.lock 6 | yarn-error.log 7 | ormlogs.log 8 | -------------------------------------------------------------------------------- /src/tasks/task.const.ts: -------------------------------------------------------------------------------- 1 | export const HourInMinutes = 60; 2 | export const DayInMinutes = 1440; 3 | export const DayInHours = 24; 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/images/basic-step/step-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-1.png -------------------------------------------------------------------------------- /docs/images/basic-step/step-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-2.png -------------------------------------------------------------------------------- /docs/images/basic-step/step-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-3.png -------------------------------------------------------------------------------- /docs/images/basic-step/step-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-4.png -------------------------------------------------------------------------------- /docs/images/basic-step/step-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-5.png -------------------------------------------------------------------------------- /docs/images/basic-step/step-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-6.png -------------------------------------------------------------------------------- /docs/images/basic-step/step-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-7.png -------------------------------------------------------------------------------- /docs/images/basic-step/step-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/basic-step/step-8.png -------------------------------------------------------------------------------- /docker/init-database/init-test-database.sql: -------------------------------------------------------------------------------- 1 | -- This is for init database for testing when run docker on local machine 2 | CREATE DATABASE test_salary_hero; -------------------------------------------------------------------------------- /src/modules/worker-report/worker-report.const.ts: -------------------------------------------------------------------------------- 1 | export enum WorkerReportMessageSuccess { 2 | ListSalaryHistorySuccess = "Get list salary history successfully.", 3 | } 4 | -------------------------------------------------------------------------------- /docs/images/overview-system/high-level-design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/overview-system/high-level-design.png -------------------------------------------------------------------------------- /test/dotenv-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description: We can change .env file to another env file for testing 3 | */ 4 | require("dotenv").config({ 5 | path: ".env.test", 6 | }); 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | modulePaths: ["./"], 5 | setupFiles: ["/test/dotenv-config.js"], 6 | }; 7 | -------------------------------------------------------------------------------- /docs/images/overview-system/low-level-design-with-aws-cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthew-nguyen-20032023/salary-hero-backend/HEAD/docs/images/overview-system/low-level-design-with-aws-cloud.png -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/configs/response-to-client.config.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from "@nestjs/common"; 2 | 3 | export interface IMetadata { 4 | total: number; 5 | } 6 | 7 | export interface IResponseToClient { 8 | message: string; 9 | statusCode: HttpStatus; 10 | data?: any; 11 | metadata?: IMetadata; 12 | } 13 | -------------------------------------------------------------------------------- /src/models/repositories/worker_wallet_history.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from "typeorm"; 2 | import { WorkerWalletHistoryEntity } from "src/models/entities/worker_wallet_history.entity"; 3 | 4 | @EntityRepository(WorkerWalletHistoryEntity) 5 | export class WorkerWalletHistoryEntityRepository extends Repository {} 6 | -------------------------------------------------------------------------------- /src/modules/wallet/wallet.const.ts: -------------------------------------------------------------------------------- 1 | export enum WalletMessageSuccess { 2 | GetWalletSuccess = "Get wallet successfully.", 3 | } 4 | 5 | export enum WalletMessageFailed { 6 | ExecBalance = "Amount exec available wallet balance.", 7 | WalletLocked = "Your wallet was locked by partner, contact for more information.", 8 | InvalidReceiverWallet = "Invalid receiver wallet.", 9 | } 10 | -------------------------------------------------------------------------------- /DockerfileTaskSchedule: -------------------------------------------------------------------------------- 1 | FROM node:16.20.1-slim 2 | 3 | # Working directory 4 | WORKDIR /app 5 | 6 | # Install dependencies 7 | COPY ./package.json ./ 8 | 9 | RUN yarn -v 10 | RUN yarn 11 | 12 | # Copy source 13 | COPY . . 14 | COPY ./.env.production ./.env 15 | 16 | ## Build and cleanup 17 | ENV NODE_ENV=production 18 | RUN yarn build 19 | 20 | ## Start server 21 | CMD ["yarn", "start:prod"] 22 | -------------------------------------------------------------------------------- /DockerfileApp: -------------------------------------------------------------------------------- 1 | FROM node:16.20.1-slim 2 | 3 | # Working directory 4 | WORKDIR /app 5 | 6 | # Install dependencies 7 | COPY ./package.json ./ 8 | 9 | RUN yarn -v 10 | RUN yarn 11 | 12 | # Copy source 13 | COPY . . 14 | COPY ./.env.production ./.env 15 | 16 | ## Build and cleanup 17 | ENV NODE_ENV=production 18 | ENV DISABLE_SCHEDULE_JOB=true 19 | RUN yarn build 20 | 21 | ## Start server 22 | CMD ["yarn", "start:prod"] 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "importHelpers": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/partner-config/dto/de-active-worker-salary-config.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsEmail, IsNotEmpty, Min } from "class-validator"; 3 | import { Type } from "class-transformer"; 4 | 5 | export class DeActiveWorkerSalaryConfigDto { 6 | @Type(() => Number) 7 | @IsNotEmpty() 8 | @Min(1) 9 | @ApiProperty({ 10 | description: "Id of worker config salary", 11 | required: true, 12 | example: 1, 13 | }) 14 | workerSalaryConfigId: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import Modules from "src/modules"; 2 | import { APP_GUARD } from "@nestjs/core"; 3 | import { Logger, Module } from "@nestjs/common"; 4 | import { JwtStrategy } from "src/guards/jwt.strategy"; 5 | import { JwtAuthGuard } from "src/guards/jwt-auth.guard"; 6 | 7 | @Module({ 8 | imports: Modules, 9 | controllers: [], 10 | providers: [ 11 | Logger, 12 | { 13 | provide: APP_GUARD, 14 | useClass: JwtAuthGuard, 15 | }, 16 | JwtStrategy, 17 | ], 18 | }) 19 | export class AppModule {} 20 | -------------------------------------------------------------------------------- /src/models/entities/company_info.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity({ name: "companies_info" }) 4 | export class CompanyInfoEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | user_email: string; 10 | 11 | @Column() 12 | company_name: string; 13 | 14 | @Column() 15 | company_description: string; 16 | 17 | @Column() 18 | timezone: number; 19 | 20 | @Column() 21 | created_at: number; 22 | 23 | @Column() 24 | updated_at: number; 25 | } 26 | -------------------------------------------------------------------------------- /src/models/repositories/worker_wallet.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from "typeorm"; 2 | import { WorkerWalletEntity } from "src/models/entities/worker_wallet.entity"; 3 | 4 | @EntityRepository(WorkerWalletEntity) 5 | export class WorkerWalletRepository extends Repository { 6 | public async getWorkerWalletByWorkerEmail( 7 | workerEmail: string 8 | ): Promise { 9 | return await this.findOne({ 10 | where: { 11 | worker_email: workerEmail, 12 | }, 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/mock/mock-user-entity.ts: -------------------------------------------------------------------------------- 1 | import { UserEntity, UserRole } from "src/models/entities/user.entity"; 2 | import { generateRandomEmail, generateRandomUsername } from "src/shares/common"; 3 | 4 | export function mockRandomUser( 5 | userRole: UserRole, 6 | createdBy = null 7 | ): UserEntity { 8 | const newUser = new UserEntity(); 9 | newUser.email = generateRandomEmail(); 10 | newUser.password = "test"; 11 | newUser.role = userRole; 12 | newUser.username = generateRandomUsername(); 13 | if (createdBy) newUser.created_by = createdBy; 14 | return newUser; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/authentication/dto/refresh-token.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class RefreshTokenDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | description: "Refresh token", 9 | required: true, 10 | example: 11 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiZW1haWwiOiJwYXJ0bmVyQGdtYWlsLmNvbSIsInJvbGUiOiJwYXJ0bmVyIiwiaWF0IjoxNzExMDgwODQxLCJleHAiOjE3MTE2ODU2NDF9.eTxIg_1Jd3pbX3CrGCJsieiWtH6HHzYMqIHxtsZn078", 12 | }) 13 | refreshToken: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/configs/database.config.ts: -------------------------------------------------------------------------------- 1 | export const AppDataSource = { 2 | type: process.env.DATABASE_TYPE as "postgres", 3 | host: process.env.DATABASE_HOST, 4 | port: Number(process.env.DATABASE_PORT), 5 | username: process.env.DATABASE_USER, 6 | password: process.env.DATABASE_PASS, 7 | database: process.env.DATABASE_NAME, 8 | entities: [__dirname + "/../models/entities/*.entity{.ts,.js}"], 9 | logging: Boolean(process.env.DATABASE_LOG), 10 | synchronize: false, 11 | migrationsRun: false, 12 | migrations: ["dist/**/migrations/*.js"], 13 | migrationsTableName: "migration_history", 14 | }; 15 | -------------------------------------------------------------------------------- /quick_start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Preparing 4 | yarn # install lib dependencies 5 | cp .env.example .env # init .env file (change information if you want to) 6 | yarn build 7 | docker-compose up -d # init services component (depend on .env file, noted new docker version run docker compose up -d) 8 | npm run typeorm:run # migrate database schema 9 | npm run typeorm:test # migrate testing database schema 10 | yarn console:dev seeding-data # seeding data for develop 11 | yarn test # testing 12 | pm2 restart ecosystem.config.js 13 | -------------------------------------------------------------------------------- /src/modules/authentication/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class LoginDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | description: "Identify can be username or email", 9 | required: true, 10 | example: "exampleUsername", 11 | }) 12 | identify: string; 13 | 14 | @IsString() 15 | @IsNotEmpty() 16 | @ApiProperty({ 17 | description: "Password for register", 18 | required: true, 19 | example: "examplePassword@123", 20 | }) 21 | password: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/authentication/dto/change-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class ChangePasswordDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | description: "Current account password", 9 | required: true, 10 | example: "admin@123", 11 | }) 12 | currentPassword: string; 13 | 14 | @IsString() 15 | @IsNotEmpty() 16 | @ApiProperty({ 17 | description: "New password want to change", 18 | required: true, 19 | example: "newPassword@123", 20 | }) 21 | newPassword: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/configs/kafka.config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | dotenv.config({ 3 | path: process.env.NODE_ENV === "testing" ? ".env.test" : ".env", 4 | }); 5 | import { Kafka } from "kafkajs"; 6 | 7 | export const KafkaConfig = new Kafka({ 8 | clientId: process.env.APP_NAME, 9 | brokers: [ 10 | `${process.env.KAFKA_HOST}:${process.env.KAFKA_PORT}`, 11 | `${process.env.KAFKA_HOST}:${process.env.KAFKA_PORT}`, // for multiple kafka cluster 12 | ], 13 | }); 14 | 15 | // For specific kafka topic to another develop to know 16 | export enum KafkaTopic { 17 | CalculateDailyWorkerSalary = "calculate-daily-worker-salary", 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/partner-config/dto/list-worker.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, Max, Min } from "class-validator"; 3 | import { Type } from "class-transformer"; 4 | 5 | export class ListWorkerDto { 6 | @Type(() => Number) 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: "Page", 10 | required: true, 11 | example: 1, 12 | }) 13 | @Min(1) 14 | page: number; 15 | 16 | @Type(() => Number) 17 | @IsNotEmpty() 18 | @ApiProperty({ 19 | description: "Limit", 20 | required: false, 21 | example: 10, 22 | }) 23 | @Min(1) 24 | @Max(100) 25 | limit: number; 26 | } 27 | -------------------------------------------------------------------------------- /src/models/entities/worker_wallet.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity({ name: "worker_wallet" }) 4 | export class WorkerWalletEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | worker_email: string; 10 | 11 | @Column() 12 | available_balance: number; 13 | 14 | @Column() 15 | pending_balance: number; 16 | 17 | @Column() 18 | is_active: boolean; 19 | 20 | @Column() 21 | created_at: number; 22 | 23 | @Column() 24 | updated_at: number; 25 | } 26 | 27 | export enum LockUnLockAction { 28 | Lock = "lock", 29 | Unlock = "unlock", 30 | } 31 | -------------------------------------------------------------------------------- /src/guards/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from "passport-jwt"; 2 | import { PassportStrategy } from "@nestjs/passport"; 3 | import { Injectable } from "@nestjs/common"; 4 | 5 | @Injectable() 6 | export class JwtStrategy extends PassportStrategy(Strategy) { 7 | constructor() { 8 | super({ 9 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 10 | ignoreExpiration: false, 11 | secretOrKey: process.env.JWT_SECRET, 12 | }); 13 | } 14 | 15 | async validate(payload: any) { 16 | return { 17 | id: payload.id, 18 | email: payload.email, 19 | role: payload.role, 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/models/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity({ name: "users" }) 4 | export class UserEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | username: string; 10 | 11 | @Column() 12 | email: string; 13 | 14 | @Column() 15 | password: string; 16 | 17 | @Column() 18 | role: UserRole; 19 | 20 | @Column() 21 | created_by: number; 22 | 23 | @Column() 24 | created_at: number; 25 | 26 | @Column() 27 | updated_at: number; 28 | } 29 | 30 | export enum UserRole { 31 | Admin = "admin", 32 | Partner = "partner", 33 | Worker = "worker", 34 | } 35 | -------------------------------------------------------------------------------- /src/models/entities/worker_salary_config.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity({ name: "worker_salary_configs" }) 4 | export class WorkerSalaryConfigEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | company_id: number; 10 | 11 | @Column() 12 | user_email: string; 13 | 14 | @Column() 15 | standard_working_day: number; 16 | 17 | @Column() 18 | base_salary: number; 19 | 20 | @Column() 21 | is_active: boolean; 22 | 23 | @Column() 24 | created_by: number; 25 | 26 | @Column() 27 | created_at: number; 28 | 29 | @Column() 30 | updated_at: number; 31 | } 32 | -------------------------------------------------------------------------------- /test/mock/mock-company-info-entity.ts: -------------------------------------------------------------------------------- 1 | import { CompanyInfoEntity } from "src/models/entities/company_info.entity"; 2 | import { generateRandomEmail, generateRandomString } from "src/shares/common"; 3 | 4 | export function mockRandomCompanyInfo( 5 | partnerEmail: string, 6 | timezone: number 7 | ): CompanyInfoEntity { 8 | const newCompanyInfo = new CompanyInfoEntity(); 9 | newCompanyInfo.company_name = generateRandomString(); 10 | newCompanyInfo.company_description = generateRandomString(); 11 | newCompanyInfo.timezone = timezone; 12 | if (partnerEmail) newCompanyInfo.user_email = partnerEmail; 13 | else newCompanyInfo.user_email = generateRandomEmail(); 14 | return newCompanyInfo; 15 | } 16 | -------------------------------------------------------------------------------- /src/models/entities/worker_salary_history.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity({ name: "worker_salary_histories" }) 4 | export class WorkerSalaryHistoryEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | date: number; 10 | 11 | @Column() 12 | worker_email: string; 13 | 14 | @Column() 15 | daily_income: number; 16 | 17 | @Column() 18 | total_income: number; 19 | 20 | @Column() 21 | worker_salary_config_id: number; 22 | 23 | @Column() 24 | is_active: boolean; 25 | 26 | @Column() 27 | note: string; 28 | 29 | @Column() 30 | created_at: number; 31 | 32 | @Column() 33 | updated_at: number; 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/partner-config/partner-config.const.ts: -------------------------------------------------------------------------------- 1 | export enum PartnerMessageSuccess { 2 | UpdateCompanyInfoSuccess = "Update company info successfully.", 3 | ListWorkerSuccess = "Get list worker successfully.", 4 | ConfigWorkerSalarySuccess = "Config worker salary successfully.", 5 | LockWalletSuccess = "Worker wallet locked successfully.", 6 | UnlockWalletSuccess = "Worker wallet unlocked successfully.", 7 | } 8 | 9 | export enum PartnerMessageFailed { 10 | CompanyInfoRequire = "Please update company info to setup worker salary.", 11 | InvalidWorker = "Invalid worker, please choose another worker email.", 12 | InvalidWorkerConfig = "Invalid worker config salary.", 13 | InvalidWorkerWallet = "Invalid worker wallet.", 14 | } 15 | -------------------------------------------------------------------------------- /src/seeding/seeding.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | import { UserRepository } from "src/models/repositories/user.repository"; 4 | import { CompanyInfoRepository } from "src/models/repositories/company_info.repository"; 5 | import { SeedingConsole } from "src/seeding/seeding.console"; 6 | import { WorkerSalaryConfigRepository } from "src/models/repositories/worker_salary_config.repository"; 7 | 8 | @Module({ 9 | imports: [ 10 | TypeOrmModule.forFeature([ 11 | UserRepository, 12 | CompanyInfoRepository, 13 | WorkerSalaryConfigRepository, 14 | ]), 15 | ], 16 | controllers: [], 17 | providers: [SeedingConsole, Logger], 18 | }) 19 | export class SeedingModule {} 20 | -------------------------------------------------------------------------------- /src/modules/partner-config/dto/lock-or-unlock-worker-wallet.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsEnum, IsNotEmpty, IsString } from "class-validator"; 3 | import { LockUnLockAction } from "src/models/entities/worker_wallet.entity"; 4 | 5 | export class LockOrUnLockWorkerWalletDto { 6 | @IsString() 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: "Email of worker want to lock", 10 | required: true, 11 | example: "worker@gmail.com", 12 | }) 13 | workerEmail: string; 14 | 15 | @IsEnum(LockUnLockAction) 16 | @IsNotEmpty() 17 | @ApiProperty({ 18 | description: "Action lock or unlock", 19 | required: true, 20 | example: `${LockUnLockAction.Lock} | ${LockUnLockAction.Unlock}`, 21 | }) 22 | action: LockUnLockAction; 23 | } 24 | -------------------------------------------------------------------------------- /test/mock/mock-worker-salary-config.ts: -------------------------------------------------------------------------------- 1 | import { WorkerSalaryConfigEntity } from "src/models/entities/worker_salary_config.entity"; 2 | 3 | export function mockWorkerSalaryConfig( 4 | workerEmail: string, 5 | standardWorkingDay: number, 6 | baseSalary: number 7 | ): WorkerSalaryConfigEntity { 8 | const newWorkerSalaryConfigEntity = new WorkerSalaryConfigEntity(); 9 | newWorkerSalaryConfigEntity.id = 1; 10 | newWorkerSalaryConfigEntity.company_id = 1; 11 | newWorkerSalaryConfigEntity.user_email = workerEmail; 12 | newWorkerSalaryConfigEntity.standard_working_day = standardWorkingDay; 13 | newWorkerSalaryConfigEntity.base_salary = baseSalary; 14 | newWorkerSalaryConfigEntity.created_by = 1; 15 | newWorkerSalaryConfigEntity.is_active = true; 16 | return newWorkerSalaryConfigEntity; 17 | } 18 | -------------------------------------------------------------------------------- /src/decorators/user-id.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | HttpException, 5 | HttpStatus, 6 | } from "@nestjs/common"; 7 | import jwtDecode from "jwt-decode"; 8 | 9 | /** 10 | * @description decorator to quickly get current user id calling api instead of get from request.user.email 11 | */ 12 | export const UserId = createParamDecorator( 13 | (data: string, ctx: ExecutionContext) => { 14 | const request = ctx.switchToHttp().getRequest(); 15 | try { 16 | const token = request.headers.authorization; 17 | const payload = jwtDecode(token); 18 | return payload["id"]; 19 | } catch (e) { 20 | throw new HttpException( 21 | { message: "Invalid token" }, 22 | HttpStatus.BAD_REQUEST 23 | ); 24 | } 25 | } 26 | ); 27 | -------------------------------------------------------------------------------- /src/decorators/user-email.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | HttpException, 5 | HttpStatus, 6 | } from "@nestjs/common"; 7 | import jwtDecode from "jwt-decode"; 8 | 9 | /** 10 | * @description decorator to quickly get current user email calling api instead of get from request.user.email 11 | */ 12 | export const UserEmail = createParamDecorator( 13 | (data: string, ctx: ExecutionContext) => { 14 | const request = ctx.switchToHttp().getRequest(); 15 | try { 16 | const token = request.headers.authorization; 17 | const payload = jwtDecode(token); 18 | return payload["email"]; 19 | } catch (e) { 20 | throw new HttpException( 21 | { message: "Invalid token" }, 22 | HttpStatus.BAD_REQUEST 23 | ); 24 | } 25 | } 26 | ); 27 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | /** 4 | * @description Backend 5 | */ 6 | { 7 | name: "SALARY_HERO: Backend API", 8 | script: "DISABLE_SCHEDULE_JOB=true node ./dist/main.js", 9 | autorestart: true, 10 | }, 11 | /** 12 | * @description Salary Task Schedule 13 | */ 14 | { 15 | name: "SALARY_HERO: Task Schedule (Kafka Provider)", 16 | script: "NODE_PORT=3001 node ./dist/main.js", 17 | autorestart: true, 18 | }, 19 | /** 20 | * @description Consumer calculate worker salary 21 | */ 22 | { 23 | name: "SALARY_HERO: Console Job Handle Salary (Kafka Consumer)", 24 | script: "node ./dist/console.js calculate-worker-salary", 25 | autorestart: true, 26 | cron_restart: "1 0 * * *", 27 | }, 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /src/modules/authentication/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsEmail, IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class RegisterDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | description: "Username for register", 9 | required: true, 10 | example: "exampleUsername", 11 | }) 12 | userName: string; 13 | 14 | @IsEmail() 15 | @IsNotEmpty() 16 | @ApiProperty({ 17 | description: "Email, and also can be used for login later", 18 | required: true, 19 | example: "exampleEmail@gmail.com", 20 | }) 21 | email: string; 22 | 23 | @IsString() 24 | @IsNotEmpty() 25 | @ApiProperty({ 26 | description: "Password for register", 27 | required: true, 28 | example: "examplePassword@123", 29 | }) 30 | password: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/models/entities/worker_wallet_history.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity({ name: "worker_wallet_histories" }) 4 | export class WorkerWalletHistoryEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | date: number; 10 | 11 | @Column() 12 | worker_email: string; 13 | 14 | @Column() 15 | action_type: WorkerWalletActionType; 16 | 17 | @Column() 18 | amount: number; 19 | 20 | @Column() 21 | note: string; 22 | 23 | @Column() 24 | created_at: number; 25 | 26 | @Column() 27 | updated_at: number; 28 | } 29 | 30 | export enum WorkerWalletActionType { 31 | Transfer = "transfer", // transfer for another worker 32 | Withdraw = "withdraw", // worker withdraw their money 33 | Receive = "receive", // worker receive money from another 34 | } 35 | -------------------------------------------------------------------------------- /src/tasks/task.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | import { InitSalaryJobTask } from "src/tasks/init-salary-job.task"; 4 | import { JobRepository } from "src/models/repositories/job.repository"; 5 | import { InitSalaryWorkerJobTask } from "src/tasks/init-salary-worker-job.task"; 6 | import { CompanyInfoRepository } from "src/models/repositories/company_info.repository"; 7 | import { WorkerSalaryConfigRepository } from "src/models/repositories/worker_salary_config.repository"; 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forFeature([ 12 | JobRepository, 13 | CompanyInfoRepository, 14 | WorkerSalaryConfigRepository, 15 | ]), 16 | ], 17 | controllers: [], 18 | providers: [InitSalaryJobTask, InitSalaryWorkerJobTask], 19 | }) 20 | export class TaskModule {} 21 | -------------------------------------------------------------------------------- /test/mock/mock-worker-salary-history.ts: -------------------------------------------------------------------------------- 1 | import { WorkerSalaryHistoryEntity } from "src/models/entities/worker_salary_history.entity"; 2 | 3 | export function mockWorkerSalaryHistory( 4 | datetime: number, 5 | workerEmail: string, 6 | dailyIncome: number, 7 | totalIncome: number 8 | ): WorkerSalaryHistoryEntity { 9 | const newWorkerSalaryHistoryEntity = new WorkerSalaryHistoryEntity(); 10 | newWorkerSalaryHistoryEntity.date = datetime; 11 | newWorkerSalaryHistoryEntity.worker_email = workerEmail; 12 | newWorkerSalaryHistoryEntity.daily_income = dailyIncome; 13 | newWorkerSalaryHistoryEntity.total_income = totalIncome; 14 | newWorkerSalaryHistoryEntity.worker_salary_config_id = 1; 15 | newWorkerSalaryHistoryEntity.is_active = true; 16 | newWorkerSalaryHistoryEntity.note = "Mock salary calculation"; 17 | return newWorkerSalaryHistoryEntity; 18 | } 19 | -------------------------------------------------------------------------------- /src/console.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | dotenv.config(); 3 | 4 | import { BootstrapConsole } from "nestjs-console"; 5 | import { AppModule } from "src/app.module"; 6 | import { SchedulerRegistry } from "@nestjs/schedule"; 7 | 8 | const bootstrap = new BootstrapConsole({ 9 | module: AppModule, 10 | useDecorators: true, 11 | contextOptions: { 12 | logger: true, 13 | }, 14 | }); 15 | 16 | bootstrap.init().then(async (app) => { 17 | try { 18 | await app.init(); 19 | const schedulerRegistry = app.get(SchedulerRegistry); 20 | const jobs = schedulerRegistry.getCronJobs(); 21 | jobs.forEach((_, jobId) => { 22 | schedulerRegistry.deleteCronJob(jobId); 23 | }); 24 | await bootstrap.boot(); 25 | await app.close(); 26 | } catch (e) { 27 | console.error(e); 28 | await app.close(); 29 | process.exit(1); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /src/modules/wallet/dto/transfer-money.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsOptional, IsString } from "class-validator"; 3 | import { Type } from "class-transformer"; 4 | 5 | export class TransferMoneyDto { 6 | @Type(() => Number) 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: "Amount want to transfer", 10 | required: true, 11 | example: 1, 12 | }) 13 | amount: number; 14 | 15 | @IsNotEmpty() 16 | @IsString() 17 | @ApiProperty({ 18 | description: "Email of user want to transfer", 19 | required: true, 20 | example: "worker1@gmail.com", 21 | }) 22 | receiveEmail: string; 23 | 24 | @IsString() 25 | @IsOptional() 26 | @ApiProperty({ 27 | description: "Some note want to sent to receiver", 28 | required: false, 29 | example: "I give you some money", 30 | }) 31 | note: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/configs/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionOptions } from "typeorm"; 2 | import * as dotenv from "dotenv"; 3 | dotenv.config({ 4 | path: process.env.NODE_ENV === "testing" ? ".env.test" : ".env", 5 | }); 6 | 7 | import { AppDataSource } from "src/configs/database.config"; 8 | 9 | const config: ConnectionOptions = { 10 | ...AppDataSource, 11 | database: 12 | process.env.NODE_ENV === "testing" 13 | ? "test_salary_hero" 14 | : AppDataSource.database, 15 | logger: "file", 16 | logging: true, 17 | migrationsTableName: "migrate_tables", 18 | synchronize: false, 19 | // Allow both start:prod and start:dev to use migrations 20 | // __dirname is either dist or src folder, meaning either 21 | // the compiled js in prod or the ts in dev. 22 | cli: { 23 | // to be compiled into dist/ folder. 24 | migrationsDir: "src/migrations", 25 | }, 26 | }; 27 | 28 | export = config; 29 | -------------------------------------------------------------------------------- /src/modules/authentication/auth.const.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from "@nestjs/common"; 2 | import { UserRole } from "src/models/entities/user.entity"; 3 | 4 | export enum AuthMessageFailed { 5 | UsernameOrPasswordIncorrect = "Username or password is incorrect.", 6 | UserHasRegister = "Username or email was taken.", 7 | InvalidCurrentPassword = "Invalid current password.", 8 | InvalidRefreshToken = "Invalid refresh token.", 9 | } 10 | 11 | export enum AuthMessageSuccess { 12 | LoginSuccessMessage = "Login successfully.", 13 | RegisterAccountSuccess = "Register account successfully.", 14 | ChangePasswordSuccess = "Change password successfully.", 15 | RefreshTokenSuccessfully = "Refresh token successfully.", 16 | } 17 | 18 | export const Public = () => SetMetadata(process.env.PUBLIC_KEY_JWT, true); 19 | export const Roles = (...roles: UserRole[]) => 20 | SetMetadata(process.env.ROLE_KEY_JWT, roles); 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # App config 2 | APP_NAME=Salary_Backend_API 3 | NODE_ENV=develop 4 | NODE_PORT=3000 5 | APP_PREFIX=/api/v1 6 | 7 | # Config schedule job 8 | # Calculate worker salary ofter 1 day 9 | CALCULATE_SALARY_AFTER_DAY=1 10 | DISABLE_SCHEDULE_JOB= 11 | 12 | # Config JWT 13 | PUBLIC_KEY_JWT=public@123 14 | ROLE_KEY_JWT=roleKey@123 15 | JWT_SECRET=JHtHGZWTYD3xewFC 16 | JWT_EXP=84600s 17 | JWT_REFRESH_EXP=604800s 18 | JWT_REFRESH_SECRET=AHtHGKKTYD3xewFC 19 | SALT_OR_ROUNDS=10 20 | 21 | # Database config 22 | DATABASE_LOG= 23 | DATABASE_TYPE=postgres 24 | DATABASE_HOST=localhost 25 | DATABASE_PORT=5432 26 | DATABASE_USER=postgres 27 | DATABASE_PASS=admin@123 28 | DATABASE_NAME=salary_hero 29 | 30 | # Redis config 31 | REDIS_HOST=localhost 32 | REDIS_PORT=6379 33 | REDIS_SERVER_PORT=3001 34 | REDIS_DEFAULT_TTL=86400 35 | 36 | # Kafka config 37 | KAFKA_PORT=9092 38 | KAFKA_HOST=127.0.0.1 39 | ZOOKEEPER_PORT=2181 40 | 41 | -------------------------------------------------------------------------------- /src/modules/wallet/dto/withdraw-money.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | import { Type } from "class-transformer"; 4 | 5 | export class WithdrawMoneyDto { 6 | @Type(() => Number) 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: "Amount want to withdraw", 10 | required: true, 11 | example: 1, 12 | }) 13 | amount: number; 14 | 15 | @IsNotEmpty() 16 | @IsString() 17 | @ApiProperty({ 18 | description: "Bank name or something for identify destination", 19 | required: true, 20 | example: "VIB", 21 | }) 22 | bankName: string; 23 | 24 | @IsString() 25 | @IsNotEmpty() 26 | @ApiProperty({ 27 | description: 28 | "Worker bank account number or something for identify destination", 29 | required: true, 30 | example: "005704060446574", 31 | }) 32 | bankAccountNumber: string; 33 | } 34 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # App config 2 | APP_NAME=Salary_Backend_API 3 | NODE_ENV=develop 4 | NODE_PORT=3000 5 | APP_PREFIX=/api/v1 6 | 7 | # Config schedule job 8 | # Calculate worker salary ofter 1 day 9 | CALCULATE_SALARY_AFTER_DAY=1 10 | DISABLE_SCHEDULE_JOB= 11 | 12 | # Config JWT 13 | PUBLIC_KEY_JWT=public@123 14 | ROLE_KEY_JWT=roleKey@123 15 | JWT_SECRET=JHtHGZWTYD3xewFC 16 | JWT_EXP=84600s 17 | JWT_REFRESH_EXP=604800s 18 | JWT_REFRESH_SECRET=AHtHGKKTYD3xewFC 19 | SALT_OR_ROUNDS=10 20 | 21 | # Database config 22 | DATABASE_LOG= 23 | DATABASE_TYPE=postgres 24 | DATABASE_HOST=salary_hero_postgres 25 | DATABASE_PORT=5432 26 | DATABASE_USER=postgres 27 | DATABASE_PASS=admin@123 28 | DATABASE_NAME=salary_hero 29 | 30 | # Redis config 31 | REDIS_HOST=salary_hero_redis 32 | REDIS_PORT=6379 33 | REDIS_SERVER_PORT=3001 34 | REDIS_DEFAULT_TTL=86400 35 | 36 | # Kafka config 37 | KAFKA_PORT=9092 38 | KAFKA_HOST=salary_hero_kafka 39 | ZOOKEEPER_PORT=2181 40 | 41 | -------------------------------------------------------------------------------- /src/modules/salary-calculation/salary-calculation.module.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModule } from "@nestjs/typeorm"; 2 | import { Logger, Module } from "@nestjs/common"; 3 | import { UserRepository } from "src/models/repositories/user.repository"; 4 | import { WorkerWalletRepository } from "src/models/repositories/worker_wallet.repository"; 5 | import { SalaryCalculationConsole } from "src/modules/salary-calculation/salary-calculation.console"; 6 | import { WorkerSalaryConfigRepository } from "src/models/repositories/worker_salary_config.repository"; 7 | import { WorkerSalaryHistoryRepository } from "src/models/repositories/worker_salary_history.repository"; 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forFeature([ 12 | UserRepository, 13 | WorkerWalletRepository, 14 | WorkerSalaryHistoryRepository, 15 | WorkerSalaryConfigRepository, 16 | ]), 17 | ], 18 | controllers: [], 19 | providers: [Logger, SalaryCalculationConsole], 20 | }) 21 | export class SalaryCalculationModule {} 22 | -------------------------------------------------------------------------------- /src/modules/partner-config/dto/config-worker-salary.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { ApiProperty } from "@nestjs/swagger"; 3 | import { IsEmail, IsNotEmpty, Min } from "class-validator"; 4 | 5 | export class ConfigWorkerSalaryDto { 6 | @IsEmail() 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: "Worker email that partner has been created", 10 | required: true, 11 | example: "exampleWorkerEmail@gmail.com", 12 | }) 13 | workerEmail: string; 14 | 15 | @Type(() => Number) 16 | @IsNotEmpty() 17 | @Min(1) 18 | @ApiProperty({ 19 | description: 20 | "Standard working day that partner want to specific for worker per month", 21 | required: true, 22 | example: 22, 23 | }) 24 | standardWorkingDay: number; 25 | 26 | @Type(() => Number) 27 | @IsNotEmpty() 28 | @Min(1) 29 | @ApiProperty({ 30 | description: "Worker salary that partner want to setup", 31 | required: true, 32 | example: 22, 33 | }) 34 | baseSalary: number; 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/worker-report/dto/get-salary-history.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsOptional, Max, Min } from "class-validator"; 3 | import { Type } from "class-transformer"; 4 | 5 | export class GetSalaryHistoryDto { 6 | @Type(() => Number) 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: "Page", 10 | required: true, 11 | example: 1, 12 | }) 13 | @Min(1) 14 | page: number; 15 | 16 | @Type(() => Number) 17 | @IsNotEmpty() 18 | @ApiProperty({ 19 | description: "Limit", 20 | required: false, 21 | example: 10, 22 | }) 23 | @Min(1) 24 | @Max(100) 25 | limit: number; 26 | 27 | @IsOptional() 28 | @ApiProperty({ 29 | description: "Get history from time", 30 | required: false, 31 | example: 1711090717808, 32 | }) 33 | fromTimestamp: number; 34 | 35 | @IsOptional() 36 | @ApiProperty({ 37 | description: "Get history less that time", 38 | required: false, 39 | example: 1711090917808, 40 | }) 41 | toTimestamp: number; 42 | } 43 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # NOTICE: This file use for run unit testing only 2 | # App config 3 | APP_NAME=Salary_Backend_API 4 | NODE_ENV=develop 5 | NODE_PORT=3000 6 | APP_PREFIX=/api/v1 7 | 8 | # Config job 9 | # Calculate worker salary ofter 1 day 10 | CALCULATE_SALARY_AFTER_DAY=1 11 | DISABLE_SCHEDULE_JOB=true 12 | 13 | # Config JWT 14 | PUBLIC_KEY_JWT=public@123 15 | ROLE_KEY_JWT=roleKey@123 16 | JWT_SECRET=JHtHGZWTYD3xewFC 17 | JWT_EXP=84600s 18 | JWT_REFRESH_EXP=604800s 19 | JWT_REFRESH_SECRET=AHtHGKKTYD3xewFC 20 | SALT_OR_ROUNDS=10 21 | 22 | # Database config 23 | DATABASE_LOG= 24 | DATABASE_TYPE=postgres 25 | DATABASE_HOST=localhost 26 | DATABASE_PORT=5432 27 | DATABASE_USER=postgres 28 | DATABASE_PASS=admin@123 29 | # With .env.test file, DATABASE_NAME associate with docker/init-database/init-test-database.sql 30 | DATABASE_NAME=test_salary_hero 31 | 32 | # Redis config 33 | REDIS_HOST=localhost 34 | REDIS_PORT=6379 35 | REDIS_SERVER_PORT=3001 36 | REDIS_DEFAULT_TTL=86400 37 | 38 | # Kafka config 39 | KAFKA_PORT=9092 40 | KAFKA_HOST=127.0.0.1 41 | ZOOKEEPER_PORT=2181 42 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ### Basic Flow 2 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-1.png?raw=true) 3 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-2.png?raw=true) 4 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-3.png?raw=true) 5 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-4.png?raw=true) 6 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-5.png?raw=true) 7 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-6.png?raw=true) 8 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-7.png?raw=true) 9 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/basic-step/step-8.png?raw=true) 10 | -------------------------------------------------------------------------------- /src/models/repositories/job.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, In, Repository } from "typeorm"; 2 | import { JobEntity, JobStatus, JobType } from "src/models/entities/job.entity"; 3 | 4 | @EntityRepository(JobEntity) 5 | export class JobRepository extends Repository { 6 | /** 7 | * @description: Get one job by key 8 | * @param date 9 | * @param key 10 | * @param status 11 | */ 12 | public async getJobByKeyAndStatus( 13 | date: number, 14 | key: string, 15 | status: string[] 16 | ): Promise { 17 | return await this.findOne({ 18 | where: { 19 | date: date, 20 | key: key, 21 | status: In(status), 22 | }, 23 | }); 24 | } 25 | 26 | /** 27 | * @description Get pending jobs by date and type 28 | * @param type 29 | */ 30 | public async getOnePendingJobByType(type: JobType): Promise { 31 | return await this.findOne({ 32 | where: { 33 | job_type: type, 34 | status: JobStatus.Pending, 35 | }, 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/worker-report/worker-report.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { JwtModule } from "@nestjs/jwt"; 3 | import { APP_GUARD } from "@nestjs/core"; 4 | import { TypeOrmModule } from "@nestjs/typeorm"; 5 | import { RolesGuard } from "src/guards/roles.guard"; 6 | import { WorkerReportService } from "src/modules/worker-report/worker-report.service"; 7 | import { WorkerReportController } from "src/modules/worker-report/worker-report.controller"; 8 | import { WorkerSalaryHistoryRepository } from "src/models/repositories/worker_salary_history.repository"; 9 | 10 | @Module({ 11 | imports: [ 12 | JwtModule.register({ 13 | secret: process.env.JWT_SECRET, 14 | signOptions: { expiresIn: process.env.JWT_EXP }, 15 | }), 16 | TypeOrmModule.forFeature([WorkerSalaryHistoryRepository]), 17 | ], 18 | controllers: [WorkerReportController], 19 | providers: [ 20 | WorkerReportService, 21 | { 22 | provide: APP_GUARD, 23 | useClass: RolesGuard, 24 | }, 25 | ], 26 | }) 27 | export class WorkerReportModule {} 28 | -------------------------------------------------------------------------------- /src/modules/authentication/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module } from "@nestjs/common"; 2 | import { JwtModule } from "@nestjs/jwt"; 3 | import { AuthController } from "src/modules/authentication/auth.controller"; 4 | import { AuthService } from "src/modules/authentication/auth.service"; 5 | import { TypeOrmModule } from "@nestjs/typeorm"; 6 | import { UserRepository } from "src/models/repositories/user.repository"; 7 | import { AuthConsole } from "src/modules/authentication/auth.console"; 8 | import { APP_GUARD } from "@nestjs/core"; 9 | import { RolesGuard } from "src/guards/roles.guard"; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([UserRepository]), 14 | JwtModule.register({ 15 | secret: process.env.JWT_SECRET, 16 | signOptions: { expiresIn: process.env.JWT_EXP }, 17 | }), 18 | ], 19 | controllers: [AuthController], 20 | providers: [ 21 | AuthService, 22 | AuthConsole, 23 | Logger, 24 | { 25 | provide: APP_GUARD, 26 | useClass: RolesGuard, 27 | }, 28 | ], 29 | }) 30 | export class AuthModule {} 31 | -------------------------------------------------------------------------------- /src/modules/partner-config/dto/partner-update-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator"; 3 | import { Type } from "class-transformer"; 4 | 5 | export class PartnerUpdateInfoDto { 6 | @IsString() 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: "Partner company name", 10 | required: true, 11 | example: "example company name", 12 | }) 13 | companyName: string; 14 | 15 | @IsString() 16 | @IsOptional() 17 | @ApiProperty({ 18 | description: "Partner company description", 19 | required: false, 20 | example: "example description", 21 | }) 22 | companyDescription: string; 23 | 24 | @Type(() => Number) 25 | @IsInt({ message: "Value must be an integer" }) 26 | @IsNotEmpty() 27 | @ApiProperty({ 28 | description: 29 | "Noted: Timezone save on backend must be in minutes.Company timezone -> Job calculate worker salary start from new day at 00:00:00 - 00:00:30 (server_timestamp + company timezone setup)", 30 | required: true, 31 | example: 420, 32 | }) 33 | timezone: number; 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 matthew-nguyen-20032023 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/modules.ts: -------------------------------------------------------------------------------- 1 | import { ScheduleModule } from "@nestjs/schedule"; 2 | import { Logger } from "@nestjs/common"; 3 | import { ConsoleModule } from "nestjs-console"; 4 | import { TypeOrmModule } from "@nestjs/typeorm"; 5 | import { TaskModule } from "src/tasks/task.module"; 6 | import { SeedingModule } from "src/seeding/seeding.module"; 7 | import { AppDataSource } from "src/configs/database.config"; 8 | import { WalletModule } from "src/modules/wallet/wallet.module"; 9 | import { AuthModule } from "src/modules/authentication/auth.module"; 10 | import { WorkerReportModule } from "src/modules/worker-report/worker-report.module"; 11 | import { PartnerConfigModule } from "src/modules/partner-config/partner-config.module"; 12 | import { SalaryCalculationModule } from "src/modules/salary-calculation/salary-calculation.module"; 13 | 14 | const Modules = [ 15 | Logger, 16 | AuthModule, 17 | PartnerConfigModule, 18 | SalaryCalculationModule, 19 | ConsoleModule, 20 | TaskModule, 21 | SeedingModule, 22 | WalletModule, 23 | WorkerReportModule, 24 | TypeOrmModule.forRoot(AppDataSource), 25 | ScheduleModule.forRoot(), 26 | ]; 27 | export default Modules; 28 | -------------------------------------------------------------------------------- /src/modules/wallet/wallet.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { JwtModule } from "@nestjs/jwt"; 3 | import { APP_GUARD } from "@nestjs/core"; 4 | import { TypeOrmModule } from "@nestjs/typeorm"; 5 | import { RolesGuard } from "src/guards/roles.guard"; 6 | import { WalletService } from "src/modules/wallet/wallet.service"; 7 | import { WalletController } from "src/modules/wallet/wallet.controller"; 8 | import { WorkerWalletRepository } from "src/models/repositories/worker_wallet.repository"; 9 | import { WorkerWalletHistoryEntityRepository } from "src/models/repositories/worker_wallet_history.repository"; 10 | 11 | @Module({ 12 | imports: [ 13 | JwtModule.register({ 14 | secret: process.env.JWT_SECRET, 15 | signOptions: { expiresIn: process.env.JWT_EXP }, 16 | }), 17 | TypeOrmModule.forFeature([ 18 | WorkerWalletRepository, 19 | WorkerWalletHistoryEntityRepository, 20 | ]), 21 | ], 22 | controllers: [WalletController], 23 | providers: [ 24 | WalletService, 25 | { 26 | provide: APP_GUARD, 27 | useClass: RolesGuard, 28 | }, 29 | ], 30 | }) 31 | export class WalletModule {} 32 | -------------------------------------------------------------------------------- /src/models/entities/job.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity({ name: "jobs" }) 4 | export class JobEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | date: number; 10 | 11 | @Column() 12 | key: string; 13 | 14 | @Column() 15 | job_type: JobType; 16 | 17 | @Column() 18 | status: JobStatus; 19 | 20 | @Column() 21 | note: string; 22 | 23 | @Column() 24 | created_at: number; 25 | 26 | @Column() 27 | updated_at: number; 28 | } 29 | 30 | export enum JobStatus { 31 | Pending = "pending", // Waiting for some condition to start 32 | Running = "running", // Current running 33 | Completed = "completed", 34 | Failed = "failed", 35 | } 36 | 37 | // Any new job need to define key here for another developer know 38 | export enum JobKey { 39 | // Need to add company info id and date in timestamp, ex: company_worker_salary_calculate_1_1710979200000 40 | // represent for company 1 at date: 2024-03-20 41 | CompanyWorkerSalaryJob = "company_worker_salary_calculate_", 42 | } 43 | 44 | export enum JobType { 45 | WorkerSalaryCalculate = "worker_salary_calculate", 46 | } 47 | -------------------------------------------------------------------------------- /src/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | HttpException, 4 | HttpStatus, 5 | Injectable, 6 | } from "@nestjs/common"; 7 | import { AuthGuard } from "@nestjs/passport"; 8 | import { Reflector } from "@nestjs/core"; 9 | 10 | @Injectable() 11 | export class JwtAuthGuard extends AuthGuard("jwt") { 12 | constructor(private reflector: Reflector) { 13 | super(); 14 | } 15 | 16 | canActivate(context: ExecutionContext) { 17 | const isPublic = this.reflector.getAllAndOverride( 18 | process.env.PUBLIC_KEY_JWT, 19 | [context.getHandler(), context.getClass()] 20 | ); 21 | if (isPublic) { 22 | return true; 23 | } 24 | // Add your custom authentication logic here 25 | // for example, call super.logIn(request) to establish a session. 26 | return super.canActivate(context); 27 | } 28 | 29 | handleRequest(err, user, info) { 30 | // You can throw an exception based on either "info" or "err" arguments 31 | if (err || !user) { 32 | throw new HttpException( 33 | { message: "Your session is expired. Please sign in" }, 34 | HttpStatus.UNAUTHORIZED 35 | ); 36 | } 37 | return user; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/authentication/auth.console.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | import { Injectable, Logger } from "@nestjs/common"; 3 | import { Command, Console } from "nestjs-console"; 4 | import { UserEntity, UserRole } from "src/models/entities/user.entity"; 5 | import { UserRepository } from "src/models/repositories/user.repository"; 6 | 7 | @Console() 8 | @Injectable() 9 | export class AuthConsole { 10 | constructor( 11 | private readonly userRepository: UserRepository, 12 | private readonly logger: Logger 13 | ) { 14 | this.logger.setContext(AuthConsole.name); 15 | } 16 | 17 | @Command({ 18 | command: "register-admin ", 19 | description: "Create admin account", 20 | }) 21 | async registerAdminAccount( 22 | username: string, 23 | email: string, 24 | password: string 25 | ): Promise { 26 | const salt = await bcrypt.genSalt(Number(process.env.SALT_OR_ROUNDS)); 27 | const newUser = new UserEntity(); 28 | newUser.username = username; 29 | newUser.email = email; 30 | newUser.password = await bcrypt.hash(password, salt); 31 | newUser.role = UserRole.Admin; 32 | const userCreated = await this.userRepository.save(newUser); 33 | this.logger.log("info", `User created successfully! ${userCreated}`); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; 2 | import { Reflector } from "@nestjs/core"; 3 | import { UserRole } from "src/models/entities/user.entity"; 4 | import { JwtService } from "@nestjs/jwt"; 5 | 6 | @Injectable() 7 | export class RolesGuard implements CanActivate { 8 | constructor( 9 | private reflector: Reflector, 10 | private readonly jwtService: JwtService 11 | ) {} 12 | 13 | async canActivate(context: ExecutionContext): Promise { 14 | const requiredRoles = this.reflector.getAllAndOverride( 15 | process.env.ROLE_KEY_JWT, 16 | [context.getHandler(), context.getClass()] 17 | ); 18 | if (!requiredRoles) { 19 | return true; 20 | } 21 | const requestContext = await context.switchToHttp().getRequest(); 22 | const user = requestContext.user; 23 | const jwt = requestContext.headers["authorization"]; 24 | 25 | if (!user && !jwt) return false; 26 | 27 | if (!user) { 28 | const token = jwt.split(" ")[1]; 29 | const verifiedToken = this.jwtService.verify(token); 30 | if (!verifiedToken) return false; 31 | return requiredRoles.some((role) => verifiedToken.role?.includes(role)); 32 | } 33 | 34 | return requiredRoles.some((role) => user.role?.includes(role)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/models/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, In, Repository } from "typeorm"; 2 | import { UserEntity } from "src/models/entities/user.entity"; 3 | 4 | @EntityRepository(UserEntity) 5 | export class UserRepository extends Repository { 6 | /** 7 | * @description function get user by username or email 8 | * @param identify 9 | */ 10 | public async getUserByUserNameOrEmail( 11 | identify: string[] 12 | ): Promise { 13 | return this.findOne({ 14 | where: [{ username: In(identify) }, { email: In(identify) }], 15 | }); 16 | } 17 | 18 | /** 19 | * @description get list user by created_by 20 | * @param createdUserId 21 | * @param page 22 | * @param limit 23 | */ 24 | public async getListUserByCreatedUserId( 25 | createdUserId: number, 26 | page: number, 27 | limit: number 28 | ): Promise { 29 | return await this.find({ 30 | where: { 31 | created_by: createdUserId, 32 | }, 33 | skip: (page - 1) * limit, 34 | take: limit, 35 | }); 36 | } 37 | 38 | /** 39 | * @description count user by created_by 40 | * @param createdUserId 41 | */ 42 | public async countUserByCreatedUserId( 43 | createdUserId: number 44 | ): Promise { 45 | return await this.count({ 46 | where: { created_by: createdUserId }, 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # This file for development only 2 | 3 | version: '3.1' 4 | 5 | services: 6 | # Database 7 | salary_hero_postgres: 8 | image: postgres 9 | container_name: salary_hero_postgres 10 | restart: always 11 | environment: 12 | POSTGRES_PASSWORD: $DATABASE_PASS 13 | POSTGRES_DB: $DATABASE_NAME 14 | ports: 15 | - "${DATABASE_PORT}:5432" 16 | volumes: 17 | - ./docker/init-database:/docker-entrypoint-initdb.d 18 | 19 | # Cache 20 | salary_hero_redis: 21 | image: redis:7.0.8 22 | container_name: salary_hero_redis 23 | restart: always 24 | ports: 25 | - "${REDIS_PORT}:6379" 26 | 27 | salary_hero_zookeeper: 28 | container_name: salary_hero_zookeeper 29 | image: 'bitnami/zookeeper:3.6.2' 30 | ports: 31 | - "${ZOOKEEPER_PORT}:2181" 32 | environment: 33 | - ALLOW_ANONYMOUS_LOGIN=yes 34 | 35 | # For Queue 36 | salary_hero_kafka: 37 | image: 'bitnami/kafka:2.6.0' 38 | container_name: salary_hero_kafka 39 | ports: 40 | - "${KAFKA_PORT}:9092" 41 | environment: 42 | - KAFKA_BROKER_ID=1 43 | - KAFKA_LISTENERS=PLAINTEXT://:${KAFKA_PORT} 44 | - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://${KAFKA_HOST}:${KAFKA_PORT} 45 | - KAFKA_ZOOKEEPER_CONNECT=salary_hero_zookeeper:${ZOOKEEPER_PORT} 46 | - ALLOW_PLAINTEXT_LISTENER=yes 47 | depends_on: 48 | - salary_hero_zookeeper 49 | -------------------------------------------------------------------------------- /src/modules/worker-report/worker-report.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { WorkerSalaryHistoryRepository } from "src/models/repositories/worker_salary_history.repository"; 3 | import { WorkerSalaryHistoryEntity } from "src/models/entities/worker_salary_history.entity"; 4 | 5 | @Injectable() 6 | export class WorkerReportService { 7 | constructor( 8 | public readonly workerSalaryHistoryRepository: WorkerSalaryHistoryRepository 9 | ) {} 10 | 11 | /** 12 | * @description Get worker salary history 13 | * @param workerEmail 14 | * @param page 15 | * @param limit 16 | * @param fromTimestamp 17 | * @param toTimestamp 18 | */ 19 | public async getWorkerSalary( 20 | workerEmail: string, 21 | page: number, 22 | limit: number, 23 | fromTimestamp: number, 24 | toTimestamp: number 25 | ): Promise<{ data: WorkerSalaryHistoryEntity[]; total: number }> { 26 | const history = 27 | await this.workerSalaryHistoryRepository.getWorkerSalaryHistory( 28 | workerEmail, 29 | page, 30 | limit, 31 | fromTimestamp, 32 | toTimestamp 33 | ); 34 | const total = 35 | await this.workerSalaryHistoryRepository.countTotalWorkerHistory( 36 | workerEmail, 37 | fromTimestamp, 38 | toTimestamp 39 | ); 40 | return { 41 | data: history, 42 | total, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/partner-config/partner-config.module.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModule } from "@nestjs/typeorm"; 2 | import { Module } from "@nestjs/common"; 3 | import { APP_GUARD } from "@nestjs/core"; 4 | import { RolesGuard } from "src/guards/roles.guard"; 5 | import { JwtModule } from "@nestjs/jwt"; 6 | import { UserRepository } from "src/models/repositories/user.repository"; 7 | import { CompanyInfoRepository } from "src/models/repositories/company_info.repository"; 8 | import { PartnerConfigService } from "src/modules/partner-config/partner-config.service"; 9 | import { WorkerWalletRepository } from "src/models/repositories/worker_wallet.repository"; 10 | import { PartnerConfigController } from "src/modules/partner-config/partner-config.controller"; 11 | import { WorkerSalaryConfigRepository } from "src/models/repositories/worker_salary_config.repository"; 12 | 13 | @Module({ 14 | imports: [ 15 | JwtModule.register({ 16 | secret: process.env.JWT_SECRET, 17 | signOptions: { expiresIn: process.env.JWT_EXP }, 18 | }), 19 | TypeOrmModule.forFeature([ 20 | CompanyInfoRepository, 21 | UserRepository, 22 | WorkerSalaryConfigRepository, 23 | WorkerWalletRepository, 24 | ]), 25 | ], 26 | controllers: [PartnerConfigController], 27 | providers: [ 28 | PartnerConfigService, 29 | { 30 | provide: APP_GUARD, 31 | useClass: RolesGuard, 32 | }, 33 | ], 34 | }) 35 | export class PartnerConfigModule {} 36 | -------------------------------------------------------------------------------- /src/models/repositories/worker_salary_config.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from "typeorm"; 2 | import { WorkerSalaryConfigEntity } from "src/models/entities/worker_salary_config.entity"; 3 | 4 | @EntityRepository(WorkerSalaryConfigEntity) 5 | export class WorkerSalaryConfigRepository extends Repository { 6 | /** 7 | * @description: Get current active worker config 8 | * @param workerEmail 9 | */ 10 | public async getActiveWorkerSalary( 11 | workerEmail: string 12 | ): Promise { 13 | return await this.findOne({ 14 | where: { 15 | user_email: workerEmail, 16 | is_active: true, 17 | }, 18 | }); 19 | } 20 | 21 | /** 22 | * @description Get list active worker config salary for specific company 23 | * @param companyId 24 | */ 25 | public async getListActiveByCompanyId( 26 | companyId: number 27 | ): Promise { 28 | return await this.find({ 29 | where: { 30 | company_id: companyId, 31 | is_active: true, 32 | }, 33 | }); 34 | } 35 | 36 | /** 37 | * @description Get worker config salary by id 38 | * @param workerConfigId 39 | */ 40 | public async getWorkerConfigById( 41 | workerConfigId: number 42 | ): Promise { 43 | return await this.findOne({ 44 | where: { 45 | id: workerConfigId, 46 | }, 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | workflow_call: 6 | 7 | jobs: 8 | test: 9 | runs-on: [ubuntu-latest] 10 | 11 | services: 12 | postgres: 13 | image: postgres 14 | env: 15 | POSTGRES_PASSWORD: admin@123 16 | POSTGRES_DB: test_salary_hero 17 | ports: 18 | - 5432:5432 19 | 20 | redis: 21 | image: redis:6-alpine 22 | ports: 23 | - 6379:6379 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version: '16.20.1' 30 | - name: Cache node modules 31 | id: cache-npm 32 | uses: actions/cache@v3 33 | env: 34 | cache-name: cache-node-modules 35 | with: 36 | path: ./node_modules 37 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-build-${{ env.cache-name }}- 40 | ${{ runner.os }}-build- 41 | ${{ runner.os }}- 42 | - name: Install yarn && Install dependencies 43 | run: npm install yarn -g && yarn && yarn list 44 | - name: Build APP 45 | run: yarn build 46 | - name: Setup env 47 | run: cp .env.test .env 48 | - name: Migration 49 | run: npm run typeorm:test 50 | - name: Echo env test 51 | run: cat .env.test 52 | - name: Echo env 53 | run: cat .env 54 | - name: Test 55 | run: yarn test 56 | -------------------------------------------------------------------------------- /src/configs/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | Logger, 7 | } from "@nestjs/common"; 8 | import { Response } from "express"; 9 | 10 | @Catch(Error) 11 | export class AllExceptionsFilter implements ExceptionFilter { 12 | private readonly logger = new Logger(AllExceptionsFilter.name); 13 | 14 | catch(exception: Error, host: ArgumentsHost) { 15 | const ctx = host.switchToHttp(); 16 | const response = ctx.getResponse(); 17 | 18 | let res: any; 19 | let statusCode: number; 20 | this.logger.error(exception); 21 | 22 | if (exception instanceof HttpException) { 23 | res = exception.getResponse(); 24 | statusCode = exception.getStatus(); 25 | 26 | if ( 27 | statusCode === 400 && 28 | typeof res?.message !== "string" && 29 | res?.message 30 | ) { 31 | res.message = res.message 32 | .map((errorMessage) => { 33 | return Object.values(errorMessage.constraints)[0].toString(); 34 | }) 35 | .toString(); 36 | } 37 | } else { 38 | res = 39 | process.env.NODE_ENV !== "production" 40 | ? exception.stack 41 | : "An error occurred. Please try again later."; 42 | statusCode = 500; 43 | 44 | if (typeof res === "string") { 45 | this.logger.error(res); 46 | res = { 47 | statusCode: statusCode, 48 | message: "An error occurred. Please try again later.", 49 | }; 50 | } 51 | } 52 | 53 | response.status(statusCode).json(res); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/worker-report/worker-report.controller.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from "src/models/entities/user.entity"; 2 | import { Controller, Get, HttpStatus, Post, Query } from "@nestjs/common"; 3 | import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; 4 | import { Roles } from "src/modules/authentication/auth.const"; 5 | import { UserEmail } from "src/decorators/user-email.decorator"; 6 | import { IResponseToClient } from "src/configs/response-to-client.config"; 7 | import { WorkerReportService } from "src/modules/worker-report/worker-report.service"; 8 | import { GetSalaryHistoryDto } from "src/modules/worker-report/dto/get-salary-history.dto"; 9 | import { WorkerReportMessageSuccess } from "src/modules/worker-report/worker-report.const"; 10 | 11 | @Controller("worker-report") 12 | @ApiTags("Worker Report") 13 | @ApiBearerAuth() 14 | @Roles(UserRole.Worker) 15 | export class WorkerReportController { 16 | constructor(private readonly workerReportService: WorkerReportService) {} 17 | 18 | @Get("salary-history") 19 | @ApiOperation({ 20 | summary: "[Worker] Api to worker for get their salary history.", 21 | }) 22 | async getSalaryHistory( 23 | @UserEmail() userEmail: string, 24 | @Query() getSalaryHistoryDto: GetSalaryHistoryDto 25 | ): Promise { 26 | const data = await this.workerReportService.getWorkerSalary( 27 | userEmail, 28 | getSalaryHistoryDto.page, 29 | getSalaryHistoryDto.limit, 30 | getSalaryHistoryDto.fromTimestamp, 31 | getSalaryHistoryDto.toTimestamp 32 | ); 33 | return { 34 | message: WorkerReportMessageSuccess.ListSalaryHistorySuccess, 35 | data, 36 | statusCode: HttpStatus.OK, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/models/repositories/company_info.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, In, Repository } from "typeorm"; 2 | import { CompanyInfoEntity } from "src/models/entities/company_info.entity"; 3 | 4 | @EntityRepository(CompanyInfoEntity) 5 | export class CompanyInfoRepository extends Repository { 6 | /** 7 | * @description: Each company belong to one partner user, so we support find company info by user id 8 | * @param userEmail 9 | */ 10 | public async getCompanyInfoByUserEmail( 11 | userEmail: string 12 | ): Promise { 13 | return await this.findOne({ 14 | where: { 15 | user_email: userEmail, 16 | }, 17 | }); 18 | } 19 | 20 | // TODO: Need to refactor if there more than 100k of companies => set job and checkpoint 21 | public async getAllCompany(): Promise { 22 | return await this.find(); 23 | } 24 | 25 | /** 26 | * @description Get all company in timezones 27 | * With this logic, best case is select one timezone will reduce 27 times (because we have 27 timezone from -12 UTC to 14 UCT, 28 | * so instead of fetching 100k company in all timezone we just need to fetch and select ~ 3700 companies 29 | * And the worse case still just need to fetch and select ~ 7400 companies, which is perfect 30 | * @param timezones 31 | */ 32 | // TODO: Need to refactor if there more than 1 Million of companies => set job and checkpoint 33 | public async getAllCompanyWithTimezones( 34 | timezones: number[] 35 | ): Promise { 36 | return await this.find({ 37 | where: { 38 | timezone: In(timezones), 39 | }, 40 | order: { 41 | timezone: "ASC", 42 | }, 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/shares/common.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | }; 4 | 5 | export function generateRandomUsername(): string { 6 | const usernameLength = Math.floor(Math.random() * 10) + 5; 7 | const characters = "abcdefghijklmnopqrstuvwxyz0123456789"; 8 | let username = ""; 9 | 10 | // Generate random username 11 | for (let i = 0; i < usernameLength; i++) { 12 | username += characters.charAt( 13 | Math.floor(Math.random() * characters.length) 14 | ); 15 | } 16 | return username; 17 | } 18 | 19 | export function generateRandomEmail(): string { 20 | const domainLength = Math.floor(Math.random() * 5) + 5; // Random length between 5 and 9 21 | 22 | const characters = "abcdefghijklmnopqrstuvwxyz0123456789"; 23 | let username = ""; 24 | let domain = ""; 25 | 26 | // Generate random username 27 | username = generateRandomUsername(); 28 | 29 | // Generate random domain 30 | for (let i = 0; i < domainLength; i++) { 31 | domain += characters.charAt(Math.floor(Math.random() * characters.length)); 32 | } 33 | 34 | return `${username}@${domain}.com`; 35 | } 36 | 37 | export function generateRandomString(): string { 38 | const domainLength = Math.floor(Math.random() * 5) + 5; // Random length between 5 and 9 39 | 40 | const characters = "abcdefghijklmnopqrstuvwxyz0123456789"; 41 | let string = ""; 42 | // Generate random domain 43 | for (let i = 0; i < domainLength; i++) { 44 | string += characters.charAt(Math.floor(Math.random() * characters.length)); 45 | } 46 | 47 | return string; 48 | } 49 | 50 | // Random from 0 -> 24 51 | export function randomHour(): number { 52 | return Math.floor(Math.random() * 24); 53 | } 54 | 55 | // Random from 0 -> 60 56 | export function randomMinute(): number { 57 | return Math.floor(Math.random() * 60); 58 | } 59 | -------------------------------------------------------------------------------- /src/migrations/1711008548306-worker_wallet.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from "typeorm"; 2 | 3 | export class workerWallet1711008548306 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: "worker_wallet", 8 | columns: [ 9 | { 10 | name: "id", 11 | type: "int", 12 | isPrimary: true, 13 | isGenerated: true, 14 | generationStrategy: "increment", 15 | }, 16 | { 17 | name: "worker_email", 18 | type: "varchar", 19 | length: "255", 20 | isUnique: true, 21 | }, 22 | { 23 | name: "available_balance", 24 | type: "float", 25 | default: 0, 26 | comment: "available balance so that worker can withdraw", 27 | }, 28 | { 29 | name: "pending_balance", 30 | type: "float", 31 | default: 0, 32 | comment: 33 | "Pending balance so that worker need to wait until next calculation to available. This is used when company want to update again yesterday worker salary", 34 | }, 35 | { 36 | name: "is_active", 37 | type: "boolean", 38 | default: true, 39 | comment: "Used when want to block worker wallet", 40 | }, 41 | { 42 | name: "created_at", 43 | type: "timestamp", 44 | default: "CURRENT_TIMESTAMP", 45 | isNullable: false, 46 | }, 47 | { 48 | name: "updated_at", 49 | type: "timestamp", 50 | default: "CURRENT_TIMESTAMP", 51 | onUpdate: "CURRENT_TIMESTAMP", 52 | isNullable: false, 53 | }, 54 | ], 55 | }), 56 | true 57 | ); 58 | } 59 | 60 | public async down(queryRunner: QueryRunner): Promise {} 61 | } 62 | -------------------------------------------------------------------------------- /src/migrations/1710859679683-users.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from "typeorm"; 2 | 3 | export class users1710859679683 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: "users", 8 | columns: [ 9 | { 10 | name: "id", 11 | type: "int", 12 | isPrimary: true, 13 | isGenerated: true, 14 | generationStrategy: "increment", 15 | }, 16 | { 17 | name: "username", 18 | type: "varchar", 19 | length: "255", 20 | isUnique: true, 21 | isNullable: false, 22 | }, 23 | { 24 | name: "email", 25 | type: "varchar", 26 | length: "255", 27 | isUnique: true, 28 | isNullable: false, 29 | }, 30 | { 31 | name: "password", 32 | type: "varchar", 33 | length: "255", 34 | isNullable: false, 35 | }, 36 | { 37 | name: "role", 38 | type: "varchar", 39 | length: "255", 40 | isNullable: false, 41 | }, 42 | { 43 | name: "created_by", 44 | type: "int", 45 | isNullable: true, 46 | comment: 47 | "User created by another user id, so another user will have permission to config salary", 48 | }, 49 | { 50 | name: "created_at", 51 | type: "timestamp", 52 | default: "CURRENT_TIMESTAMP", 53 | isNullable: false, 54 | }, 55 | { 56 | name: "updated_at", 57 | type: "timestamp", 58 | default: "CURRENT_TIMESTAMP", 59 | onUpdate: "CURRENT_TIMESTAMP", 60 | isNullable: false, 61 | }, 62 | ], 63 | }), 64 | true 65 | ); 66 | } 67 | 68 | public async down(queryRunner: QueryRunner): Promise {} 69 | } 70 | -------------------------------------------------------------------------------- /src/migrations/1710870445190-companies_info.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from "typeorm"; 2 | 3 | export class companiesInfo1710870445190 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: "companies_info", 8 | columns: [ 9 | { 10 | name: "id", 11 | type: "int", 12 | isPrimary: true, 13 | isGenerated: true, 14 | generationStrategy: "increment", 15 | comment: 16 | "Partner must update their company info to register their worker!", 17 | }, 18 | { 19 | name: "user_email", 20 | type: "varchar", 21 | length: "255", 22 | isNullable: false, 23 | isUnique: true, 24 | comment: 25 | "Each company info will belong to one user, so it good for searching by user email", 26 | }, 27 | { 28 | name: "company_name", 29 | type: "varchar", 30 | length: "255", 31 | isNullable: false, 32 | }, 33 | { 34 | name: "company_description", 35 | type: "varchar", 36 | length: "500", 37 | isNullable: true, 38 | }, 39 | { 40 | name: "timezone", 41 | type: "int", 42 | default: 0, 43 | isNullable: false, 44 | comment: 45 | "Job calculate worker salary start from new day at 00:00:00 - 00:00:30 (server_timestamp + company timezone setup)", 46 | }, 47 | { 48 | name: "created_at", 49 | type: "timestamp", 50 | default: "CURRENT_TIMESTAMP", 51 | isNullable: false, 52 | }, 53 | { 54 | name: "updated_at", 55 | type: "timestamp", 56 | default: "CURRENT_TIMESTAMP", 57 | onUpdate: "CURRENT_TIMESTAMP", 58 | isNullable: false, 59 | }, 60 | ], 61 | }), 62 | true 63 | ); 64 | } 65 | 66 | public async down(queryRunner: QueryRunner): Promise {} 67 | } 68 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | dotenv.config(); 3 | 4 | import { SchedulerRegistry } from "@nestjs/schedule"; 5 | import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; 6 | import { NestExpressApplication } from "@nestjs/platform-express"; 7 | import { NestFactory } from "@nestjs/core"; 8 | import { AppModule } from "src/app.module"; 9 | import { AllExceptionsFilter } from "src/configs/all-exceptions.filter"; 10 | import { 11 | BadRequestException, 12 | HttpStatus, 13 | ValidationPipe, 14 | } from "@nestjs/common"; 15 | 16 | async function bootstrap() { 17 | const app = await NestFactory.create(AppModule, { 18 | cors: true, 19 | }); 20 | 21 | app.enableCors(); 22 | app.setGlobalPrefix(process.env.APP_PREFIX); 23 | 24 | app.useGlobalPipes( 25 | new ValidationPipe({ 26 | whitelist: true, 27 | errorHttpStatusCode: HttpStatus.BAD_REQUEST, 28 | transform: true, 29 | exceptionFactory: (errors) => new BadRequestException(errors), 30 | }) 31 | ); 32 | 33 | app.useGlobalFilters(new AllExceptionsFilter()); 34 | 35 | if (process.env.NODE_ENV !== "production") { 36 | const openAPIConfig = new DocumentBuilder() 37 | .setTitle(`${process.env.APP_NAME} API`) 38 | .setDescription(`This api document only available for development only!`) 39 | .setVersion("1.0") 40 | .addBearerAuth({ 41 | type: "http", 42 | scheme: "bearer", 43 | bearerFormat: "JWT", 44 | name: "Authorization", 45 | description: "Enter JWT token", 46 | in: "header", 47 | }) 48 | .build(); 49 | 50 | const document = SwaggerModule.createDocument(app, openAPIConfig); 51 | SwaggerModule.setup("api/docs", app, document); 52 | } 53 | 54 | await app.listen(process.env.NODE_PORT); 55 | 56 | if (process.env.DISABLE_SCHEDULE_JOB) { 57 | // disable when DISABLE_SCHEDULE_JOB 58 | const schedulerRegistry = app.get(SchedulerRegistry); 59 | const jobs = schedulerRegistry.getCronJobs(); 60 | jobs.forEach((_, jobId) => { 61 | schedulerRegistry.deleteCronJob(jobId); 62 | }); 63 | } 64 | 65 | console.log( 66 | `[${process.env.APP_NAME}]: `, 67 | `SERVICE BACKEND RUNNING ON PORT ${process.env.NODE_PORT}` 68 | ); 69 | } 70 | bootstrap(); 71 | -------------------------------------------------------------------------------- /src/migrations/1711009720195-worker_wallet_histories.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; 2 | 3 | export class workerWalletHistories1711009720195 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: "worker_wallet_histories", 8 | columns: [ 9 | { 10 | name: "id", 11 | type: "int", 12 | isPrimary: true, 13 | isGenerated: true, 14 | generationStrategy: "increment", 15 | }, 16 | { 17 | name: "date", 18 | type: "bigint", 19 | isNullable: false, 20 | comment: 21 | "Date of action but in timestamp for multiple region support", 22 | }, 23 | { 24 | name: "worker_email", 25 | type: "varchar", 26 | length: "255", 27 | isNullable: false, 28 | }, 29 | { 30 | name: "action_type", 31 | type: "varchar", 32 | length: "10", 33 | isNullable: false, 34 | comment: "Can be withdraw, transfer, receive,...", 35 | }, 36 | { 37 | name: "amount", 38 | type: "int", 39 | default: 0, 40 | comment: "amount of money", 41 | }, 42 | { 43 | name: "note", 44 | type: "varchar", 45 | length: "255", 46 | isNullable: true, 47 | comment: "Some note if worker want to", 48 | }, 49 | { 50 | name: "created_at", 51 | type: "timestamp", 52 | default: "CURRENT_TIMESTAMP", 53 | isNullable: false, 54 | }, 55 | { 56 | name: "updated_at", 57 | type: "timestamp", 58 | default: "CURRENT_TIMESTAMP", 59 | onUpdate: "CURRENT_TIMESTAMP", 60 | isNullable: false, 61 | }, 62 | ], 63 | }), 64 | true 65 | ); 66 | await queryRunner.createIndices("worker_wallet_histories", [ 67 | new TableIndex({ 68 | columnNames: ["worker_email", "date"], 69 | isUnique: false, 70 | }), 71 | ]); 72 | } 73 | 74 | public async down(queryRunner: QueryRunner): Promise {} 75 | } 76 | -------------------------------------------------------------------------------- /src/migrations/1711014723466-jobs.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; 2 | 3 | export class jobs1711014723466 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: "jobs", 8 | columns: [ 9 | { 10 | name: "id", 11 | type: "int", 12 | isPrimary: true, 13 | isGenerated: true, 14 | generationStrategy: "increment", 15 | }, 16 | { 17 | name: "date", 18 | type: "bigint", 19 | isNullable: false, 20 | comment: 21 | "Date in timestamp UTC +0, hours, minute and second will be set to 0", 22 | }, 23 | { 24 | name: "key", 25 | type: "varchar", 26 | length: "255", 27 | isNullable: false, 28 | comment: "Job key, can be any value as we want", 29 | }, 30 | { 31 | name: "job_type", 32 | type: "varchar", 33 | length: "255", 34 | isNullable: false, 35 | comment: 36 | "Job type, can be calculate_worker_salary, init_job_calculate_worker_salary", 37 | }, 38 | { 39 | name: "status", 40 | type: "varchar", 41 | length: "10", 42 | isNullable: false, 43 | }, 44 | { 45 | name: "note", 46 | type: "varchar", 47 | length: "255", 48 | isNullable: true, 49 | comment: "Some note if worker want to", 50 | }, 51 | { 52 | name: "created_at", 53 | type: "timestamp", 54 | default: "CURRENT_TIMESTAMP", 55 | isNullable: false, 56 | }, 57 | { 58 | name: "updated_at", 59 | type: "timestamp", 60 | default: "CURRENT_TIMESTAMP", 61 | onUpdate: "CURRENT_TIMESTAMP", 62 | isNullable: false, 63 | }, 64 | ], 65 | }), 66 | true 67 | ); 68 | await queryRunner.createIndices("jobs", [ 69 | new TableIndex({ 70 | columnNames: ["date"], 71 | isUnique: false, 72 | }), 73 | ]); 74 | } 75 | 76 | public async down(queryRunner: QueryRunner): Promise {} 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/wallet/wallet.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, HttpStatus, Post } from "@nestjs/common"; 2 | import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; 3 | import { UserRole } from "src/models/entities/user.entity"; 4 | import { Roles } from "src/modules/authentication/auth.const"; 5 | import { UserEmail } from "src/decorators/user-email.decorator"; 6 | import { WalletService } from "src/modules/wallet/wallet.service"; 7 | import { WalletMessageSuccess } from "src/modules/wallet/wallet.const"; 8 | import { IResponseToClient } from "src/configs/response-to-client.config"; 9 | import { WithdrawMoneyDto } from "src/modules/wallet/dto/withdraw-money.dto"; 10 | import { TransferMoneyDto } from "src/modules/wallet/dto/transfer-money.dto"; 11 | 12 | @Controller("wallet") 13 | @ApiTags("Wallet") 14 | @ApiBearerAuth() 15 | @Roles(UserRole.Worker) 16 | export class WalletController { 17 | constructor(private readonly walletService: WalletService) {} 18 | 19 | @Get("get-wallet") 20 | @ApiOperation({ 21 | summary: "[Worker] Api to worker for get wallet.", 22 | }) 23 | async getWallet(@UserEmail() userEmail: string): Promise { 24 | const data = await this.walletService.getWorkerWallet(userEmail); 25 | return { 26 | message: WalletMessageSuccess.GetWalletSuccess, 27 | data, 28 | statusCode: HttpStatus.OK, 29 | }; 30 | } 31 | 32 | @Post("transfer-money") 33 | @ApiOperation({ 34 | summary: "[Worker] Api to worker for transfer money to another wallet.", 35 | }) 36 | async transferMoney( 37 | @UserEmail() userEmail: string, 38 | @Body() transferMoneyDto: TransferMoneyDto 39 | ): Promise { 40 | const data = await this.walletService.transferMoney( 41 | userEmail, 42 | transferMoneyDto.receiveEmail, 43 | transferMoneyDto.amount, 44 | transferMoneyDto.note 45 | ); 46 | return { 47 | message: WalletMessageSuccess.GetWalletSuccess, 48 | data, 49 | statusCode: HttpStatus.OK, 50 | }; 51 | } 52 | 53 | @Post("withdraw-money") 54 | @ApiOperation({ 55 | summary: "[Worker] Api to worker for withdraw money.", 56 | }) 57 | async withDrawMoney( 58 | @UserEmail() workerEmail: string, 59 | @Body() withdrawMoneyDto: WithdrawMoneyDto 60 | ): Promise { 61 | const data = await this.walletService.withdraw( 62 | workerEmail, 63 | withdrawMoneyDto.bankName, 64 | withdrawMoneyDto.bankAccountNumber, 65 | withdrawMoneyDto.amount 66 | ); 67 | return { 68 | message: WalletMessageSuccess.GetWalletSuccess, 69 | data, 70 | statusCode: HttpStatus.OK, 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/migrations/1710863933870-worker_salary_configs.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; 2 | 3 | export class workerSalaryConfigs1710863933870 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: "worker_salary_configs", 8 | columns: [ 9 | { 10 | name: "id", 11 | type: "int", 12 | isPrimary: true, 13 | isGenerated: true, 14 | generationStrategy: "increment", 15 | comment: 16 | "Partner must create worker salary config for their employee, so that their salary will be calculated!", 17 | }, 18 | { 19 | name: "company_id", 20 | type: "int", 21 | isNullable: false, 22 | comment: "Refer to company id on companies_info table", 23 | }, 24 | { 25 | name: "user_email", 26 | type: "varchar", 27 | isNullable: false, 28 | comment: "Refer to email on users table", 29 | }, 30 | { 31 | name: "standard_working_day", 32 | type: "int", 33 | isNullable: false, 34 | comment: 35 | "Partners (companies) can set their working days per month for specific worker instead of hard code for 30 days.", 36 | }, 37 | { 38 | name: "base_salary", 39 | type: "int", 40 | isNullable: false, 41 | comment: "Partners can also set base salary for their worker", 42 | }, 43 | { 44 | name: "is_active", 45 | type: "boolean", 46 | default: true, 47 | isNullable: false, 48 | comment: "Only active config will be used to calculate salary", 49 | }, 50 | { 51 | name: "created_by", 52 | type: "int", 53 | isNullable: false, 54 | comment: "Identify who is manager of this config or this worker", 55 | }, 56 | { 57 | name: "created_at", 58 | type: "timestamp", 59 | default: "CURRENT_TIMESTAMP", 60 | isNullable: false, 61 | }, 62 | { 63 | name: "updated_at", 64 | type: "timestamp", 65 | default: "CURRENT_TIMESTAMP", 66 | onUpdate: "CURRENT_TIMESTAMP", 67 | isNullable: false, 68 | }, 69 | ], 70 | }), 71 | true 72 | ); 73 | await queryRunner.createIndices("worker_salary_configs", [ 74 | new TableIndex({ 75 | columnNames: ["company_id"], 76 | isUnique: false, 77 | }), 78 | ]); 79 | } 80 | 81 | public async down(queryRunner: QueryRunner): Promise {} 82 | } 83 | -------------------------------------------------------------------------------- /src/tasks/init-salary-worker-job.task.ts: -------------------------------------------------------------------------------- 1 | import { _ } from "lodash"; 2 | import { Producer } from "kafkajs"; 3 | import { Injectable, Logger } from "@nestjs/common"; 4 | import { Cron, CronExpression } from "@nestjs/schedule"; 5 | import { KafkaConfig, KafkaTopic } from "src/configs/kafka.config"; 6 | import { JobRepository } from "src/models/repositories/job.repository"; 7 | import { JobStatus, JobType } from "src/models/entities/job.entity"; 8 | import { WorkerSalaryConfigRepository } from "src/models/repositories/worker_salary_config.repository"; 9 | 10 | /** 11 | * @description Determine company salary job need to handle and push message to kafka for calculate salary for specific worker 12 | */ 13 | @Injectable() 14 | export class InitSalaryWorkerJobTask { 15 | private readonly logger = new Logger(InitSalaryWorkerJobTask.name); 16 | private kafkaProducer: Producer; 17 | 18 | constructor( 19 | public readonly jobRepository: JobRepository, 20 | public readonly workerSalaryConfigRepository: WorkerSalaryConfigRepository 21 | ) {} 22 | 23 | @Cron(CronExpression.EVERY_30_SECONDS) 24 | public async handleCron() { 25 | const pendingJob = await this.jobRepository.getOnePendingJobByType( 26 | JobType.WorkerSalaryCalculate 27 | ); 28 | 29 | if (!pendingJob) { 30 | this.logger.log( 31 | `No pending job found for type of ${JobType.WorkerSalaryCalculate}. Waiting for new one created...` 32 | ); 33 | return; 34 | } 35 | 36 | // key like company_worker_salary_calculate_1_1710979200000 37 | // represent for jobName_companyId_datetime, so I'm using lodash to get companyId 38 | const companyId = _.nth(pendingJob.key.split("_"), -2); 39 | const datetime = _.last(pendingJob.key.split("_")); 40 | const listWorkerSalaryConfig = 41 | await this.workerSalaryConfigRepository.getListActiveByCompanyId( 42 | companyId 43 | ); 44 | 45 | // No config found, so set job to completed 46 | if (listWorkerSalaryConfig.length === 0) { 47 | this.logger.log("There is no worker config salary for company."); 48 | pendingJob.status = JobStatus.Completed; 49 | pendingJob.note = `There is no worker config salary for company`; 50 | await this.jobRepository.save(pendingJob); 51 | return; 52 | } 53 | 54 | this.kafkaProducer = KafkaConfig.producer(); 55 | await this.kafkaProducer.connect(); 56 | 57 | for (const workerConfigSalary of listWorkerSalaryConfig) { 58 | const messageSend = JSON.stringify({ 59 | datetime, 60 | workerConfigSalary, 61 | }); 62 | await this.kafkaProducer.send({ 63 | topic: KafkaTopic.CalculateDailyWorkerSalary, 64 | messages: [{ value: messageSend }], 65 | }); 66 | this.logger.log( 67 | `Kafka message send to calculate salary for worker ${ 68 | workerConfigSalary.user_email 69 | } at ${new Date(datetime)}` 70 | ); 71 | } 72 | 73 | pendingJob.status = JobStatus.Completed; 74 | pendingJob.note = `Kafka message send to calculate salary for all worker of company at server time ${new Date()}`; 75 | await this.jobRepository.save(pendingJob); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/migrations/1711007153515-worker_salary_histories.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; 2 | 3 | export class workerSalaryHistories1711007153515 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: "worker_salary_histories", 8 | columns: [ 9 | { 10 | name: "id", 11 | type: "int", 12 | isPrimary: true, 13 | isGenerated: true, 14 | generationStrategy: "increment", 15 | }, 16 | { 17 | name: "date", 18 | type: "bigint", 19 | isNullable: false, 20 | comment: 21 | "Date of calculation but in timestamp for multiple region support", 22 | }, 23 | { 24 | name: "worker_email", 25 | type: "varchar", 26 | length: "255", 27 | isNullable: false, 28 | comment: "Worker email", 29 | }, 30 | { 31 | name: "daily_income", 32 | type: "float", 33 | isNullable: false, 34 | comment: 35 | "Represent for how much income worker earn in specific day", 36 | }, 37 | { 38 | name: "total_income", 39 | type: "float", 40 | isNullable: false, 41 | comment: 42 | "Represent total income of workers from oldest til this current. Formula is today total income = yesterday total income + today daily income", 43 | }, 44 | { 45 | name: "worker_salary_config_id", 46 | type: "int", 47 | isNullable: false, 48 | comment: 49 | "Config salary that used to calculate worker salary at this moment", 50 | }, 51 | { 52 | name: "is_active", 53 | type: "boolean", 54 | isNullable: false, 55 | default: true, 56 | comment: "Each worker only have one active salary per day", 57 | }, 58 | { 59 | name: "note", 60 | type: "varchar", 61 | length: "255", 62 | isNullable: true, 63 | comment: 64 | "Some note if we want to when we de-active and active new one. ex: new salary updated from record id 5", 65 | }, 66 | { 67 | name: "created_at", 68 | type: "timestamp", 69 | default: "CURRENT_TIMESTAMP", 70 | isNullable: false, 71 | }, 72 | { 73 | name: "updated_at", 74 | type: "timestamp", 75 | default: "CURRENT_TIMESTAMP", 76 | onUpdate: "CURRENT_TIMESTAMP", 77 | isNullable: false, 78 | }, 79 | ], 80 | }), 81 | true 82 | ); 83 | await queryRunner.createIndices("worker_salary_histories", [ 84 | new TableIndex({ 85 | columnNames: ["worker_email", "date"], 86 | isUnique: false, 87 | }), 88 | new TableIndex({ 89 | columnNames: ["date"], 90 | isUnique: false, 91 | }), 92 | ]); 93 | } 94 | 95 | public async down(queryRunner: QueryRunner): Promise {} 96 | } 97 | -------------------------------------------------------------------------------- /src/models/repositories/worker_salary_history.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Between, 3 | EntityRepository, 4 | LessThan, 5 | LessThanOrEqual, 6 | MoreThanOrEqual, 7 | Repository, 8 | } from "typeorm"; 9 | import { WorkerSalaryHistoryEntity } from "src/models/entities/worker_salary_history.entity"; 10 | 11 | @EntityRepository(WorkerSalaryHistoryEntity) 12 | export class WorkerSalaryHistoryRepository extends Repository { 13 | /** 14 | * @description Get worker salary history by date 15 | * @param datetime 16 | * @param workerEmail 17 | */ 18 | public async getWorkerSalaryHistoryByDate( 19 | datetime: number, 20 | workerEmail: string 21 | ): Promise { 22 | return await this.findOne({ 23 | where: { 24 | date: datetime, 25 | worker_email: workerEmail, 26 | is_active: true, 27 | }, 28 | }); 29 | } 30 | 31 | /** 32 | * @description: return latest previous salary have datetime < datetime input 33 | * @param datetime 34 | * @param workerEmail 35 | */ 36 | public async getPreviousHistoryByDate( 37 | datetime: number, 38 | workerEmail: string 39 | ): Promise { 40 | return await this.findOne({ 41 | where: { 42 | date: LessThan(datetime), 43 | worker_email: workerEmail, 44 | is_active: true, 45 | }, 46 | order: { 47 | id: "DESC", 48 | }, 49 | }); 50 | } 51 | 52 | /** 53 | * @description Get worker salary history by worker email 54 | * @param workerEmail 55 | * @param page 56 | * @param limit 57 | * @param fromTimestamp 58 | * @param toTimestamp 59 | */ 60 | public async getWorkerSalaryHistory( 61 | workerEmail: string, 62 | page: number, 63 | limit: number, 64 | fromTimestamp: number, 65 | toTimestamp: number 66 | ): Promise { 67 | const condition = { 68 | worker_email: workerEmail, 69 | }; 70 | if (fromTimestamp && toTimestamp) { 71 | condition["date"] = Between(fromTimestamp, toTimestamp); 72 | } else { 73 | if (fromTimestamp) condition["date"] = MoreThanOrEqual(fromTimestamp); 74 | if (toTimestamp) condition["date"] = LessThanOrEqual(toTimestamp); 75 | } 76 | return await this.find({ 77 | where: condition, 78 | skip: (page - 1) * limit, 79 | take: limit, 80 | }); 81 | } 82 | 83 | /** 84 | * @description Count total worker salary history by email 85 | * @param workerEmail 86 | * @param fromTimestamp 87 | * @param toTimestamp 88 | */ 89 | public async countTotalWorkerHistory( 90 | workerEmail: string, 91 | fromTimestamp: number, 92 | toTimestamp: number 93 | ): Promise { 94 | const condition = { 95 | worker_email: workerEmail, 96 | }; 97 | if (fromTimestamp && toTimestamp) { 98 | condition["date"] = Between(fromTimestamp, toTimestamp); 99 | } else { 100 | if (fromTimestamp) condition["date"] = MoreThanOrEqual(fromTimestamp); 101 | if (toTimestamp) condition["date"] = LessThanOrEqual(toTimestamp); 102 | } 103 | 104 | return await this.count({ 105 | where: condition, 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /test/e2e/partner-config/partner-config.e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from "@nestjs/testing"; 2 | import { HttpStatus, INestApplication } from "@nestjs/common"; 3 | import Modules from "src/modules"; 4 | import * as request from "supertest"; 5 | import { PartnerUpdateInfoDto } from "src/modules/partner-config/dto/partner-update-info.dto"; 6 | import { JwtService } from "@nestjs/jwt"; 7 | import { UserRole } from "src/models/entities/user.entity"; 8 | 9 | describe("Partner Config Service", () => { 10 | let app: INestApplication; 11 | let jwtService: JwtService; 12 | let partnerAccessToken: string; 13 | let workerAccessToken: string; 14 | let adminAccessToken: string; 15 | 16 | beforeAll(async () => { 17 | const moduleRef = await Test.createTestingModule({ 18 | imports: [...Modules], 19 | controllers: [], 20 | providers: [], 21 | }).compile(); 22 | 23 | app = moduleRef.createNestApplication(); 24 | jwtService = moduleRef.get(JwtService); 25 | 26 | partnerAccessToken = jwtService.sign({ 27 | id: 1, 28 | role: UserRole.Partner, 29 | email: "mockPartnerEmail@gmail.com", 30 | }); 31 | workerAccessToken = jwtService.sign({ 32 | id: 2, 33 | role: UserRole.Worker, 34 | email: "mockWorkerEmail@gmail.com", 35 | }); 36 | adminAccessToken = jwtService.sign({ 37 | id: 3, 38 | role: UserRole.Admin, 39 | email: "mockAdminEmail@gmail.com", 40 | }); 41 | await app.init(); 42 | }); 43 | 44 | describe("Test Update Company Info", () => { 45 | const mockPartnerUpdateInfoDto = new PartnerUpdateInfoDto(); 46 | mockPartnerUpdateInfoDto.companyName = "mock company name"; 47 | mockPartnerUpdateInfoDto.companyDescription = "mock description"; 48 | mockPartnerUpdateInfoDto.timezone = 1500; 49 | 50 | it("Should be forbidden because not login", async () => { 51 | const response = await request(app.getHttpServer()) 52 | .put("/partner-config/update-info") 53 | .send(mockPartnerUpdateInfoDto); 54 | expect(response.status).toBe(HttpStatus.FORBIDDEN); 55 | }); 56 | 57 | it("Should be forbidden because worker has no permission to access this api", async () => { 58 | const response = await request(app.getHttpServer()) 59 | .put("/partner-config/update-info") 60 | .set("Authorization", `Bearer ${workerAccessToken}`) 61 | .send(mockPartnerUpdateInfoDto); 62 | expect(response.status).toBe(HttpStatus.FORBIDDEN); 63 | }); 64 | 65 | it("Should be forbidden because admin has no permission to access this api", async () => { 66 | const response = await request(app.getHttpServer()) 67 | .put("/partner-config/update-info") 68 | .set("Authorization", `Bearer ${adminAccessToken}`) 69 | .send(mockPartnerUpdateInfoDto); 70 | expect(response.status).toBe(HttpStatus.FORBIDDEN); 71 | }); 72 | 73 | it("Should be created because partner has permission to access this api", async () => { 74 | const response = await request(app.getHttpServer()) 75 | .put("/partner-config/update-info") 76 | .set("Authorization", `Bearer ${partnerAccessToken}`) 77 | .send(mockPartnerUpdateInfoDto); 78 | expect(response.status).toBe(HttpStatus.OK); 79 | }); 80 | }); 81 | 82 | afterAll(async () => { 83 | await app.close(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 | # Salary Hero Backend 9 | 10 | ## Description 11 | 12 | Salary payment project support partner register, config and manage their worker account, 13 | their worker salary and formula. From that, worker salary will be calculated 14 | automatically every day, every month and worker can manage 15 | their money. 16 | 17 | ## Design 18 | ### Basic High Level Design Overview 19 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/overview-system/high-level-design.png?raw=true) 20 | ### Low Level Design With AWS Cloud 21 | ![alt text](https://github.com/matthew-nguyen-20032023/salary-hero-backend/blob/dev/docs/images/overview-system/low-level-design-with-aws-cloud.png?raw=true) 22 | 23 | ## Required 24 | Node version v16.20.1
25 | Yarn version 1.22.19
26 | Docker version 20.10.21, build 20.10.21-0ubuntu1~22.04.3
27 | docker-compose version 1.29.2, build unknown
28 | PM2 version 5.3.0 => Only for Quick Start 29 | ## Quick Start Or Manual Setup Guide Below 30 | ```bash 31 | # Make sure you have full required above 32 | # Important: make sure that list port here available on your machine 33 | # List port: 9092, 6379, 5432, 2181, 3000, 3001 34 | # Or you can change value from .env.example for another port 35 | $ sudo chmod -R 777 ./quick_start.sh 36 | $ ./quick_start.sh 37 | ``` 38 | 39 | ## Manual Setup 40 | ### Setup components 41 | ```bash 42 | # Preparing 43 | $ yarn # install lib dependencies 44 | $ cp .env.example .env # init .env file (change information if you want to) 45 | $ docker-compose up -d # init services component (depend on .env file, noted new docker version run docker compose up -d) 46 | $ yarn build # build migration file to migrate 47 | $ npm run typeorm:run # migrate database schema 48 | $ npm run typeorm:test # migrate testing database schema 49 | $ yarn console:dev seeding-data # seeding data for develop 50 | ``` 51 | ### Run Backend 52 | ```bash 53 | # For development run 54 | $ yarn start:dev 55 | #-----------------------------# 56 | # Or for production run 57 | $ yarn build 58 | $ yarn start:prod 59 | ``` 60 | ### Background Job Handle worker salary calculation 61 | ```bash 62 | # For development run 63 | $ yarn console:dev calculate-worker-salary 64 | #-----------------------------# 65 | # For production run 66 | $ node dist/src/console.js calculate-worker-salary 67 | ``` 68 | 69 | ### Note 70 | After full setup and seeding data, we have some account seeded
71 | AdminAccount: admin@gmail.com
72 | PartnerAccount: partner@gmail.com
73 | WorkerAccount: worker@gmail.com
74 | Password for all: admin@123
75 | API Swagger: http://localhost:3000/api/docs/ 76 | 77 | ### Testing 78 | ```bash 79 | $ yarn test 80 | ``` 81 | 82 | ## Some Feature Can Implement In The Future 83 | - OTP to withdraw or transfer money for worker 84 | 85 | ## Repository Activity 86 | ![Alt](https://repobeats.axiom.co/api/embed/1929095ae8b4fb2d5d5dbc561ad4e906db6dd2b7.svg "Repobeats analytics image") 87 | ## License 88 | 89 | Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salary_hero", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Salary Backend API", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "nest build", 9 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 10 | "start": "nest start", 11 | "start:dev": "nest start --watch", 12 | "start:debug": "nest start --debug --watch", 13 | "start:prod": "node dist/main", 14 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json", 20 | "console:dev": "ts-node -r tsconfig-paths/register src/console.ts", 21 | "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config src/configs/ormconfig.ts", 22 | "typeorm:migrate": "npm run typeorm migration:generate -- -n", 23 | "typeorm:run": "npm run typeorm migration:run", 24 | "typeorm:revert": "npm run typeorm migration:revert", 25 | "typeorm:create": "npm run typeorm migration:create -- -n", 26 | "typeorm:test": "NODE_ENV=testing npm run typeorm migration:run" 27 | }, 28 | "dependencies": { 29 | "@nestjs-modules/mailer": "1.6.0", 30 | "@nestjs/bull": "0.3.1", 31 | "@nestjs/cache-manager": "2.1.0", 32 | "@nestjs/common": "7.6.15", 33 | "@nestjs/config": "0.6.3", 34 | "@nestjs/core": "7.6.15", 35 | "@nestjs/jwt": "7.2.0", 36 | "@nestjs/microservices": "7.6.16", 37 | "@nestjs/passport": "7.1.5", 38 | "@nestjs/platform-express": "7.6.15", 39 | "@nestjs/schedule": "0.4.3", 40 | "@nestjs/swagger": "4.8.0", 41 | "@nestjs/typeorm": "7.1.5", 42 | "bcrypt": "5.0.1", 43 | "bull": "3.22.7", 44 | "cache-manager": "3.4.3", 45 | "cache-manager-redis-store": "2.0.0", 46 | "class-transformer": "0.4.0", 47 | "class-validator": "0.13.1", 48 | "commander": "7.2.0", 49 | "config": "3.3.6", 50 | "cross-var": "1.1.0", 51 | "dotenv": "10.0.0", 52 | "jest": "26.6.3", 53 | "jsonwebtoken": "8.5.1", 54 | "jwt-decode": "3.1.2", 55 | "kafkajs": "2.2.4", 56 | "lodash": "4.17.21", 57 | "moment": "2.29.1", 58 | "nest-queue": "1.0.3", 59 | "nestjs-console": "5.0.1", 60 | "nodemailer": "6.6.1", 61 | "passport": "0.4.1", 62 | "passport-jwt": "4.0.0", 63 | "passport-local": "1.0.0", 64 | "pg": "8.11.3", 65 | "prettier": "2.3.2", 66 | "redis": "3.1.2", 67 | "reflect-metadata": "0.1.13", 68 | "request-ip": "2.1.3", 69 | "rxjs": "6.6.7", 70 | "swagger-ui-express": "4.1.6", 71 | "tslib": "2.2.0", 72 | "typeorm": "0.2.33" 73 | }, 74 | "devDependencies": { 75 | "@nestjs/cli": "9.0.0", 76 | "@nestjs/schematics": "9.0.0", 77 | "@nestjs/testing": "7.6.15", 78 | "@types/express": "4.17.11", 79 | "@types/jest": "26.0.23", 80 | "@types/node": "18.0.3", 81 | "@types/supertest": "2.0.10", 82 | "@typescript-eslint/eslint-plugin": "5.30.5", 83 | "@typescript-eslint/parser": "5.30.5", 84 | "eslint": "8.19.0", 85 | "eslint-config-prettier": "8.5.0", 86 | "eslint-plugin-prettier": "4.2.1", 87 | "jest": "26.6.3", 88 | "source-map-support": "0.5.21", 89 | "supertest": "6.1.6", 90 | "ts-jest": "26.5.4", 91 | "ts-loader": "9.3.1", 92 | "ts-node": "10.8.2", 93 | "tsconfig-paths": "4.0.0", 94 | "typescript": "4.7.4" 95 | }, 96 | "jest": { 97 | "moduleNameMapper": { 98 | "^src/(.*)$": "/$1" 99 | }, 100 | "moduleFileExtensions": [ 101 | "js", 102 | "json", 103 | "ts" 104 | ], 105 | "rootDir": "src", 106 | "testRegex": ".*\\.spec\\.ts$", 107 | "transform": { 108 | "^.+\\.(t|j)s$": "ts-jest" 109 | }, 110 | "collectCoverageFrom": [ 111 | "**/*.(t|j)s" 112 | ], 113 | "coverageDirectory": "../coverage", 114 | "testEnvironment": "node" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/modules/authentication/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; 2 | import { Body, Controller, Get, HttpStatus, Post, Put } from "@nestjs/common"; 3 | import { 4 | AuthMessageSuccess, 5 | Public, 6 | Roles, 7 | } from "src/modules/authentication/auth.const"; 8 | import { UserId } from "src/decorators/user-id.decorator"; 9 | import { UserRole } from "src/models/entities/user.entity"; 10 | import { UserEmail } from "src/decorators/user-email.decorator"; 11 | import { LoginDto } from "src/modules/authentication/dto/login.dto"; 12 | import { AuthService } from "src/modules/authentication/auth.service"; 13 | import { IResponseToClient } from "src/configs/response-to-client.config"; 14 | import { RegisterDto } from "src/modules/authentication/dto/register.dto"; 15 | import { ChangePasswordDto } from "src/modules/authentication/dto/change-password.dto"; 16 | import { RefreshTokenDto } from "./dto/refresh-token.dto"; 17 | 18 | @Controller("auth") 19 | @ApiTags("Authentication") 20 | export class AuthController { 21 | constructor(private readonly authService: AuthService) {} 22 | 23 | @ApiBearerAuth() 24 | @Post("register-for-partner") 25 | @ApiOperation({ 26 | summary: "[Admin] Api to Salary Hero use to register partner account", 27 | }) 28 | @Roles(UserRole.Admin) 29 | async registerForPartner( 30 | @UserId() userId: number, 31 | @Body() registerDto: RegisterDto 32 | ): Promise { 33 | const data = await this.authService.register( 34 | registerDto.userName, 35 | registerDto.email, 36 | registerDto.password, 37 | UserRole.Partner, 38 | userId 39 | ); 40 | return { 41 | message: AuthMessageSuccess.RegisterAccountSuccess, 42 | data, 43 | statusCode: HttpStatus.CREATED, 44 | }; 45 | } 46 | 47 | @ApiBearerAuth() 48 | @Post("register-for-worker") 49 | @ApiOperation({ 50 | summary: "[Partner] Api to partner use to register their worker account", 51 | }) 52 | @Roles(UserRole.Partner) 53 | async registerForWorker( 54 | @UserId() userId: number, 55 | @Body() registerDto: RegisterDto 56 | ): Promise { 57 | const data = await this.authService.register( 58 | registerDto.userName, 59 | registerDto.email, 60 | registerDto.password, 61 | UserRole.Worker, 62 | userId 63 | ); 64 | return { 65 | message: AuthMessageSuccess.RegisterAccountSuccess, 66 | data, 67 | statusCode: HttpStatus.CREATED, 68 | }; 69 | } 70 | 71 | @Post("login") 72 | @ApiOperation({ 73 | summary: "[Public] Api for all type of user to login", 74 | }) 75 | @Public() 76 | async login(@Body() loginDto: LoginDto): Promise { 77 | const data = await this.authService.login( 78 | loginDto.identify, 79 | loginDto.password 80 | ); 81 | return { 82 | message: AuthMessageSuccess.LoginSuccessMessage, 83 | data, 84 | statusCode: HttpStatus.OK, 85 | }; 86 | } 87 | 88 | @ApiBearerAuth() 89 | @Put("change-password") 90 | @ApiOperation({ 91 | summary: "[ALL] Api for all type of user login to change their password", 92 | }) 93 | async changePassword( 94 | @UserEmail() userEmail: string, 95 | @Body() changePasswordDto: ChangePasswordDto 96 | ): Promise { 97 | const data = await this.authService.changePassword( 98 | userEmail, 99 | changePasswordDto.currentPassword, 100 | changePasswordDto.newPassword 101 | ); 102 | return { 103 | message: AuthMessageSuccess.ChangePasswordSuccess, 104 | data, 105 | statusCode: HttpStatus.OK, 106 | }; 107 | } 108 | 109 | @Post("refresh-token") 110 | @ApiOperation({ 111 | summary: 112 | "[ALL] Api to get new token from refresh token, so that user dont need to login again when their token expire", 113 | }) 114 | @Public() 115 | async refreshToken( 116 | @Body() refreshTokenDto: RefreshTokenDto 117 | ): Promise { 118 | const data = await this.authService.refreshToken( 119 | refreshTokenDto.refreshToken 120 | ); 121 | return { 122 | message: AuthMessageSuccess.RefreshTokenSuccessfully, 123 | data, 124 | statusCode: HttpStatus.CREATED, 125 | }; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/seeding/seeding.console.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from "@nestjs/common"; 2 | import { Command, Console } from "nestjs-console"; 3 | import { UserEntity, UserRole } from "src/models/entities/user.entity"; 4 | import { UserRepository } from "src/models/repositories/user.repository"; 5 | import { CompanyInfoEntity } from "src/models/entities/company_info.entity"; 6 | import { CompanyInfoRepository } from "src/models/repositories/company_info.repository"; 7 | import { WorkerSalaryConfigEntity } from "src/models/entities/worker_salary_config.entity"; 8 | import { WorkerSalaryConfigRepository } from "src/models/repositories/worker_salary_config.repository"; 9 | 10 | @Console() 11 | @Injectable() 12 | export class SeedingConsole { 13 | constructor( 14 | private readonly userRepository: UserRepository, 15 | private readonly companyInfoRepository: CompanyInfoRepository, 16 | private readonly workerSalaryConfigRepository: WorkerSalaryConfigRepository, 17 | private readonly logger: Logger 18 | ) { 19 | this.logger.setContext(SeedingConsole.name); 20 | } 21 | 22 | @Command({ 23 | command: "seeding-data", 24 | description: "Seeding data for develop", 25 | }) 26 | async seedingData(): Promise { 27 | const admin = await this.seedingAdminUser(); 28 | const partner = await this.seedingPartnerUser(admin.id); 29 | const worker = await this.seedingWorkerUser(partner.id); 30 | const companyInfo = await this.seedingCompanyInfo(partner); 31 | await this.seedingWorkerConfigSalary(worker, partner, companyInfo); 32 | } 33 | 34 | private async seedingAdminUser(): Promise { 35 | const newAdmin = new UserEntity(); 36 | newAdmin.email = "admin@gmail.com"; 37 | newAdmin.username = "admin"; 38 | newAdmin.password = 39 | "$2b$10$aOTx8rtNMc88s1Zcx4J/ROAiLjLz3d5AVGzFkfsXcY6rouNqkIUF2"; //pwd is admin@123 40 | newAdmin.role = UserRole.Admin; 41 | const adminCreated = await this.userRepository.save(newAdmin); 42 | this.logger.log( 43 | `Admin seeded with username is ${newAdmin.username} and pwd is admin@123` 44 | ); 45 | return adminCreated; 46 | } 47 | private async seedingPartnerUser( 48 | createdByUserId: number 49 | ): Promise { 50 | const newPartner = new UserEntity(); 51 | newPartner.email = "partner@gmail.com"; 52 | newPartner.username = "partner"; 53 | newPartner.password = 54 | "$2b$10$aOTx8rtNMc88s1Zcx4J/ROAiLjLz3d5AVGzFkfsXcY6rouNqkIUF2"; //pwd is admin@123 55 | newPartner.role = UserRole.Partner; 56 | newPartner.created_by = createdByUserId; 57 | const partnerCreated = await this.userRepository.save(newPartner); 58 | this.logger.log( 59 | `Partner seeded with username is ${newPartner.username} and pwd is admin@123` 60 | ); 61 | return partnerCreated; 62 | } 63 | 64 | private async seedingWorkerUser( 65 | createdByUserId: number 66 | ): Promise { 67 | const newWorker = new UserEntity(); 68 | newWorker.email = "worker@gmail.com"; 69 | newWorker.username = "worker"; 70 | newWorker.password = 71 | "$2b$10$aOTx8rtNMc88s1Zcx4J/ROAiLjLz3d5AVGzFkfsXcY6rouNqkIUF2"; //pwd is admin@123 72 | newWorker.role = UserRole.Worker; 73 | newWorker.created_by = createdByUserId; 74 | const workerCreated = await this.userRepository.save(newWorker); 75 | this.logger.log( 76 | `Worker seeded with username is ${workerCreated.username} and pwd is admin@123` 77 | ); 78 | return workerCreated; 79 | } 80 | 81 | private async seedingCompanyInfo( 82 | partnerAccount: UserEntity 83 | ): Promise { 84 | const newCompanyInfo = new CompanyInfoEntity(); 85 | newCompanyInfo.company_name = partnerAccount.username; 86 | newCompanyInfo.company_description = partnerAccount.email; 87 | newCompanyInfo.user_email = partnerAccount.email; 88 | newCompanyInfo.timezone = 7; 89 | const companyInfoCreated = await this.companyInfoRepository.save( 90 | newCompanyInfo 91 | ); 92 | this.logger.log(`Company seeded for partner email ${partnerAccount.email}`); 93 | return companyInfoCreated; 94 | } 95 | 96 | private async seedingWorkerConfigSalary( 97 | workerAccount: UserEntity, 98 | partnerAccount: UserEntity, 99 | companyInfo: CompanyInfoEntity 100 | ): Promise { 101 | const newWorkerSalaryConfig = new WorkerSalaryConfigEntity(); 102 | newWorkerSalaryConfig.user_email = workerAccount.email; 103 | newWorkerSalaryConfig.base_salary = 3000; 104 | newWorkerSalaryConfig.standard_working_day = 26; 105 | newWorkerSalaryConfig.is_active = true; 106 | newWorkerSalaryConfig.company_id = companyInfo.id; 107 | newWorkerSalaryConfig.created_by = partnerAccount.id; 108 | const workerSalaryCreated = await this.workerSalaryConfigRepository.save( 109 | newWorkerSalaryConfig 110 | ); 111 | this.logger.log( 112 | `new salary config seeded for worker email ${workerAccount.email}` 113 | ); 114 | return workerSalaryCreated; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/modules/authentication/auth.service.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | import { JwtService } from "@nestjs/jwt"; 3 | import { HttpException, HttpStatus, Injectable } from "@nestjs/common"; 4 | import { UserEntity, UserRole } from "src/models/entities/user.entity"; 5 | import { UserRepository } from "src/models/repositories/user.repository"; 6 | import { AuthMessageFailed } from "src/modules/authentication/auth.const"; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor( 11 | public readonly userRepository: UserRepository, 12 | public jwtService: JwtService 13 | ) {} 14 | 15 | /** 16 | * @description: Can be used for register for partner or worker as well 17 | * @param userName 18 | * @param email 19 | * @param password 20 | * @param role 21 | * @param createdUserId 22 | */ 23 | public async register( 24 | userName: string, 25 | email: string, 26 | password: string, 27 | role: UserRole, 28 | createdUserId: number 29 | ): Promise { 30 | const existUser = await this.userRepository.getUserByUserNameOrEmail([ 31 | userName, 32 | email, 33 | ]); 34 | 35 | if (existUser) { 36 | throw new HttpException( 37 | { message: AuthMessageFailed.UserHasRegister }, 38 | HttpStatus.BAD_REQUEST 39 | ); 40 | } 41 | 42 | const salt = await bcrypt.genSalt(Number(process.env.SALT_OR_ROUNDS)); 43 | const hashedPassword = await bcrypt.hash(password, salt); 44 | const newUser = new UserEntity(); 45 | newUser.username = userName; 46 | newUser.password = hashedPassword; 47 | newUser.email = email; 48 | newUser.role = role; 49 | newUser.created_by = createdUserId; 50 | // TODO: Can add more logic such as add column code and status so that user have to verify their email to active account 51 | return await this.userRepository.save(newUser); 52 | } 53 | 54 | /** 55 | * @description: Can be used to log in by any type of user 56 | * @param identify can be username or email as well 57 | * @param password 58 | */ 59 | // TODO: Implement captcha for login failed after 5 times 60 | // TODO: Implement lock account after 10 times failed login for secure 61 | public async login( 62 | identify: string, 63 | password: string 64 | ): Promise<{ access_token: string; refresh_token: string }> { 65 | const user = await this.userRepository.getUserByUserNameOrEmail([identify]); 66 | if (!user) { 67 | throw new HttpException( 68 | { 69 | message: AuthMessageFailed.UsernameOrPasswordIncorrect, 70 | }, 71 | HttpStatus.BAD_REQUEST 72 | ); 73 | } 74 | 75 | const isPasswordMatch = await bcrypt.compare(password, user.password); 76 | if (!isPasswordMatch) 77 | throw new HttpException( 78 | { 79 | message: AuthMessageFailed.UsernameOrPasswordIncorrect, 80 | }, 81 | HttpStatus.BAD_REQUEST 82 | ); 83 | 84 | const payload = { 85 | id: user.id, 86 | email: user.email, 87 | role: user.role, 88 | }; 89 | return { 90 | access_token: await this.jwtService.signAsync(payload), 91 | refresh_token: await this.jwtService.signAsync(payload, { 92 | expiresIn: process.env.JWT_REFRESH_EXP, 93 | secret: process.env.JWT_REFRESH_SECRET, 94 | }), 95 | }; 96 | } 97 | 98 | /** 99 | * @description: Api to change password 100 | * @param userEmail 101 | * @param currentPassword 102 | * @param newPassword 103 | */ 104 | // TODO: Need to implement password policy for strong and safe password 105 | public async changePassword( 106 | userEmail: string, 107 | currentPassword: string, 108 | newPassword: string 109 | ): Promise { 110 | const user = await this.userRepository.getUserByUserNameOrEmail([ 111 | userEmail, 112 | ]); 113 | const isPasswordMatch = await bcrypt.compare( 114 | currentPassword, 115 | user.password 116 | ); 117 | if (!isPasswordMatch) { 118 | throw new HttpException( 119 | { 120 | message: AuthMessageFailed.InvalidCurrentPassword, 121 | }, 122 | HttpStatus.BAD_REQUEST 123 | ); 124 | } 125 | 126 | const salt = await bcrypt.genSalt(Number(process.env.SALT_OR_ROUNDS)); 127 | user.password = await bcrypt.hash(newPassword, salt); 128 | await this.userRepository.save(user); 129 | return true; 130 | } 131 | 132 | /** 133 | * @description Get new account token from refresh token 134 | * @param refreshToken 135 | */ 136 | async refreshToken(refreshToken: string): Promise<{ access_token: string }> { 137 | try { 138 | const refreshTokenDecode = await this.jwtService.verifyAsync( 139 | refreshToken, 140 | { 141 | secret: process.env.JWT_REFRESH_SECRET, 142 | } 143 | ); 144 | return { 145 | access_token: await this.jwtService.signAsync({ 146 | id: refreshTokenDecode.id, 147 | email: refreshTokenDecode.email, 148 | role: refreshTokenDecode.role, 149 | }), 150 | }; 151 | } catch (error) { 152 | throw new HttpException( 153 | { message: AuthMessageFailed.InvalidRefreshToken }, 154 | HttpStatus.BAD_REQUEST 155 | ); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/modules/partner-config/partner-config.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpStatus, 7 | Post, 8 | Put, 9 | Query, 10 | } from "@nestjs/common"; 11 | import { UserId } from "src/decorators/user-id.decorator"; 12 | import { UserRole } from "src/models/entities/user.entity"; 13 | import { Roles } from "src/modules/authentication/auth.const"; 14 | import { UserEmail } from "src/decorators/user-email.decorator"; 15 | import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; 16 | import { IResponseToClient } from "src/configs/response-to-client.config"; 17 | import { LockUnLockAction } from "src/models/entities/worker_wallet.entity"; 18 | import { ListWorkerDto } from "src/modules/partner-config/dto/list-worker.dto"; 19 | import { PartnerMessageSuccess } from "src/modules/partner-config/partner-config.const"; 20 | import { PartnerConfigService } from "src/modules/partner-config/partner-config.service"; 21 | import { PartnerUpdateInfoDto } from "src/modules/partner-config/dto/partner-update-info.dto"; 22 | import { ConfigWorkerSalaryDto } from "src/modules/partner-config/dto/config-worker-salary.dto"; 23 | import { LockOrUnLockWorkerWalletDto } from "src/modules/partner-config/dto/lock-or-unlock-worker-wallet.dto"; 24 | import { DeActiveWorkerSalaryConfigDto } from "src/modules/partner-config/dto/de-active-worker-salary-config.dto"; 25 | 26 | @Controller("partner-config") 27 | @ApiTags("Partner") 28 | @ApiBearerAuth() 29 | @Roles(UserRole.Partner) 30 | export class PartnerConfigController { 31 | constructor(private readonly partnerService: PartnerConfigService) {} 32 | 33 | @Put("update-info") 34 | @ApiOperation({ 35 | summary: 36 | "[Partner] Api to partner for update their information. Noted that partner must update their information to register their worker.", 37 | }) 38 | async updateCompanyInfo( 39 | @Body() partnerUpdateInfoDto: PartnerUpdateInfoDto, 40 | @UserEmail() userEmail: string 41 | ): Promise { 42 | const data = await this.partnerService.updateCompanyInfo( 43 | userEmail, 44 | partnerUpdateInfoDto.companyName, 45 | partnerUpdateInfoDto.companyDescription, 46 | partnerUpdateInfoDto.timezone 47 | ); 48 | return { 49 | message: PartnerMessageSuccess.UpdateCompanyInfoSuccess, 50 | data, 51 | statusCode: HttpStatus.OK, 52 | }; 53 | } 54 | 55 | @Get("list-worker") 56 | @ApiOperation({ 57 | summary: "[Partner] Api to partner for listing their worker.", 58 | }) 59 | async listWorker( 60 | @UserId() userId: number, 61 | @Query() listWorkerDto: ListWorkerDto 62 | ): Promise { 63 | const data = await this.partnerService.listWorkerBeLongToPartner( 64 | userId, 65 | listWorkerDto.page, 66 | listWorkerDto.limit 67 | ); 68 | return { 69 | message: PartnerMessageSuccess.ListWorkerSuccess, 70 | data, 71 | statusCode: HttpStatus.CREATED, 72 | }; 73 | } 74 | 75 | @Post("worker-salary") 76 | @ApiOperation({ 77 | summary: 78 | "[Partner] Api to partner for config their worker salary. (will de-active the old config if exist)", 79 | }) 80 | async workerSalary( 81 | @UserId() partnerId: number, 82 | @UserEmail() partnerEmail: string, 83 | @Body() configWorkerSalaryDto: ConfigWorkerSalaryDto 84 | ): Promise { 85 | const data = await this.partnerService.configWorkerSalary( 86 | partnerId, 87 | partnerEmail, 88 | configWorkerSalaryDto.workerEmail, 89 | configWorkerSalaryDto.standardWorkingDay, 90 | configWorkerSalaryDto.baseSalary 91 | ); 92 | return { 93 | message: PartnerMessageSuccess.ConfigWorkerSalarySuccess, 94 | data, 95 | statusCode: HttpStatus.CREATED, 96 | }; 97 | } 98 | 99 | @Delete("de-active-worker-salary-config") 100 | @ApiOperation({ 101 | summary: 102 | "[Partner] Api to partner for de-active their worker salary config", 103 | }) 104 | async deActiveWorkerSalary( 105 | @UserId() partnerId: number, 106 | @Body() deActiveWorkerSalaryConfigDto: DeActiveWorkerSalaryConfigDto 107 | ): Promise { 108 | const data = await this.partnerService.deActiveWorkerSalaryConfig( 109 | partnerId, 110 | deActiveWorkerSalaryConfigDto.workerSalaryConfigId 111 | ); 112 | return { 113 | message: PartnerMessageSuccess.ConfigWorkerSalarySuccess, 114 | data, 115 | statusCode: HttpStatus.CREATED, 116 | }; 117 | } 118 | 119 | @Put("lock-or-unlock-worker-wallet") 120 | @ApiOperation({ 121 | summary: "[Partner] Api to partner for lock or unlock their worker wallet", 122 | }) 123 | async lockWorkerWallet( 124 | @UserId() partnerId: number, 125 | @Body() lockOrUnLockWorkerWalletDto: LockOrUnLockWorkerWalletDto 126 | ): Promise { 127 | const data = await this.partnerService.lockOrUnlockWorkerWallet( 128 | partnerId, 129 | lockOrUnLockWorkerWalletDto.workerEmail, 130 | lockOrUnLockWorkerWalletDto.action 131 | ); 132 | return { 133 | message: 134 | lockOrUnLockWorkerWalletDto.action === LockUnLockAction.Unlock 135 | ? PartnerMessageSuccess.UnlockWalletSuccess 136 | : PartnerMessageSuccess.LockWalletSuccess, 137 | data, 138 | statusCode: HttpStatus.CREATED, 139 | }; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/modules/salary-calculation/salary-calculation.console.ts: -------------------------------------------------------------------------------- 1 | import { Consumer } from "kafkajs"; 2 | import { getConnection } from "typeorm"; 3 | import { Command, Console } from "nestjs-console"; 4 | import { Injectable, Logger } from "@nestjs/common"; 5 | import { KafkaConfig, KafkaTopic } from "src/configs/kafka.config"; 6 | import { UserRepository } from "src/models/repositories/user.repository"; 7 | import { WorkerWalletEntity } from "src/models/entities/worker_wallet.entity"; 8 | import { WorkerWalletRepository } from "src/models/repositories/worker_wallet.repository"; 9 | import { WorkerSalaryConfigEntity } from "src/models/entities/worker_salary_config.entity"; 10 | import { WorkerSalaryHistoryEntity } from "src/models/entities/worker_salary_history.entity"; 11 | import { WorkerSalaryHistoryRepository } from "src/models/repositories/worker_salary_history.repository"; 12 | 13 | @Console() 14 | @Injectable() 15 | export class SalaryCalculationConsole { 16 | private readonly kafkaConsumer: Consumer = KafkaConfig.consumer({ 17 | // more consumer added to group will point to different partition, so make more partition if more consumer 18 | // best is n partition <-> n consumer 19 | groupId: "calculate-worker-salary", 20 | }); 21 | 22 | constructor( 23 | private readonly logger: Logger, 24 | public readonly userRepository: UserRepository, 25 | public readonly workerWalletRepository: WorkerWalletRepository, 26 | public readonly workerSalaryHistoryRepository: WorkerSalaryHistoryRepository 27 | ) { 28 | this.logger.setContext(SalaryCalculationConsole.name); 29 | } 30 | 31 | @Command({ 32 | command: "calculate-worker-salary", 33 | description: "Calculate worker salary", 34 | }) 35 | async calculateWorkerSalary(): Promise { 36 | await this.kafkaConsumer.connect(); 37 | await this.kafkaConsumer.subscribe({ 38 | topic: KafkaTopic.CalculateDailyWorkerSalary, 39 | fromBeginning: false, 40 | }); 41 | 42 | await this.kafkaConsumer.run({ 43 | eachMessage: async ({ topic, partition, message }) => { 44 | this.logger.log( 45 | `Got message calculate worker salary from topic ${topic} of partition ${partition}` 46 | ); 47 | const messageInfo: { 48 | datetime: number; 49 | workerConfigSalary: WorkerSalaryConfigEntity; 50 | } = JSON.parse(message.value.toString()); 51 | 52 | await this.calculateDailyWorkerSalary( 53 | messageInfo.datetime, 54 | messageInfo.workerConfigSalary 55 | ); 56 | }, 57 | }); 58 | return new Promise(() => {}); 59 | } 60 | 61 | /** 62 | * @description Core logic related to worker salary 63 | * @param datetime 64 | * @param workerConfigSalary 65 | */ 66 | public async calculateDailyWorkerSalary( 67 | datetime: number, 68 | workerConfigSalary: WorkerSalaryConfigEntity 69 | ): Promise { 70 | const existSalaryHistory = 71 | await this.workerSalaryHistoryRepository.getWorkerSalaryHistoryByDate( 72 | datetime, 73 | workerConfigSalary.user_email 74 | ); 75 | 76 | // To calculate again worker salary of one day, we will have another job or api for handle 77 | // This job only for mission calculate new worker salary 78 | if (existSalaryHistory) { 79 | this.logger.log(`Exist salary history found!`); 80 | return; 81 | } 82 | 83 | // For history log 84 | const previousSalaryHistory = 85 | await this.workerSalaryHistoryRepository.getPreviousHistoryByDate( 86 | datetime, 87 | workerConfigSalary.user_email 88 | ); 89 | const newWorkerSalaryHistory = new WorkerSalaryHistoryEntity(); 90 | const dailyIncome = 91 | workerConfigSalary.base_salary / workerConfigSalary.standard_working_day; 92 | newWorkerSalaryHistory.worker_salary_config_id = workerConfigSalary.id; 93 | newWorkerSalaryHistory.worker_email = workerConfigSalary.user_email; 94 | newWorkerSalaryHistory.is_active = true; 95 | newWorkerSalaryHistory.date = datetime; 96 | newWorkerSalaryHistory.daily_income = Number(dailyIncome.toFixed(3)); 97 | newWorkerSalaryHistory.total_income = previousSalaryHistory 98 | ? Number((previousSalaryHistory.total_income + dailyIncome).toFixed(3)) 99 | : Number(dailyIncome.toFixed(3)); 100 | newWorkerSalaryHistory.note = "New history created"; 101 | 102 | // For worker wallet 103 | let workerWallet = 104 | await this.workerWalletRepository.getWorkerWalletByWorkerEmail( 105 | newWorkerSalaryHistory.worker_email 106 | ); 107 | if (!workerWallet) { 108 | workerWallet = new WorkerWalletEntity(); 109 | workerWallet.worker_email = newWorkerSalaryHistory.worker_email; 110 | workerWallet.is_active = true; 111 | workerWallet.available_balance = 0; 112 | workerWallet.pending_balance = Number(dailyIncome.toFixed(3)); 113 | } else { 114 | // Exist pending balance of previous calculation, then added to available balance 115 | if (workerWallet.pending_balance > 0) { 116 | workerWallet.available_balance = Number( 117 | ( 118 | workerWallet.available_balance + workerWallet.pending_balance 119 | ).toFixed(3) 120 | ); 121 | } 122 | // Pending balance always setup to dailyIncome each time calculation 123 | workerWallet.pending_balance = Number(dailyIncome.toFixed(3)); 124 | } 125 | 126 | await getConnection().transaction(async (transaction) => { 127 | await transaction.save(newWorkerSalaryHistory); 128 | await transaction.save(workerWallet); 129 | }); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/unit/salary-calculation/salary-calculation.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import Modules from "src/modules"; 2 | import { Test } from "@nestjs/testing"; 3 | import { INestApplication } from "@nestjs/common"; 4 | import { SalaryCalculationConsole } from "src/modules/salary-calculation/salary-calculation.console"; 5 | import { mockWorkerSalaryHistory } from "test/mock/mock-worker-salary-history"; 6 | import { mockWorkerSalaryConfig } from "test/mock/mock-worker-salary-config"; 7 | 8 | describe("Salary Calculation Console", () => { 9 | let app: INestApplication; 10 | let salaryCalculationConsole: SalaryCalculationConsole; 11 | 12 | beforeAll(async () => { 13 | const moduleRef = await Test.createTestingModule({ 14 | imports: [...Modules], 15 | controllers: [], 16 | providers: [], 17 | }).compile(); 18 | 19 | app = moduleRef.createNestApplication(); 20 | await app.init(); 21 | 22 | salaryCalculationConsole = moduleRef.get(SalaryCalculationConsole); 23 | 24 | // Reset all data 25 | await salaryCalculationConsole.workerWalletRepository.delete({}); 26 | await salaryCalculationConsole.workerSalaryHistoryRepository.delete({}); 27 | }); 28 | 29 | describe("Test Core logic related to worker salary", () => { 30 | const mockEmail = "worker@gmail.com"; 31 | const mockStandardWorkingDay = 22; 32 | const mockBaseSalary = 1000; 33 | const mockConfigWorkerSalary = mockWorkerSalaryConfig( 34 | mockEmail, 35 | mockStandardWorkingDay, 36 | mockBaseSalary 37 | ); 38 | const dailyIncome = ( 39 | mockConfigWorkerSalary.base_salary / 40 | mockConfigWorkerSalary.standard_working_day 41 | ).toFixed(3); 42 | 43 | async function testCalculateSalary( 44 | date: number, 45 | timeRun: number, 46 | email = mockEmail, 47 | mockConfigWorkerSalaryReplace = null 48 | ): Promise { 49 | await salaryCalculationConsole.calculateDailyWorkerSalary( 50 | date, 51 | mockConfigWorkerSalaryReplace 52 | ? mockConfigWorkerSalaryReplace 53 | : mockConfigWorkerSalary 54 | ); 55 | 56 | // salary history 57 | const resultSalaryHistory = 58 | await salaryCalculationConsole.workerSalaryHistoryRepository.getWorkerSalaryHistoryByDate( 59 | date, 60 | email 61 | ); 62 | 63 | // result wallet 64 | const resultWorkerWallet = 65 | await salaryCalculationConsole.workerWalletRepository.getWorkerWalletByWorkerEmail( 66 | email 67 | ); 68 | 69 | // timeRun will be added 1 when move to new date 70 | const totalIncomeExpected = (Number(dailyIncome) * timeRun).toFixed(3); 71 | // Because pending balance will be added to available balance after next calculation 72 | const availableBalanceExpected = 73 | timeRun > 1 ? ((timeRun - 1) * Number(dailyIncome)).toFixed(3) : 0; 74 | 75 | // History test 76 | expect(resultSalaryHistory.total_income).toBe( 77 | Number(totalIncomeExpected) 78 | ); 79 | expect(resultSalaryHistory.daily_income).toBe(Number(dailyIncome)); 80 | 81 | // Wallet test 82 | expect(resultWorkerWallet.pending_balance).toBe(Number(dailyIncome)); 83 | expect(resultWorkerWallet.available_balance).toBe( 84 | Number(availableBalanceExpected) 85 | ); 86 | } 87 | 88 | it("Should be undefined because exist salary calculation exist", async () => { 89 | const mockDate = 10; 90 | const mockWorkerSalary = mockWorkerSalaryHistory( 91 | mockDate, 92 | mockEmail, 93 | Number(dailyIncome), 94 | Number(dailyIncome) * 2 95 | ); 96 | const mockWorkerSalaryCreated = 97 | await salaryCalculationConsole.workerSalaryHistoryRepository.save( 98 | mockWorkerSalary 99 | ); 100 | 101 | expect(mockWorkerSalaryCreated.worker_email).toBe(mockEmail); 102 | expect(mockWorkerSalaryCreated.date).toBe(mockDate); 103 | expect(mockWorkerSalaryCreated.daily_income).toBe(Number(dailyIncome)); 104 | expect(mockWorkerSalaryCreated.total_income).toBe( 105 | Number(dailyIncome) * 2 106 | ); 107 | 108 | const resultCalculationSalary = 109 | await salaryCalculationConsole.calculateDailyWorkerSalary( 110 | mockDate, 111 | mockConfigWorkerSalary 112 | ); 113 | 114 | // Because already calculated salary for this day 115 | expect(resultCalculationSalary).toBeUndefined(); 116 | }); 117 | 118 | it("Should be calculated and update available balance from pending balance for next calculation", async () => { 119 | await salaryCalculationConsole.workerSalaryHistoryRepository.delete({}); 120 | 121 | // Calculate and test salary for five days calculation 122 | await testCalculateSalary(11, 1); 123 | await testCalculateSalary(12, 2); 124 | await testCalculateSalary(13, 3); 125 | await testCalculateSalary(14, 4); 126 | await testCalculateSalary(15, 5); 127 | }); 128 | 129 | it("Running testing for another worker to make sure they not conflict", async () => { 130 | const anotherMockEmail = "anotherMockWorkerEmail@gmail.com"; 131 | const mockConfigWorkerSalary = mockWorkerSalaryConfig( 132 | anotherMockEmail, 133 | mockStandardWorkingDay, 134 | mockBaseSalary 135 | ); 136 | // Calculate and test salary for five days calculation 137 | await testCalculateSalary( 138 | 11, 139 | 1, 140 | anotherMockEmail, 141 | mockConfigWorkerSalary 142 | ); 143 | await testCalculateSalary( 144 | 12, 145 | 2, 146 | anotherMockEmail, 147 | mockConfigWorkerSalary 148 | ); 149 | await testCalculateSalary( 150 | 13, 151 | 3, 152 | anotherMockEmail, 153 | mockConfigWorkerSalary 154 | ); 155 | await testCalculateSalary( 156 | 14, 157 | 4, 158 | anotherMockEmail, 159 | mockConfigWorkerSalary 160 | ); 161 | await testCalculateSalary( 162 | 15, 163 | 5, 164 | anotherMockEmail, 165 | mockConfigWorkerSalary 166 | ); 167 | }); 168 | }); 169 | 170 | afterAll(async () => { 171 | await app.close(); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/tasks/init-salary-job.task.ts: -------------------------------------------------------------------------------- 1 | import * as moment from "moment"; 2 | import { Injectable, Logger } from "@nestjs/common"; 3 | import { Cron, CronExpression } from "@nestjs/schedule"; 4 | import { JobRepository } from "src/models/repositories/job.repository"; 5 | import { 6 | JobEntity, 7 | JobKey, 8 | JobStatus, 9 | JobType, 10 | } from "src/models/entities/job.entity"; 11 | import { DayInMinutes, HourInMinutes } from "src/tasks/task.const"; 12 | import { CompanyInfoEntity } from "src/models/entities/company_info.entity"; 13 | import { CompanyInfoRepository } from "src/models/repositories/company_info.repository"; 14 | 15 | /** 16 | * @description Determine new day for specific company timezone, then decide to create salary job for specific company 17 | */ 18 | @Injectable() 19 | export class InitSalaryJobTask { 20 | private readonly logger = new Logger(InitSalaryJobTask.name); 21 | constructor( 22 | public readonly jobRepository: JobRepository, 23 | public readonly companyInfoRepository: CompanyInfoRepository 24 | ) {} 25 | 26 | /** 27 | * @description Determine timezone need to calculate by formula current time (In UTC+0) + timezone have to equal 0 or 24 28 | * @MaTrix 29 | * 00:00 utc => timezoneCalculate = 0; 30 | * 01:00 utc => timezoneCalculate = -1; 31 | * 02:00 utc => timezoneCalculate = -2; 32 | * ... 33 | * 10:00 utc => timezoneCalculate = 14 | -10; 34 | * 11:00 utc => timezoneCalculate = 13 | -11; 35 | * 12:00 utc => timezoneCalculate = 12 | -12; 36 | * 13:00 utc => timezoneCalculate = 11; 37 | * 14:00 utc => timezoneCalculate = 10; 38 | * 15:00 utc => timezoneCalculate = 9; 39 | * ... 40 | * 41 | * @param currentTimeIn0UTCInHour 42 | * @param currentTimeIn0UTCInMinute 43 | */ 44 | public getTimezoneNeedToCalculate( 45 | currentTimeIn0UTCInHour: number, 46 | currentTimeIn0UTCInMinute: number 47 | ): number[] { 48 | const timeDiffInMinute = 49 | currentTimeIn0UTCInHour * HourInMinutes + currentTimeIn0UTCInMinute; 50 | 51 | if (timeDiffInMinute === 0) return [0]; 52 | // Only need to get company with negative time zone (from 0 to 9 hour) convert to minutes 53 | if (timeDiffInMinute >= 0 && timeDiffInMinute <= 540) { 54 | return [-timeDiffInMinute]; 55 | } 56 | 57 | // Need to get both company with negative time zone and positive time zone (from 10 to 12) 58 | if (timeDiffInMinute >= 600 && timeDiffInMinute <= 720) { 59 | return [-timeDiffInMinute, DayInMinutes - timeDiffInMinute]; 60 | } 61 | 62 | // Only need to get company with positive time zone 63 | return [DayInMinutes - timeDiffInMinute]; 64 | } 65 | /** 66 | * @description: With old logic, we select all company each time this schedule run, then we add their timezone to 67 | * current time and check if there is no job for company at current time -> init job, otherwise will skip. 68 | * So this old logic will may impact performance when we have a lot of company 69 | * So instead of old logic select all company, we refactor it by determine only timezone need to calculate -> 70 | * -> When timezone + current server time greater 00:00 and less than 01:00 71 | * https://en.wikipedia.org/wiki/Coordinated_Universal_Time#:~:text=The%20westernmost%20time%20zone%20uses,be%20on%20the%20same%20day. 72 | * Follow wikipedia we have minimum time zone is -12 UTC, and maximum timezone is +14 UTC 73 | */ 74 | public async getCompanyNeedToCalculateSalary( 75 | currentServerTime: moment.Moment 76 | ): Promise { 77 | const currentTimeIn0UTCInHour = currentServerTime.utc().hour(); 78 | const currentTimeIn0UTCInMinute = currentServerTime.utc().minute(); 79 | const listTimezoneNeedToCalculate = this.getTimezoneNeedToCalculate( 80 | currentTimeIn0UTCInHour, 81 | currentTimeIn0UTCInMinute 82 | ); 83 | this.logger.log( 84 | `Current server time in UTC0 is ${ 85 | currentTimeIn0UTCInHour * HourInMinutes + currentTimeIn0UTCInMinute 86 | }. Finding companies with timezone ${listTimezoneNeedToCalculate}` 87 | ); 88 | return await this.companyInfoRepository.getAllCompanyWithTimezones( 89 | listTimezoneNeedToCalculate 90 | ); 91 | } 92 | 93 | @Cron(CronExpression.EVERY_30_SECONDS) 94 | public async handleCron() { 95 | const currentServerTime = moment(); 96 | const listCompany = await this.getCompanyNeedToCalculateSalary( 97 | currentServerTime 98 | ); 99 | 100 | if (listCompany.length === 0) { 101 | this.logger.log( 102 | "There is no company found for timezones. Waiting for new one created!" 103 | ); 104 | return; 105 | } 106 | 107 | const jobs: JobEntity[] = []; 108 | for (const companyInfo of listCompany) { 109 | const currentCompanyDateTime = currentServerTime 110 | .utc() // Get current time at UTC +0 111 | .add(companyInfo.timezone, "minutes") // Add company timezone to get current company time 112 | .set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) // Round hour, minute and second to 0 113 | .subtract(Number(process.env.CALCULATE_SALARY_AFTER_DAY), "days") // Calculate salary after day 114 | .format("YYYY-MM-DD"); 115 | const companyTimeZoneCalculateSalary = new Date( 116 | currentCompanyDateTime 117 | ).getTime(); 118 | 119 | // Job key to determine company was job salary was added today 120 | const jobKey = `${JobKey.CompanyWorkerSalaryJob}${companyInfo.id}_${companyTimeZoneCalculateSalary}`; 121 | const existJob = await this.jobRepository.getJobByKeyAndStatus( 122 | companyTimeZoneCalculateSalary, 123 | jobKey, 124 | [JobStatus.Pending, JobStatus.Completed] 125 | ); 126 | 127 | if (existJob) { 128 | this.logger.log( 129 | `Exist job calculate salary for date ${currentCompanyDateTime} of ${companyInfo.user_email}` 130 | ); 131 | continue; 132 | } 133 | 134 | const newJob = new JobEntity(); 135 | newJob.key = jobKey; 136 | newJob.status = JobStatus.Pending; 137 | newJob.date = companyTimeZoneCalculateSalary; 138 | newJob.job_type = JobType.WorkerSalaryCalculate; 139 | jobs.push(newJob); 140 | this.logger.log( 141 | `Create job calculate salary for company ${companyInfo.id} in date ${currentCompanyDateTime}` 142 | ); 143 | } 144 | 145 | if (jobs.length === 0) { 146 | this.logger.log(`Dont need to create salary job! ${new Date()}`); 147 | return; 148 | } 149 | 150 | await this.jobRepository.save(jobs); 151 | this.logger.log( 152 | `Salary job for date ${new Date()} was created successfully.` 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /test/unit/authentication/authentication.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | import { Test } from "@nestjs/testing"; 3 | import { JwtService } from "@nestjs/jwt"; 4 | import { INestApplication } from "@nestjs/common"; 5 | import Modules from "src/modules"; 6 | import { UserRole } from "src/models/entities/user.entity"; 7 | import { mockRandomUser } from "test/mock/mock-user-entity"; 8 | import { AuthService } from "src/modules/authentication/auth.service"; 9 | import { AuthMessageFailed } from "src/modules/authentication/auth.const"; 10 | 11 | describe("Test Auth Service", () => { 12 | let app: INestApplication; 13 | let authService: AuthService; 14 | let jwtService: JwtService; 15 | 16 | beforeAll(async () => { 17 | const moduleRef = await Test.createTestingModule({ 18 | imports: [...Modules], 19 | controllers: [], 20 | providers: [], 21 | }).compile(); 22 | 23 | app = moduleRef.createNestApplication(); 24 | await app.init(); 25 | 26 | authService = moduleRef.get(AuthService); 27 | jwtService = moduleRef.get(JwtService); 28 | 29 | // Reset all data 30 | await authService.userRepository.delete({}); 31 | }); 32 | 33 | describe("Test register, login, and refresh token", () => { 34 | const mockUserAdmin = mockRandomUser(UserRole.Admin); 35 | const mockUserPartner = mockRandomUser(UserRole.Partner); 36 | const mockUserWorker = mockRandomUser(UserRole.Worker); 37 | 38 | it("should register a admin user", async () => { 39 | const result = await authService.register( 40 | mockUserAdmin.username, 41 | mockUserAdmin.email, 42 | mockUserAdmin.password, 43 | mockUserAdmin.role, 44 | 1 45 | ); 46 | 47 | expect(result.username).toEqual(mockUserAdmin.username); 48 | expect(result.email).toEqual(mockUserAdmin.email); 49 | expect(result.role).toEqual(mockUserAdmin.role); 50 | const isPasswordMatch = await bcrypt.compare( 51 | mockUserAdmin.password, 52 | result.password 53 | ); 54 | expect(isPasswordMatch).toBe(true); 55 | }); 56 | 57 | it("should throw HttpException if user already exists", async () => { 58 | let errorMessage; 59 | try { 60 | await authService.register( 61 | mockUserAdmin.username, 62 | mockUserAdmin.email, 63 | mockUserAdmin.password, 64 | mockUserAdmin.role, 65 | 1 66 | ); 67 | } catch (error) { 68 | errorMessage = error.message; 69 | } 70 | expect(errorMessage).toBe(AuthMessageFailed.UserHasRegister); 71 | }); 72 | 73 | it("should register a partner user", async () => { 74 | const result = await authService.register( 75 | mockUserPartner.username, 76 | mockUserPartner.email, 77 | mockUserPartner.password, 78 | mockUserPartner.role, 79 | 1 80 | ); 81 | 82 | expect(result.username).toEqual(mockUserPartner.username); 83 | expect(result.email).toEqual(mockUserPartner.email); 84 | expect(result.role).toEqual(mockUserPartner.role); 85 | const isPasswordMatch = await bcrypt.compare( 86 | mockUserPartner.password, 87 | result.password 88 | ); 89 | expect(isPasswordMatch).toBe(true); 90 | }); 91 | 92 | it("should register a worker user", async () => { 93 | const result = await authService.register( 94 | mockUserWorker.username, 95 | mockUserWorker.email, 96 | mockUserWorker.password, 97 | mockUserWorker.role, 98 | 1 99 | ); 100 | 101 | expect(result.username).toEqual(mockUserWorker.username); 102 | expect(result.email).toEqual(mockUserWorker.email); 103 | expect(result.role).toEqual(mockUserWorker.role); 104 | const isPasswordMatch = await bcrypt.compare( 105 | mockUserWorker.password, 106 | result.password 107 | ); 108 | expect(isPasswordMatch).toBe(true); 109 | }); 110 | 111 | it("should login a user successfully", async () => { 112 | // Login with username 113 | const resultLoginWithUsername = await authService.login( 114 | mockUserAdmin.username, 115 | mockUserAdmin.password 116 | ); 117 | // Login with email 118 | const resultLoginWithEmail = await authService.login( 119 | mockUserAdmin.email, 120 | mockUserAdmin.password 121 | ); 122 | 123 | expect(resultLoginWithUsername.refresh_token).toBeDefined(); 124 | expect(resultLoginWithUsername.access_token).toBeDefined(); 125 | expect(resultLoginWithEmail.refresh_token).toBeDefined(); 126 | expect(resultLoginWithEmail.access_token).toBeDefined(); 127 | // Verify token 128 | const verifyAccessToken = await jwtService.verifyAsync( 129 | resultLoginWithUsername.access_token 130 | ); 131 | expect(verifyAccessToken.email).toBe(mockUserAdmin.email); 132 | // Verify refresh token 133 | const verifyRefreshToken = await jwtService.verifyAsync( 134 | resultLoginWithUsername.refresh_token, 135 | { 136 | secret: process.env.JWT_REFRESH_SECRET, 137 | } 138 | ); 139 | expect(verifyRefreshToken.email).toBe(mockUserAdmin.email); 140 | }); 141 | 142 | it("should throw HttpException if user invalid or wrong password or email", async () => { 143 | const identify = "nonexistentUser"; 144 | const password = "nonexistentPassword"; 145 | let result; 146 | try { 147 | await authService.login(identify, password); 148 | } catch (error) { 149 | result = error.message; 150 | } 151 | expect(result).toBe(AuthMessageFailed.UsernameOrPasswordIncorrect); 152 | }); 153 | 154 | it("should create new token", async () => { 155 | const resultLoginWithUsername = await authService.login( 156 | mockUserAdmin.username, 157 | mockUserAdmin.password 158 | ); 159 | const result = await authService.refreshToken( 160 | resultLoginWithUsername.refresh_token 161 | ); 162 | expect(result.access_token).toBeDefined(); 163 | const verifyToken = await jwtService.verifyAsync(result.access_token); 164 | expect(verifyToken.email).toBe(mockUserAdmin.email); 165 | }); 166 | 167 | it("should throw error because invalid token refresh", async () => { 168 | const invalidRefreshToken = 169 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImEiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.pX8MaSijRUH5tX5O-_ZnySmveCsFEOF_HbDBxZuviQ0"; 170 | let result; 171 | try { 172 | await authService.refreshToken(invalidRefreshToken); 173 | } catch (error) { 174 | result = error.message; 175 | } 176 | expect(result).toBe(AuthMessageFailed.InvalidRefreshToken); 177 | }); 178 | }); 179 | 180 | afterAll(async () => { 181 | await app.close(); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/modules/wallet/wallet.service.ts: -------------------------------------------------------------------------------- 1 | import { getConnection } from "typeorm"; 2 | import { HttpException, HttpStatus, Injectable } from "@nestjs/common"; 3 | import { 4 | WorkerWalletActionType, 5 | WorkerWalletHistoryEntity, 6 | } from "src/models/entities/worker_wallet_history.entity"; 7 | import { WalletMessageFailed } from "src/modules/wallet/wallet.const"; 8 | import { WorkerWalletEntity } from "src/models/entities/worker_wallet.entity"; 9 | import { WorkerWalletRepository } from "src/models/repositories/worker_wallet.repository"; 10 | 11 | @Injectable() 12 | export class WalletService { 13 | constructor(public readonly workerWalletRepository: WorkerWalletRepository) {} 14 | 15 | private async createWorkerWallet( 16 | workerEmail: string 17 | ): Promise { 18 | const newWorkerWallet = new WorkerWalletEntity(); 19 | newWorkerWallet.worker_email = workerEmail; 20 | newWorkerWallet.is_active = true; 21 | newWorkerWallet.pending_balance = 0; 22 | newWorkerWallet.available_balance = 0; 23 | return await this.workerWalletRepository.save(newWorkerWallet); 24 | } 25 | 26 | /** 27 | * @description Return worker wallet to worker for check 28 | * @param workerEmail 29 | */ 30 | public async getWorkerWallet( 31 | workerEmail: string 32 | ): Promise { 33 | const workerWallet = 34 | await this.workerWalletRepository.getWorkerWalletByWorkerEmail( 35 | workerEmail 36 | ); 37 | 38 | if (!workerWallet) return await this.createWorkerWallet(workerEmail); 39 | return workerWallet; 40 | } 41 | 42 | /** 43 | * @description Support transfer money from sender wallet to receiver wallet 44 | * @param senderEmail 45 | * @param receiveEmail 46 | * @param amount 47 | * @param note some note for clear worker transaction 48 | */ 49 | // TODO: Can add logic kafka here for handle high traffic at peak hours 50 | // TODO: Can add logic OTP for confirm 51 | // TODO: Can perform Socket-Redis or push notification to alert for worker 52 | public async transferMoney( 53 | senderEmail: string, 54 | receiveEmail: string, 55 | amount: number, 56 | note: string 57 | ): Promise<{ 58 | updatedSenderWallet: WorkerWalletEntity; 59 | history: WorkerWalletHistoryEntity; 60 | }> { 61 | const senderWallet = await this.getWorkerWallet(senderEmail); 62 | if (!senderWallet.is_active) { 63 | throw new HttpException( 64 | { message: WalletMessageFailed.WalletLocked }, 65 | HttpStatus.BAD_REQUEST 66 | ); 67 | } 68 | 69 | // Only available balance allow, pending balance need to wait n (as we config) day for added to available balance 70 | if (senderWallet.available_balance < amount) { 71 | throw new HttpException( 72 | { message: WalletMessageFailed.ExecBalance }, 73 | HttpStatus.BAD_REQUEST 74 | ); 75 | } 76 | 77 | const receiverWallet = 78 | await this.workerWalletRepository.getWorkerWalletByWorkerEmail( 79 | receiveEmail 80 | ); 81 | if (!receiverWallet) { 82 | throw new HttpException( 83 | { message: WalletMessageFailed.InvalidReceiverWallet }, 84 | HttpStatus.BAD_REQUEST 85 | ); 86 | } 87 | 88 | // Update both two wallet balance 89 | senderWallet.available_balance -= amount; 90 | receiverWallet.available_balance += amount; 91 | 92 | // History for sender 93 | const newSenderWalletHistory = new WorkerWalletHistoryEntity(); 94 | newSenderWalletHistory.amount = amount; 95 | newSenderWalletHistory.worker_email = senderEmail; 96 | newSenderWalletHistory.date = new Date().getTime(); 97 | newSenderWalletHistory.action_type = WorkerWalletActionType.Transfer; 98 | if (note) newSenderWalletHistory.note = note; 99 | 100 | // History for receiver 101 | const newReceiverWalletHistory = new WorkerWalletHistoryEntity(); 102 | newReceiverWalletHistory.amount = amount; 103 | newReceiverWalletHistory.worker_email = receiveEmail; 104 | newReceiverWalletHistory.date = new Date().getTime(); 105 | newReceiverWalletHistory.action_type = WorkerWalletActionType.Receive; 106 | if (note) newReceiverWalletHistory.note = note; 107 | 108 | // Transaction for everything success or rollback 109 | return await getConnection().transaction(async (transaction) => { 110 | const updatedSenderWallet = await transaction.save( 111 | senderWallet 112 | ); 113 | await transaction.save(receiverWallet); 114 | const history = await transaction.save( 115 | newSenderWalletHistory 116 | ); 117 | await transaction.save( 118 | newReceiverWalletHistory 119 | ); 120 | return { 121 | updatedSenderWallet, 122 | history, 123 | }; 124 | }); 125 | } 126 | 127 | /** 128 | * @description Support worker to withdraw their money to their bank account or somewhere 129 | * @param workerEmail 130 | * @param bankName 131 | * @param bankAccountNumber 132 | * @param amount 133 | */ 134 | // TODO: Should use kafka for handle this 135 | // TODO: Should separate to processes to create request withdraw -> validate -> send 3rd party -> verify, so on 136 | // TODO: to make sure it secure and correct 137 | // TODO: Should have OTP as well 138 | public async withdraw( 139 | workerEmail: string, 140 | bankName: string, 141 | bankAccountNumber: string, 142 | amount: number 143 | ): Promise<{ 144 | updatedSenderWallet: WorkerWalletEntity; 145 | history: WorkerWalletHistoryEntity; 146 | }> { 147 | const workerWallet = await this.getWorkerWallet(workerEmail); 148 | if (!workerWallet.is_active) { 149 | throw new HttpException( 150 | { message: WalletMessageFailed.WalletLocked }, 151 | HttpStatus.BAD_REQUEST 152 | ); 153 | } 154 | 155 | if (workerWallet.available_balance < amount) { 156 | throw new HttpException( 157 | { message: WalletMessageFailed.ExecBalance }, 158 | HttpStatus.BAD_REQUEST 159 | ); 160 | } 161 | 162 | workerWallet.available_balance -= amount; 163 | const newWorkerWalletHistory = new WorkerWalletHistoryEntity(); 164 | newWorkerWalletHistory.amount = amount; 165 | newWorkerWalletHistory.worker_email = workerEmail; 166 | newWorkerWalletHistory.date = new Date().getTime(); 167 | newWorkerWalletHistory.action_type = WorkerWalletActionType.Withdraw; 168 | newWorkerWalletHistory.note = `Withdraw to bank ${bankName} ${bankAccountNumber} with amount ${amount}$`; 169 | 170 | return await getConnection().transaction(async (transaction) => { 171 | const workerWalletUpdated = await transaction.save( 172 | workerWallet 173 | ); 174 | const history = await transaction.save( 175 | newWorkerWalletHistory 176 | ); 177 | return { 178 | updatedSenderWallet: workerWalletUpdated, 179 | history, 180 | }; 181 | }); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/modules/partner-config/partner-config.service.ts: -------------------------------------------------------------------------------- 1 | import { getConnection } from "typeorm"; 2 | import { HttpException, HttpStatus, Injectable } from "@nestjs/common"; 3 | import { UserEntity } from "src/models/entities/user.entity"; 4 | import { UserRepository } from "src/models/repositories/user.repository"; 5 | import { 6 | LockUnLockAction, 7 | WorkerWalletEntity, 8 | } from "src/models/entities/worker_wallet.entity"; 9 | import { CompanyInfoEntity } from "src/models/entities/company_info.entity"; 10 | import { PartnerMessageFailed } from "src/modules/partner-config/partner-config.const"; 11 | import { CompanyInfoRepository } from "src/models/repositories/company_info.repository"; 12 | import { WorkerWalletRepository } from "src/models/repositories/worker_wallet.repository"; 13 | import { WorkerSalaryConfigEntity } from "src/models/entities/worker_salary_config.entity"; 14 | import { WorkerSalaryConfigRepository } from "src/models/repositories/worker_salary_config.repository"; 15 | 16 | @Injectable() 17 | export class PartnerConfigService { 18 | constructor( 19 | public readonly userRepository: UserRepository, 20 | public readonly companyInfoRepository: CompanyInfoRepository, 21 | public readonly workerWalletRepository: WorkerWalletRepository, 22 | public readonly workerSalaryConfigRepository: WorkerSalaryConfigRepository 23 | ) {} 24 | 25 | /** 26 | * @description: update company info if existed, create new one if not exist 27 | * @param userEmail 28 | * @param companyName 29 | * @param companyDescription 30 | * @param timezone 31 | */ 32 | public async updateCompanyInfo( 33 | userEmail: string, 34 | companyName: string, 35 | companyDescription: string, 36 | timezone: number 37 | ): Promise { 38 | let companyInfo: CompanyInfoEntity; 39 | 40 | companyInfo = await this.companyInfoRepository.getCompanyInfoByUserEmail( 41 | userEmail 42 | ); 43 | 44 | if (!companyInfo) { 45 | companyInfo = new CompanyInfoEntity(); 46 | companyInfo.user_email = userEmail; 47 | companyInfo.timezone = timezone; 48 | } 49 | 50 | // Only update on field that user want to change 51 | if (companyDescription) 52 | companyInfo.company_description = companyDescription; 53 | if (companyName) companyInfo.company_name = companyName; 54 | if (timezone) companyInfo.timezone = timezone; 55 | 56 | return await this.companyInfoRepository.save(companyInfo); 57 | } 58 | 59 | /** 60 | * @description: Get list worker belong to partner, so that partner can choose for setup salary formula 61 | * @param partnerId 62 | * @param page 63 | * @param limit 64 | */ 65 | public async listWorkerBeLongToPartner( 66 | partnerId: number, 67 | page: number, 68 | limit: number 69 | ): Promise<{ workers: UserEntity[]; total: number }> { 70 | const workers = await this.userRepository.getListUserByCreatedUserId( 71 | partnerId, 72 | page, 73 | limit 74 | ); 75 | const total = await this.userRepository.countUserByCreatedUserId(partnerId); 76 | return { 77 | workers, 78 | total, 79 | }; 80 | } 81 | 82 | /** 83 | * @description Partner setup their worker salary 84 | * @param partnerId 85 | * @param partnerEmail 86 | * @param workerEmail 87 | * @param standardWorkingDay 88 | * @param baseSalary 89 | */ 90 | public async configWorkerSalary( 91 | partnerId: number, 92 | partnerEmail: string, 93 | workerEmail: string, 94 | standardWorkingDay: number, 95 | baseSalary: number 96 | ): Promise { 97 | const partnerCompany = 98 | await this.companyInfoRepository.getCompanyInfoByUserEmail(partnerEmail); 99 | if (!partnerCompany) { 100 | throw new HttpException( 101 | { message: PartnerMessageFailed.CompanyInfoRequire }, 102 | HttpStatus.BAD_REQUEST 103 | ); 104 | } 105 | 106 | const isPartnerWorker = await this.isPartnerWorker(partnerId, workerEmail); 107 | if (!isPartnerWorker) { 108 | throw new HttpException( 109 | { message: PartnerMessageFailed.InvalidWorker }, 110 | HttpStatus.BAD_REQUEST 111 | ); 112 | } 113 | 114 | const newConfigSalary = new WorkerSalaryConfigEntity(); 115 | newConfigSalary.company_id = partnerCompany.id; 116 | newConfigSalary.user_email = workerEmail; 117 | newConfigSalary.standard_working_day = standardWorkingDay; 118 | newConfigSalary.base_salary = baseSalary; 119 | newConfigSalary.is_active = true; 120 | newConfigSalary.created_by = partnerId; 121 | 122 | const existConfigSalary = 123 | await this.workerSalaryConfigRepository.getActiveWorkerSalary( 124 | workerEmail 125 | ); 126 | 127 | if (!existConfigSalary) { 128 | return await this.workerSalaryConfigRepository.save(newConfigSalary); 129 | } 130 | 131 | // de-active the old config and create new one => for partner tracing in the future 132 | return await getConnection().transaction(async (transaction) => { 133 | existConfigSalary.is_active = false; 134 | await transaction.save(existConfigSalary); 135 | return await transaction.save(newConfigSalary); 136 | }); 137 | } 138 | 139 | /** 140 | * @description de-active worker salary config, only support employee of specific 141 | * @param partnerId 142 | * @param configId 143 | */ 144 | public async deActiveWorkerSalaryConfig( 145 | partnerId: number, 146 | configId: number 147 | ): Promise { 148 | const workerSalaryConfig = 149 | await this.workerSalaryConfigRepository.getWorkerConfigById(configId); 150 | 151 | if (!workerSalaryConfig || !workerSalaryConfig.is_active) { 152 | throw new HttpException( 153 | { message: PartnerMessageFailed.InvalidWorkerConfig }, 154 | HttpStatus.BAD_REQUEST 155 | ); 156 | } 157 | 158 | const workerAccount = await this.userRepository.getUserByUserNameOrEmail([ 159 | workerSalaryConfig.user_email, 160 | ]); 161 | 162 | if (workerAccount.created_by !== partnerId) { 163 | throw new HttpException( 164 | { message: PartnerMessageFailed.InvalidWorkerConfig }, 165 | HttpStatus.BAD_REQUEST 166 | ); 167 | } 168 | workerSalaryConfig.is_active = false; 169 | return await this.workerSalaryConfigRepository.save(workerSalaryConfig); 170 | } 171 | 172 | /** 173 | * @description Support partner lock and unlock their worker wallet 174 | * @param partnerId 175 | * @param workerEmail 176 | * @param action 177 | */ 178 | public async lockOrUnlockWorkerWallet( 179 | partnerId: number, 180 | workerEmail: string, 181 | action: LockUnLockAction 182 | ): Promise { 183 | const isPartnerWorker = await this.isPartnerWorker(partnerId, workerEmail); 184 | if (!isPartnerWorker) { 185 | throw new HttpException( 186 | { message: PartnerMessageFailed.InvalidWorker }, 187 | HttpStatus.BAD_REQUEST 188 | ); 189 | } 190 | 191 | const workerWallet = 192 | await this.workerWalletRepository.getWorkerWalletByWorkerEmail( 193 | workerEmail 194 | ); 195 | if (!workerWallet) { 196 | throw new HttpException( 197 | { message: PartnerMessageFailed.InvalidWorkerWallet }, 198 | HttpStatus.BAD_REQUEST 199 | ); 200 | } 201 | 202 | workerWallet.is_active = action === LockUnLockAction.Unlock; 203 | return await this.workerWalletRepository.save(workerWallet); 204 | } 205 | 206 | /** 207 | * @description check that worker belongs to partner or not, partner only can set up their worker salary 208 | * @param partnerId 209 | * @param workerEmail 210 | */ 211 | public async isPartnerWorker( 212 | partnerId: number, 213 | workerEmail: string 214 | ): Promise { 215 | const worker = await this.userRepository.getUserByUserNameOrEmail([ 216 | workerEmail, 217 | ]); 218 | if (!worker) return false; 219 | return worker.created_by === partnerId; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /test/unit/partner-config/partner-config.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from "@nestjs/testing"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import Modules from "src/modules"; 4 | import { PartnerConfigService } from "src/modules/partner-config/partner-config.service"; 5 | import { WorkerSalaryConfigEntity } from "src/models/entities/worker_salary_config.entity"; 6 | import { PartnerMessageFailed } from "src/modules/partner-config/partner-config.const"; 7 | import { UserRole } from "src/models/entities/user.entity"; 8 | import { mockRandomUser } from "test/mock/mock-user-entity"; 9 | import { mockRandomCompanyInfo } from "test/mock/mock-company-info-entity"; 10 | 11 | describe("Partner Config Service", () => { 12 | let app: INestApplication; 13 | let partnerConfigService: PartnerConfigService; 14 | 15 | beforeAll(async () => { 16 | const moduleRef = await Test.createTestingModule({ 17 | imports: [...Modules], 18 | controllers: [], 19 | providers: [], 20 | }).compile(); 21 | 22 | app = moduleRef.createNestApplication(); 23 | await app.init(); 24 | 25 | partnerConfigService = moduleRef.get(PartnerConfigService); 26 | 27 | // Reset all data 28 | await partnerConfigService.companyInfoRepository.delete({}); 29 | await partnerConfigService.workerSalaryConfigRepository.delete({}); 30 | await partnerConfigService.userRepository.delete({}); 31 | }); 32 | 33 | describe("Test Update Company Info", () => { 34 | const mockTimezone = 1500; 35 | it("Create new company info because company info not existed", async () => { 36 | const mockCompanyName = "testCompanyName"; 37 | const mockCompanyDescription = "testDescription"; 38 | const mockEmail = "testEmail@gmail.com"; 39 | const newCompanyInfoCreated = 40 | await partnerConfigService.updateCompanyInfo( 41 | mockEmail, 42 | mockCompanyName, 43 | mockCompanyDescription, 44 | mockTimezone 45 | ); 46 | expect(newCompanyInfoCreated.user_email).toBe(mockEmail); 47 | expect(newCompanyInfoCreated.timezone).toBe(mockTimezone); 48 | expect(newCompanyInfoCreated.company_name).toBe(mockCompanyName); 49 | expect(newCompanyInfoCreated.company_description).toBe( 50 | mockCompanyDescription 51 | ); 52 | }); 53 | 54 | it("Update company info because company info existed", async () => { 55 | const mockCompanyName = "testCompanyName1"; 56 | const mockCompanyDescription = "testDescription1"; 57 | const mockEmail = "testEmail1@gmail.com"; 58 | await partnerConfigService.updateCompanyInfo( 59 | mockEmail, 60 | mockCompanyName, 61 | mockCompanyDescription, 62 | mockTimezone 63 | ); 64 | const mockUpdateCompanyName = "Company Changed"; 65 | const updatedCompanyInfo = await partnerConfigService.updateCompanyInfo( 66 | mockEmail, 67 | mockUpdateCompanyName, 68 | undefined, 69 | undefined 70 | ); 71 | expect(updatedCompanyInfo.user_email).toBe(mockEmail); 72 | expect(updatedCompanyInfo.timezone).toBe(mockTimezone); 73 | expect(updatedCompanyInfo.company_name).toBe(mockUpdateCompanyName); 74 | expect(updatedCompanyInfo.company_description).toBe( 75 | mockCompanyDescription 76 | ); 77 | }); 78 | }); 79 | 80 | describe("Test Update Worker Salary", () => { 81 | it("Should can not create because partner not update their company info", async () => { 82 | const mockPartnerId = 100; 83 | let result: WorkerSalaryConfigEntity; 84 | try { 85 | result = await partnerConfigService.configWorkerSalary( 86 | mockPartnerId, 87 | "examplePartnerEmail@gmail.com", 88 | "exampleWorkerEmail@gmail.com", 89 | 1, 90 | 1 91 | ); 92 | } catch (error) { 93 | expect(error.message).toBe(PartnerMessageFailed.CompanyInfoRequire); 94 | } 95 | expect(result).toBeUndefined(); 96 | }); 97 | 98 | it("Should can not create because partner not owner of worker", async () => { 99 | // prepare data test 100 | const mockPartner = mockRandomUser(UserRole.Partner, 1); 101 | const mockPartnerCreated = await partnerConfigService.userRepository.save( 102 | mockPartner 103 | ); 104 | const mockCompanyInfo = mockRandomCompanyInfo( 105 | mockPartnerCreated.email, 106 | 0 107 | ); 108 | await partnerConfigService.companyInfoRepository.save(mockCompanyInfo); 109 | const mockAnotherWorker = mockRandomUser( 110 | UserRole.Worker, 111 | mockPartnerCreated.id + 1 // make different create by 112 | ); 113 | const mockAnotherWorkerCreated = 114 | await partnerConfigService.userRepository.save(mockAnotherWorker); 115 | 116 | let result: WorkerSalaryConfigEntity; 117 | try { 118 | result = await partnerConfigService.configWorkerSalary( 119 | mockPartnerCreated.id, 120 | mockPartnerCreated.email, 121 | mockAnotherWorkerCreated.email, 122 | 1, 123 | 1 124 | ); 125 | } catch (error) { 126 | expect(error.message).toBe(PartnerMessageFailed.InvalidWorker); 127 | } 128 | expect(result).toBeUndefined(); 129 | }); 130 | 131 | it("Should create new one because config salary not exist", async () => { 132 | // prepare data test 133 | const mockStandardWorkingDay = 22; 134 | const mockBaseSalary = 3000; 135 | const mockPartner = mockRandomUser(UserRole.Partner, 1); 136 | const mockPartnerCreated = await partnerConfigService.userRepository.save( 137 | mockPartner 138 | ); 139 | const mockCompanyInfo = mockRandomCompanyInfo( 140 | mockPartnerCreated.email, 141 | 0 142 | ); 143 | const mockCompanyInfoCreated = 144 | await partnerConfigService.companyInfoRepository.save(mockCompanyInfo); 145 | const mockPartnerWorker = mockRandomUser( 146 | UserRole.Worker, 147 | mockPartnerCreated.id // make worker belong to partner 148 | ); 149 | await partnerConfigService.userRepository.save(mockPartnerWorker); 150 | 151 | const result = await partnerConfigService.configWorkerSalary( 152 | mockPartnerCreated.id, 153 | mockPartnerCreated.email, 154 | mockPartnerWorker.email, 155 | mockStandardWorkingDay, 156 | mockBaseSalary 157 | ); 158 | expect(result.user_email).toBe(mockPartnerWorker.email); 159 | expect(result.company_id).toBe(mockCompanyInfoCreated.id); 160 | expect(result.base_salary).toBe(mockBaseSalary); 161 | expect(result.standard_working_day).toBe(mockStandardWorkingDay); 162 | expect(result.is_active).toBe(true); 163 | }); 164 | 165 | it("Should create de-active the old one and create new one config", async () => { 166 | // prepare data and flow for test 167 | const mockStandardWorkingDay = 22; 168 | const mockBaseSalary = 3000; 169 | const mockPartner = mockRandomUser(UserRole.Partner, 1); 170 | const mockPartnerCreated = await partnerConfigService.userRepository.save( 171 | mockPartner 172 | ); 173 | const mockCompanyInfo = mockRandomCompanyInfo( 174 | mockPartnerCreated.email, 175 | 0 176 | ); 177 | const mockCompanyInfoCreated = 178 | await partnerConfigService.companyInfoRepository.save(mockCompanyInfo); 179 | const mockPartnerWorker = mockRandomUser( 180 | UserRole.Worker, 181 | mockPartnerCreated.id // make worker belong to partner 182 | ); 183 | await partnerConfigService.userRepository.save(mockPartnerWorker); 184 | 185 | // Create new one config salary for worker 186 | const result = await partnerConfigService.configWorkerSalary( 187 | mockPartnerCreated.id, 188 | mockPartnerCreated.email, 189 | mockPartnerWorker.email, 190 | mockStandardWorkingDay, 191 | mockBaseSalary 192 | ); 193 | 194 | // Update new config salary and de-active the old one 195 | const mockAnotherStandardWorkingDay = 30; 196 | const mockAnotherSalaryBase = 4000; 197 | const updateNewOne = await partnerConfigService.configWorkerSalary( 198 | mockPartnerCreated.id, 199 | mockPartnerCreated.email, 200 | mockPartnerWorker.email, 201 | mockAnotherStandardWorkingDay, 202 | mockAnotherSalaryBase 203 | ); 204 | 205 | expect(updateNewOne.user_email).toBe(mockPartnerWorker.email); 206 | expect(updateNewOne.company_id).toBe(mockCompanyInfoCreated.id); 207 | expect(updateNewOne.base_salary).toBe(mockAnotherSalaryBase); 208 | expect(updateNewOne.standard_working_day).toBe( 209 | mockAnotherStandardWorkingDay 210 | ); 211 | expect(updateNewOne.is_active).toBe(true); 212 | 213 | const oldOne = 214 | await partnerConfigService.workerSalaryConfigRepository.findOne( 215 | result.id 216 | ); 217 | expect(oldOne.is_active).toBe(false); 218 | }); 219 | }); 220 | 221 | afterAll(async () => { 222 | await app.close(); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /test/unit/task/init-salary-job.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import * as moment from "moment"; 2 | import Modules from "src/modules"; 3 | import { Test } from "@nestjs/testing"; 4 | import { INestApplication } from "@nestjs/common"; 5 | import { DayInHours, DayInMinutes, HourInMinutes } from "src/tasks/task.const"; 6 | import { randomHour, randomMinute } from "src/shares/common"; 7 | import { InitSalaryJobTask } from "src/tasks/init-salary-job.task"; 8 | import { mockRandomCompanyInfo } from "test/mock/mock-company-info-entity"; 9 | import { CompanyInfoEntity } from "src/models/entities/company_info.entity"; 10 | 11 | describe("Task init salary job for company", () => { 12 | let app: INestApplication; 13 | let initSalaryJobTask: InitSalaryJobTask; 14 | 15 | beforeAll(async () => { 16 | const moduleRef = await Test.createTestingModule({ 17 | imports: [...Modules], 18 | controllers: [], 19 | providers: [], 20 | }).compile(); 21 | 22 | app = moduleRef.createNestApplication(); 23 | await app.init(); 24 | 25 | initSalaryJobTask = moduleRef.get(InitSalaryJobTask); 26 | // Reset all data 27 | await initSalaryJobTask.companyInfoRepository.delete({}); 28 | }); 29 | 30 | describe("Test get timezone need to calculate", () => { 31 | it("should return timezone for positive values", () => { 32 | const mockHour0 = 0; 33 | const mockHour1 = 1; 34 | const mockHour2 = 2; 35 | const mockHour3 = 3; 36 | const mockHour4 = 4; 37 | const mockHour5 = 5; 38 | const mockHour6 = 6; 39 | const mockHour7 = 7; 40 | const mockHour8 = 8; 41 | const mockHour9 = 9; 42 | expect( 43 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour0, 0) 44 | ).toEqual([mockHour0 * HourInMinutes]); 45 | expect( 46 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour1, 0) 47 | ).toEqual([-mockHour1 * HourInMinutes]); 48 | expect( 49 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour2, 0) 50 | ).toEqual([-mockHour2 * HourInMinutes]); 51 | expect( 52 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour3, 0) 53 | ).toEqual([-mockHour3 * HourInMinutes]); 54 | expect( 55 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour4, 0) 56 | ).toEqual([-mockHour4 * HourInMinutes]); 57 | expect( 58 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour5, 0) 59 | ).toEqual([-mockHour5 * HourInMinutes]); 60 | expect( 61 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour6, 0) 62 | ).toEqual([-mockHour6 * HourInMinutes]); 63 | expect( 64 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour7, 0) 65 | ).toEqual([-mockHour7 * HourInMinutes]); 66 | expect( 67 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour8, 0) 68 | ).toEqual([-mockHour8 * HourInMinutes]); 69 | expect( 70 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour9, 0) 71 | ).toEqual([-mockHour9 * HourInMinutes]); 72 | }); 73 | 74 | it("should return timezone for values between 10 and 12", () => { 75 | const mockHour10 = 10; 76 | const mockHour11 = 11; 77 | const mockHour12 = 12; 78 | expect( 79 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour10, 0) 80 | ).toEqual([ 81 | -mockHour10 * HourInMinutes, 82 | (DayInHours - mockHour10) * HourInMinutes, 83 | ]); 84 | expect( 85 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour11, 0) 86 | ).toEqual([ 87 | -mockHour11 * HourInMinutes, 88 | (DayInHours - mockHour11) * HourInMinutes, 89 | ]); 90 | expect( 91 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour12, 0) 92 | ).toEqual([ 93 | -mockHour12 * HourInMinutes, 94 | (DayInHours - mockHour12) * HourInMinutes, 95 | ]); 96 | }); 97 | 98 | it("should return timezone for negative values", () => { 99 | const mockHour13 = 13; 100 | const mockHour14 = 14; 101 | const mockHour15 = 15; 102 | const mockHour16 = 16; 103 | const mockHour17 = 17; 104 | const mockHour18 = 18; 105 | const mockHour19 = 19; 106 | const mockHour20 = 20; 107 | const mockHour21 = 21; 108 | const mockHour22 = 22; 109 | const mockHour23 = 23; 110 | expect( 111 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour13, 0) 112 | ).toEqual([(DayInHours - mockHour13) * HourInMinutes]); 113 | expect( 114 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour14, 0) 115 | ).toEqual([(DayInHours - mockHour14) * HourInMinutes]); 116 | expect( 117 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour15, 0) 118 | ).toEqual([(DayInHours - mockHour15) * HourInMinutes]); 119 | expect( 120 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour16, 0) 121 | ).toEqual([(DayInHours - mockHour16) * HourInMinutes]); 122 | expect( 123 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour17, 0) 124 | ).toEqual([(DayInHours - mockHour17) * HourInMinutes]); 125 | expect( 126 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour18, 0) 127 | ).toEqual([(DayInHours - mockHour18) * HourInMinutes]); 128 | expect( 129 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour19, 0) 130 | ).toEqual([(DayInHours - mockHour19) * HourInMinutes]); 131 | expect( 132 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour20, 0) 133 | ).toEqual([(DayInHours - mockHour20) * HourInMinutes]); 134 | expect( 135 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour21, 0) 136 | ).toEqual([(DayInHours - mockHour21) * HourInMinutes]); 137 | expect( 138 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour22, 0) 139 | ).toEqual([(DayInHours - mockHour22) * HourInMinutes]); 140 | expect( 141 | initSalaryJobTask.getTimezoneNeedToCalculate(mockHour23, 0) 142 | ).toEqual([(DayInHours - mockHour23) * HourInMinutes]); 143 | }); 144 | }); 145 | 146 | describe("Test get company need to calculate salary", () => { 147 | const serverTimezone = moment().utcOffset() / 60; 148 | const mockCompanies: CompanyInfoEntity[] = []; 149 | const negative12 = -12 * HourInMinutes; 150 | const negative11 = -11 * HourInMinutes; 151 | const negative10 = -10 * HourInMinutes; 152 | const negative9 = -9 * HourInMinutes; 153 | const negative8 = -8 * HourInMinutes; 154 | const negative7 = -7 * HourInMinutes; 155 | const negative6 = -6 * HourInMinutes; 156 | const negative5 = -5 * HourInMinutes; 157 | const negative4 = -4 * HourInMinutes; 158 | const negative3 = -3 * HourInMinutes; 159 | const negative2 = -2 * HourInMinutes; 160 | const negative1 = -1 * HourInMinutes; 161 | const positive0 = 0; 162 | const positive1 = HourInMinutes; 163 | const positive2 = 2 * HourInMinutes; 164 | const positive3 = 3 * HourInMinutes; 165 | const positive4 = 4 * HourInMinutes; 166 | const positive5 = 5 * HourInMinutes; 167 | const positive6 = 6 * HourInMinutes; 168 | const positive7 = 7 * HourInMinutes; 169 | const positive8 = 8 * HourInMinutes; 170 | const positive9 = 9 * HourInMinutes; 171 | const positive10 = 10 * HourInMinutes; 172 | const positive11 = 11 * HourInMinutes; 173 | const positive12 = 12 * HourInMinutes; 174 | const positive13 = 13 * HourInMinutes; 175 | const positive14 = 14 * HourInMinutes; 176 | 177 | mockCompanies[negative12] = mockRandomCompanyInfo(undefined, negative12); 178 | mockCompanies[negative11] = mockRandomCompanyInfo(undefined, negative11); 179 | mockCompanies[negative10] = mockRandomCompanyInfo(undefined, negative10); 180 | mockCompanies[negative9] = mockRandomCompanyInfo(undefined, negative9); 181 | mockCompanies[negative8] = mockRandomCompanyInfo(undefined, negative8); 182 | mockCompanies[negative7] = mockRandomCompanyInfo(undefined, negative7); 183 | mockCompanies[negative6] = mockRandomCompanyInfo(undefined, negative6); 184 | mockCompanies[negative5] = mockRandomCompanyInfo(undefined, negative5); 185 | mockCompanies[negative4] = mockRandomCompanyInfo(undefined, negative4); 186 | mockCompanies[negative3] = mockRandomCompanyInfo(undefined, negative3); 187 | mockCompanies[negative2] = mockRandomCompanyInfo(undefined, negative2); 188 | mockCompanies[negative1] = mockRandomCompanyInfo(undefined, negative1); 189 | mockCompanies[positive0] = mockRandomCompanyInfo(undefined, positive0); 190 | mockCompanies[positive1] = mockRandomCompanyInfo(undefined, positive1); 191 | mockCompanies[positive2] = mockRandomCompanyInfo(undefined, positive2); 192 | mockCompanies[positive3] = mockRandomCompanyInfo(undefined, positive3); 193 | mockCompanies[positive4] = mockRandomCompanyInfo(undefined, positive4); 194 | mockCompanies[positive5] = mockRandomCompanyInfo(undefined, positive5); 195 | mockCompanies[positive6] = mockRandomCompanyInfo(undefined, positive6); 196 | mockCompanies[positive7] = mockRandomCompanyInfo(undefined, positive7); 197 | mockCompanies[positive8] = mockRandomCompanyInfo(undefined, positive8); 198 | mockCompanies[positive9] = mockRandomCompanyInfo(undefined, positive9); 199 | mockCompanies[positive10] = mockRandomCompanyInfo(undefined, positive10); 200 | mockCompanies[positive11] = mockRandomCompanyInfo(undefined, positive11); 201 | mockCompanies[positive12] = mockRandomCompanyInfo(undefined, positive12); 202 | mockCompanies[positive13] = mockRandomCompanyInfo(undefined, positive13); 203 | mockCompanies[positive14] = mockRandomCompanyInfo(undefined, positive14); 204 | 205 | it("Should save all company info", async () => { 206 | const result = await initSalaryJobTask.companyInfoRepository.save( 207 | Object.values(mockCompanies) 208 | ); 209 | expect(result.length).toBe(Object.values(mockCompanies).length); 210 | 211 | for (const companyCreated of result) { 212 | expect(companyCreated.company_name).toBe( 213 | mockCompanies[companyCreated.timezone].company_name 214 | ); 215 | expect(companyCreated.timezone).toBe( 216 | mockCompanies[companyCreated.timezone].timezone 217 | ); 218 | } 219 | }); 220 | 221 | /** 222 | * @description Base test for reuse 223 | * @param minuteInUTC0 224 | * @param expectedTimezone 225 | */ 226 | async function testGetCompanyNeedToCalculateSalary( 227 | minuteInUTC0: number, 228 | expectedTimezone: number[] 229 | ): Promise { 230 | const startTime = `2024-03-01 00:00:00`; 231 | const serverTime = moment(startTime).add(minuteInUTC0, "minutes"); 232 | const listCompany = 233 | await initSalaryJobTask.getCompanyNeedToCalculateSalary(serverTime); 234 | 235 | listCompany.forEach((company, index) => { 236 | const isIncludeTimezone = expectedTimezone.includes(company.timezone); 237 | expect(isIncludeTimezone).toBe(true); 238 | }); 239 | } 240 | 241 | it("Test get company need to calculate salary from 00:00 to 09:00", async () => { 242 | const hoursInUTC0 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 243 | for (const hour of hoursInUTC0) { 244 | await testGetCompanyNeedToCalculateSalary( 245 | (serverTimezone + hour) * HourInMinutes, 246 | [hour === 0 ? hour : -hour * HourInMinutes] 247 | ); 248 | } 249 | }); 250 | 251 | it("Test get company need to calculate salary from 10:00 to 12:00", async () => { 252 | const hoursInUTC0 = [10, 11, 12]; 253 | for (const hour of hoursInUTC0) { 254 | const expectedTimezones = []; 255 | expectedTimezones.push(-hour * HourInMinutes); 256 | expectedTimezones.push((DayInHours - hour) * HourInMinutes); 257 | await testGetCompanyNeedToCalculateSalary( 258 | (serverTimezone + hour) * HourInMinutes, 259 | expectedTimezones 260 | ); 261 | } 262 | }); 263 | 264 | it("Test get company need to calculate salary from 13:00 to 23:00", async () => { 265 | const hoursInUTC0 = [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]; 266 | for (const hour of hoursInUTC0) { 267 | await testGetCompanyNeedToCalculateSalary( 268 | serverTimezone + hour < DayInHours 269 | ? (serverTimezone + hour) * HourInMinutes 270 | : (serverTimezone + hour - DayInHours) * HourInMinutes, 271 | [(DayInHours - hour) * HourInMinutes] 272 | ); 273 | } 274 | }); 275 | 276 | async function randomHourAndMinuteTest(): Promise { 277 | const mockRandomHour = randomHour() - 1; 278 | const mockRandomMinute = randomMinute(); 279 | const mockTotalMinute = mockRandomHour * HourInMinutes + mockRandomMinute; 280 | 281 | const companyWithDecimal = mockRandomCompanyInfo( 282 | undefined, 283 | mockTotalMinute 284 | ); 285 | await initSalaryJobTask.companyInfoRepository.save(companyWithDecimal); 286 | const expectedTimeZones = []; 287 | 288 | if (mockTotalMinute >= 0 && mockTotalMinute <= 9 * HourInMinutes) { 289 | expectedTimeZones.push( 290 | mockTotalMinute === 0 ? mockTotalMinute : -mockTotalMinute 291 | ); 292 | await testGetCompanyNeedToCalculateSalary( 293 | serverTimezone * HourInMinutes + mockTotalMinute, 294 | expectedTimeZones 295 | ); 296 | } 297 | 298 | if ( 299 | mockTotalMinute >= 10 * HourInMinutes && 300 | mockTotalMinute <= 12 * HourInMinutes 301 | ) { 302 | expectedTimeZones.push(-mockRandomMinute); 303 | expectedTimeZones.push(DayInMinutes - mockTotalMinute); 304 | await testGetCompanyNeedToCalculateSalary( 305 | serverTimezone * HourInMinutes + mockTotalMinute, 306 | expectedTimeZones 307 | ); 308 | } 309 | 310 | if ( 311 | mockTotalMinute >= 13 * HourInMinutes && 312 | mockTotalMinute <= 23 * HourInMinutes 313 | ) { 314 | expectedTimeZones.push(DayInMinutes - mockTotalMinute); 315 | await testGetCompanyNeedToCalculateSalary( 316 | serverTimezone * HourInMinutes + mockTotalMinute < DayInMinutes 317 | ? serverTimezone * HourInMinutes + mockTotalMinute 318 | : serverTimezone * HourInMinutes + mockTotalMinute - DayInMinutes, 319 | expectedTimeZones 320 | ); 321 | } 322 | } 323 | 324 | it("Test get company has timezone with decimal with random Hour and Minute", async () => { 325 | let randomCasePass = 0; 326 | 327 | while (randomCasePass < 10) { 328 | await randomHourAndMinuteTest(); 329 | randomCasePass++; 330 | } 331 | }); 332 | }); 333 | 334 | afterAll(async () => { 335 | await app.close(); 336 | }); 337 | }); 338 | --------------------------------------------------------------------------------