├── .dockerignore ├── init.sql ├── src ├── common │ ├── interfaces │ │ ├── accessToken.ts │ │ ├── paginate.ts │ │ └── logger.ts │ ├── index.ts │ ├── enums │ │ └── sort.enum.ts │ ├── helper │ │ ├── index.ts │ │ ├── password.transformer.ts │ │ ├── paginate.ts │ │ └── log-http.ts │ ├── utils │ │ ├── index.ts │ │ ├── Hash.ts │ │ ├── url-params.ts │ │ └── validator.ts │ ├── logger │ │ ├── logger.module.ts │ │ └── logger.service.ts │ ├── common.module.ts │ ├── dtos │ │ ├── index.ts │ │ ├── sort.request.dto.ts │ │ ├── paginate.request.dto.ts │ │ └── filter.request.dto.ts │ ├── transformer │ │ ├── trim-strings.pipe.ts │ │ └── abstract-transform.pipe.ts │ ├── decorator │ │ └── current-user.decorator.ts │ ├── guard │ │ └── jwt-guard.ts │ ├── validator │ │ ├── same-as.validator.ts │ │ └── unique.validator.ts │ ├── database │ │ └── database.module.ts │ ├── interceptors │ │ └── logger.interceptor.ts │ ├── errors │ │ └── index.ts │ ├── routes │ │ └── routes.ts │ └── filters │ │ └── all-exceptions.filter.ts ├── modules │ ├── shared │ │ └── functions │ │ │ ├── index.ts │ │ │ ├── box.handler.ts │ │ │ ├── word.helper.ts │ │ │ ├── wordsBox.helper.ts │ │ │ └── user.helper.ts │ ├── box │ │ ├── queries │ │ │ ├── impl │ │ │ │ ├── index.ts │ │ │ │ ├── get-box-detail.command.ts │ │ │ │ └── get-boxes.command.ts │ │ │ └── query │ │ │ │ ├── index.ts │ │ │ │ ├── get-box-detail.handler.ts │ │ │ │ └── get-boxes.handler.ts │ │ ├── commands │ │ │ ├── impl │ │ │ │ ├── index.ts │ │ │ │ ├── delete-box.handler.command.ts │ │ │ │ ├── create-Box.command.ts │ │ │ │ ├── update-box.command.ts │ │ │ │ ├── add-wordsBoxes-to-box.command.ts │ │ │ │ └── remove-wordsBox-from-box.command.ts │ │ │ └── handler │ │ │ │ ├── index.ts │ │ │ │ ├── update-box.handler.ts │ │ │ │ ├── delete-box.handler.ts │ │ │ │ ├── create-box.handler.ts │ │ │ │ ├── add-WordsBoxes-to-box.handler.ts │ │ │ │ └── remove-wordsBox-from-box.handler.ts │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── create-box.request.dto.ts │ │ │ ├── update-box-request.dto.ts │ │ │ ├── add-wordBoxes-to-box.request.dto.ts │ │ │ ├── remove-wordsBox-from-box.request.dto.ts │ │ │ └── get-boxes.request.dto.ts │ │ ├── box.module.ts │ │ └── box.controller.ts │ ├── word │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── get-words.request.dto.ts │ │ │ ├── create-word.request.dto.ts │ │ │ └── update-word.request.dto.ts │ │ ├── queries │ │ │ ├── impl │ │ │ │ ├── index.ts │ │ │ │ ├── get-word.query.ts │ │ │ │ └── get-words.query.ts │ │ │ └── handler │ │ │ │ ├── index.ts │ │ │ │ ├── get-word.handler.ts │ │ │ │ └── get-words.handler.ts │ │ ├── commands │ │ │ ├── impl │ │ │ │ ├── index.ts │ │ │ │ ├── delete-word.command.ts │ │ │ │ ├── create-word.command.ts │ │ │ │ └── update-word.command.ts │ │ │ └── handler │ │ │ │ ├── index.ts │ │ │ │ ├── delete-word.handler.ts │ │ │ │ ├── create-word.handler.ts │ │ │ │ └── update-word.handler.ts │ │ ├── word.module.ts │ │ └── word.controler.ts │ ├── user │ │ ├── index.ts │ │ ├── user.module.ts │ │ └── user.service.ts │ ├── wordsBox │ │ ├── queries │ │ │ ├── impl │ │ │ │ ├── index.ts │ │ │ │ ├── get-words-box-detail.query.ts │ │ │ │ └── get-words-box.query.ts │ │ │ └── handler │ │ │ │ ├── index.ts │ │ │ │ ├── get-words-box-detail.handler.ts │ │ │ │ └── get-words-box.handler.ts │ │ ├── commands │ │ │ ├── impl │ │ │ │ ├── index.ts │ │ │ │ ├── create-words-box.command.ts │ │ │ │ ├── add-word-to-box.command.ts │ │ │ │ ├── remove-words.command.ts │ │ │ │ ├── delete-words-box.command.ts │ │ │ │ └── update-words-box.command.ts │ │ │ └── handler │ │ │ │ ├── index.ts │ │ │ │ ├── delete-words-box.handler.ts │ │ │ │ ├── update-words-box.handler.ts │ │ │ │ ├── create-words-box.handler.ts │ │ │ │ ├── add-words-to-box.handler.ts │ │ │ │ └── remove-words.handler.ts │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── remove-words.request.dto.ts │ │ │ ├── create-words-box.request.dto.ts │ │ │ ├── add-word-to-box.request.dto.ts │ │ │ ├── update-words-box.request.dto.ts │ │ │ └── get-box-words-request.dto.ts │ │ ├── wordsBox.module.ts │ │ └── wordsBox.controler.ts │ └── auth │ │ ├── index.ts │ │ ├── dto │ │ ├── login.request.dto.ts │ │ └── register.request.dto.ts │ │ ├── jwt.strategy.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ └── auth.controller.ts ├── config │ ├── index.ts │ ├── config.module.ts │ └── config.service.ts ├── app.service.ts ├── migrations │ └── 1692528570137-init-modele.ts ├── app.controller.ts ├── entities │ ├── index.ts │ ├── base.model.ts │ ├── box.entity.ts │ ├── word.entity.ts │ ├── wordsBox.entity.ts │ └── user.entity.ts ├── swagger.ts ├── app.controller.spec.ts ├── main.ts └── app.module.ts ├── .prettierrc ├── .gitignore ├── test ├── helper │ ├── index.ts │ ├── create-box.handler.ts │ ├── createWordsBox.helper.ts │ ├── createWord.helper.ts │ └── createUser.helper.ts ├── jest-e2e.js ├── jest.config.js ├── app.e2e-spec.ts └── modules │ ├── box │ ├── queries │ │ ├── get-box-detail.handler.spec.ts │ │ └── get-boxes.handler.spec.ts │ └── commands │ │ ├── delete-box.handler.spec.ts │ │ ├── create-box.handler.spec.ts │ │ ├── update-box.handler.spec.ts │ │ ├── add-WordsBoxes-to-box.handler.spec.ts │ │ └── remove-wordsBox-from-box.handler.spec.ts │ ├── word │ ├── commands │ │ ├── create-word.handler.spec.ts │ │ ├── delete-word.handler.spec.ts │ │ └── update-word.handler.spec.ts │ └── queries │ │ ├── get-word-query.spec.ts │ │ └── get-words-query.spec.ts │ └── wordsbox │ ├── queries │ ├── get-words-box-detail.handler.spec.ts │ └── get-words-box.handler.spec.ts │ └── command │ ├── create-words-box.handler.spec.ts │ ├── delete-words-box.handler.spec.ts │ ├── update-words-box.handler.spec.ts │ ├── add-words-to-box.handler.spec.ts │ └── remove-words.handler.spec.ts ├── nest-cli.json ├── tsconfig.spec.json ├── nodemon.json ├── .eslintignore ├── Dockerfile ├── .env.example ├── docker-compose.yml ├── cspell.json ├── ormconfig.ts ├── tsconfig.json ├── .github └── workflows │ └── node.js.yml ├── README.md └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /init.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE test; -------------------------------------------------------------------------------- /src/common/interfaces/accessToken.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common.module'; 2 | -------------------------------------------------------------------------------- /src/modules/shared/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.helper' -------------------------------------------------------------------------------- /src/modules/box/queries/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-boxes.command' -------------------------------------------------------------------------------- /src/modules/word/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-word.request.dto'; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | tmp/ 6 | temp/ 7 | /dist 8 | .env -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config.module'; 2 | export * from './config.service'; 3 | -------------------------------------------------------------------------------- /src/modules/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.service'; 2 | export * from './user.module'; 3 | -------------------------------------------------------------------------------- /src/common/enums/sort.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SortEnum { 2 | ASC = 'ASC', 3 | DESC = 'DESC', 4 | } 5 | -------------------------------------------------------------------------------- /src/common/helper/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./log-http" 2 | export * from "./password.transformer" 3 | -------------------------------------------------------------------------------- /test/helper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createUser.helper' 2 | export * from './createWord.helper' 3 | -------------------------------------------------------------------------------- /src/modules/word/queries/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-words.query' 2 | export * from './get-word.query' 3 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Hash' 2 | export * from './url-params' 3 | export * from './validator' 4 | -------------------------------------------------------------------------------- /src/modules/wordsBox/queries/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-words-box.query' 2 | export * from './get-words-box-detail.query' -------------------------------------------------------------------------------- /test/jest-e2e.js: -------------------------------------------------------------------------------- 1 | const sharedConfig = require('./jest.config.js'); 2 | 3 | module.exports = { 4 | ...sharedConfig, 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "ts-node -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/word/commands/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-word.command' 2 | export * from './update-word.command' 3 | export * from './delete-word.command' 4 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | 6 | root(): string { 7 | return 'OK...!'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | # don't lint nyc coverage output 6 | coverage -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.17.1-alpine 2 | 3 | WORKDIR /app 4 | 5 | ADD package.json /app/package.json 6 | 7 | RUN npm install --force 8 | 9 | ADD . /app 10 | 11 | RUN npm run build 12 | 13 | EXPOSE 3000 14 | 15 | CMD ["npm", "run", "start:prod"] -------------------------------------------------------------------------------- /src/common/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LoggerService } from './logger.service'; 3 | 4 | @Module({ 5 | providers: [LoggerService], 6 | exports: [LoggerService], 7 | }) 8 | export class LoggerModule { } 9 | -------------------------------------------------------------------------------- /src/modules/word/queries/handler/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./get-words.handler"; 2 | import { GetWordHandler } from "./get-word.handler"; 3 | import { GetWordsHandler } from "./get-words.handler"; 4 | 5 | export const QueryHandler = [GetWordsHandler,GetWordHandler] -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { IsUniqueConstraint, } from './validator/unique.validator'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [IsUniqueConstraint], 7 | }) 8 | export class CommonModule {} 9 | -------------------------------------------------------------------------------- /src/modules/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dto/login.request.dto'; 2 | export * from './dto/register.request.dto'; 3 | export * from './auth.service'; 4 | export * from './jwt.strategy'; 5 | export * from './auth.module'; 6 | export * from './auth.controller'; 7 | -------------------------------------------------------------------------------- /src/modules/word/queries/impl/get-word.query.ts: -------------------------------------------------------------------------------- 1 | import { IQuery } from "@nestjs/cqrs"; 2 | 3 | export class GetWordQuery implements IQuery{ 4 | constructor( 5 | public readonly userId: string, 6 | public readonly wordId: string 7 | ) { } 8 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/queries/handler/index.ts: -------------------------------------------------------------------------------- 1 | import { WordsBoxDetailHandler } from "./get-words-box-detail.handler"; 2 | import { GetWordsBoxHandler } from "./get-words-box.handler"; 3 | 4 | 5 | export const QueryHandler =[GetWordsBoxHandler,WordsBoxDetailHandler] -------------------------------------------------------------------------------- /src/modules/box/commands/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-Box.command' 2 | export * from './add-wordsBoxes-to-box.command' 3 | export * from './delete-box.handler.command' 4 | export * from './remove-wordsBox-from-box.command' 5 | export * from './update-box.command' -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './add-word-to-box.command' 2 | export * from './create-words-box.command' 3 | export * from './delete-words-box.command' 4 | export * from './remove-words.command' 5 | export * from './update-words-box.command' 6 | -------------------------------------------------------------------------------- /src/modules/wordsBox/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-words-box.request.dto' 2 | export * from './add-word-to-box.request.dto' 3 | export * from './remove-words.request.dto' 4 | export * from './update-words-box.request.dto' 5 | export * from './get-box-words-request.dto' -------------------------------------------------------------------------------- /src/modules/box/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-box.request.dto' 2 | export * from './add-wordBoxes-to-box.request.dto' 3 | export * from './get-boxes.request.dto' 4 | export * from './remove-wordsBox-from-box.request.dto' 5 | export * from './update-box-request.dto' 6 | 7 | -------------------------------------------------------------------------------- /src/modules/box/queries/impl/get-box-detail.command.ts: -------------------------------------------------------------------------------- 1 | import { IQuery } from "@nestjs/cqrs"; 2 | 3 | export class getBoxDetailCommand implements IQuery { 4 | constructor( 5 | public readonly userId :string, 6 | public readonly boxId: string, 7 | ) {} 8 | } -------------------------------------------------------------------------------- /src/modules/box/queries/query/index.ts: -------------------------------------------------------------------------------- 1 | import { getBoxDetailHandler } from './get-box-detail.handler' 2 | import { GetBoxesHandler } from './get-boxes.handler' 3 | 4 | export * from './get-boxes.handler' 5 | 6 | export const QueryHandler = [GetBoxesHandler,getBoxDetailHandler] 7 | -------------------------------------------------------------------------------- /src/modules/word/commands/impl/delete-word.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | 3 | export class DeleteWordCommand implements ICommand{ 4 | constructor( 5 | public readonly userId :string, 6 | public readonly wordId :string 7 | ){} 8 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/dto/remove-words.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsArray } from "class-validator"; 3 | 4 | export class RemoveWordsRequestDto { 5 | @IsArray() 6 | @ApiProperty({isArray: true }) 7 | wordsIds:string [] 8 | } -------------------------------------------------------------------------------- /src/modules/box/commands/impl/delete-box.handler.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | 3 | export class DeleteBoxCommand implements ICommand { 4 | constructor( 5 | public readonly userId: string, 6 | public readonly boxId: string 7 | ){} 8 | } -------------------------------------------------------------------------------- /src/modules/box/dto/create-box.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class CreateBoxRequestDto { 5 | @IsString() 6 | @ApiProperty() 7 | @IsNotEmpty() 8 | name: string 9 | } -------------------------------------------------------------------------------- /src/common/interfaces/paginate.ts: -------------------------------------------------------------------------------- 1 | export interface IPaginate { 2 | items: T[]; 3 | meta: { 4 | totalItems: number; 5 | itemCount: number; 6 | itemsPerPage: number; 7 | totalPages: number; 8 | currentPage: number; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/box/dto/update-box-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class UpdateBoxRequestDto { 5 | @ApiProperty() 6 | @IsString() 7 | @IsNotEmpty() 8 | name :string 9 | } -------------------------------------------------------------------------------- /src/modules/box/dto/add-wordBoxes-to-box.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsArray } from "class-validator"; 3 | 4 | export class AddWordsBoxesToBoxRequestDto { 5 | @IsArray() 6 | @ApiProperty({ isArray: true }) 7 | WordBoxIds: string[] 8 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/queries/impl/get-words-box-detail.query.ts: -------------------------------------------------------------------------------- 1 | import { IQuery } from "@nestjs/cqrs"; 2 | 3 | export class getWordsBoxDetailQuery implements IQuery { 4 | constructor( 5 | public readonly userId: string, 6 | public readonly boxId: string, 7 | ) { } 8 | } -------------------------------------------------------------------------------- /src/common/dtos/index.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from "@nestjs/swagger"; 2 | import { SortRequestDto } from "./sort.request.dto"; 3 | import { FilterRequestDto } from "./filter.request.dto"; 4 | 5 | export class SortAndFiltersRequestDto extends IntersectionType(SortRequestDto, FilterRequestDto) {} -------------------------------------------------------------------------------- /src/modules/box/dto/remove-wordsBox-from-box.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsArray } from "class-validator"; 3 | 4 | export class RemoveWordsBoxFromBoxRequestDto { 5 | @IsArray() 6 | @ApiProperty({isArray: true }) 7 | wordsBoxIds: string [] 8 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/dto/create-words-box.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class CreateWordsBoxRequestDto { 5 | @ApiProperty() 6 | @IsString() 7 | @IsNotEmpty() 8 | name: string 9 | } -------------------------------------------------------------------------------- /src/common/transformer/trim-strings.pipe.ts: -------------------------------------------------------------------------------- 1 | import { AbstractTransformPipe } from './abstract-transform.pipe'; 2 | 3 | export class TrimStringsPipe extends AbstractTransformPipe { 4 | 5 | protected transformValue(value: any) { 6 | return typeof value === 'string' ? value.trim() : value; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/wordsBox/dto/add-word-to-box.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsArray, IsString, IsUUID } from "class-validator"; 3 | 4 | export class AddWordToBoxRequestDto { 5 | @ApiProperty({ isArray: true}) 6 | @IsArray({always: true,}) 7 | ids: string []; 8 | } -------------------------------------------------------------------------------- /src/common/helper/password.transformer.ts: -------------------------------------------------------------------------------- 1 | import { ValueTransformer } from 'typeorm'; 2 | import { Hash } from '../utils/Hash'; 3 | 4 | export class PasswordTransformer implements ValueTransformer { 5 | to(value) { 6 | return Hash.make(value); 7 | } 8 | 9 | from(value) { 10 | return value; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/decorator/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from "@nestjs/common"; 2 | 3 | export const CurrentUser = createParamDecorator( 4 | (data: unknown, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | return request.user.id; 7 | }, 8 | ); -------------------------------------------------------------------------------- /src/common/utils/Hash.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcrypt'; 2 | 3 | export class Hash { 4 | static make(plainText) { 5 | const salt = bcrypt.genSaltSync(); 6 | return bcrypt.hashSync(plainText, salt); 7 | } 8 | 9 | static compare(plainText, hash) { 10 | return bcrypt.compareSync(plainText, hash); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/box/queries/impl/get-boxes.command.ts: -------------------------------------------------------------------------------- 1 | import { IQuery } from "@nestjs/cqrs"; 2 | import { GetBoxesRequestDto } from "../../dto"; 3 | 4 | export class GetBoxesCommand implements IQuery { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly getBoxesRequestDto:GetBoxesRequestDto 8 | ){} 9 | } -------------------------------------------------------------------------------- /src/common/interfaces/logger.ts: -------------------------------------------------------------------------------- 1 | export interface ILogger { 2 | debug(context: string, message: string): void; 3 | log(context: string, message: string): void; 4 | error(context: string, message: string, trace?: string): void; 5 | warn(context: string, message: string): void; 6 | verbose(context: string, message: string): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/box/commands/impl/create-Box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { CreateBoxRequestDto } from "../../dto"; 3 | 4 | export class CreateBoxCommand implements ICommand { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly createBoxRequestDto: CreateBoxRequestDto 8 | ){} 9 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/queries/impl/get-words-box.query.ts: -------------------------------------------------------------------------------- 1 | import { IQuery } from "@nestjs/cqrs"; 2 | import { GetWordsRequestDto } from "../../dto"; 3 | 4 | export class GetWordsBoxQuery implements IQuery { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly getWordsRequestDto:GetWordsRequestDto 8 | ) { } 9 | } -------------------------------------------------------------------------------- /src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from './config.service'; 3 | 4 | @Module({ 5 | providers: [ 6 | { 7 | provide: ConfigService, 8 | useValue: new ConfigService('.env'), 9 | }, 10 | ], 11 | exports: [ConfigService], 12 | }) 13 | export class ConfigModule { } 14 | -------------------------------------------------------------------------------- /src/modules/word/commands/impl/create-word.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { CreateWordRequestDto } from "../../dto"; 3 | 4 | export class CreateWordCommand implements ICommand{ 5 | constructor( 6 | public readonly userId: string, 7 | public readonly createWordRequestDto : CreateWordRequestDto 8 | ){} 9 | } -------------------------------------------------------------------------------- /src/modules/word/queries/impl/get-words.query.ts: -------------------------------------------------------------------------------- 1 | import { IQuery } from "@nestjs/cqrs"; 2 | import { GetWordsRequestDto } from "../../dto/get-words.request.dto"; 3 | 4 | export class GetWordsQuery implements IQuery{ 5 | constructor( 6 | public readonly userId: string, 7 | public readonly getWordsRequestDto: GetWordsRequestDto, 8 | ) {} 9 | } -------------------------------------------------------------------------------- /src/migrations/1692528570137-init-modele.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from "typeorm" 2 | 3 | export class InitModele1692528570137 implements MigrationInterface { 4 | 5 | public async up(queryRunner: QueryRunner): Promise { 6 | } 7 | 8 | public async down(queryRunner: QueryRunner): Promise { 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/common/utils/url-params.ts: -------------------------------------------------------------------------------- 1 | export const URL_REPLACE_PARAMS = ( 2 | url: string, 3 | params: { [key: string]: string }, 4 | ) => { 5 | for (const key in params) { 6 | if (Object.prototype.hasOwnProperty.call(params, key)) { 7 | const value = params[key]; 8 | url = url.replace(`:${key}`, value); 9 | } 10 | } 11 | return url; 12 | }; 13 | -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/impl/create-words-box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { CreateWordsBoxRequestDto } from "../../dto"; 3 | 4 | export class CreateWordsBoxCommand implements ICommand{ 5 | constructor( 6 | public readonly userId: string, 7 | public readonly createWordsBoxRequestDto: CreateWordsBoxRequestDto 8 | ){ 9 | 10 | } 11 | } -------------------------------------------------------------------------------- /src/modules/box/commands/impl/update-box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { UpdateBoxRequestDto } from "../../dto"; 3 | 4 | export class UpdateBoxCommand implements ICommand { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly boxId: string, 8 | public readonly updateBoxRequestDto: UpdateBoxRequestDto 9 | ){} 10 | } -------------------------------------------------------------------------------- /src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { UsersService } from './user.service'; 4 | import { UserEntity } from '../../entities'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([UserEntity])], 8 | exports: [UsersService], 9 | providers: [UsersService], 10 | }) 11 | export class UserModule {} 12 | -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/impl/add-word-to-box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { AddWordToBoxRequestDto } from "../../dto"; 3 | 4 | export class AddWordToBox implements ICommand { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly boxId: string, 8 | public readonly addWordToBoxRequestDto: AddWordToBoxRequestDto 9 | ) {} 10 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/impl/remove-words.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { RemoveWordsRequestDto } from "../../dto"; 3 | 4 | export class RemoveWordsCommand implements ICommand { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly boxId: string, 8 | public readonly removeWordsRequestDto: RemoveWordsRequestDto 9 | ) { } 10 | } -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Get, Controller } from '@nestjs/common'; 2 | import { ApiBearerAuth } from '@nestjs/swagger'; 3 | import { AppService } from './app.service'; 4 | 5 | @ApiBearerAuth() 6 | @Controller() 7 | export class AppController { 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @Get("/health") 11 | root(): string { 12 | return this.appService.root(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/impl/delete-words-box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { RemoveWordsRequestDto } from "../../dto"; 3 | 4 | export class DeleteWordsBoxCommand implements ICommand { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly boxId: string, 8 | public readonly removeWordsRequestDto:RemoveWordsRequestDto 9 | ) { } 10 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/dto/update-words-box.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger" 2 | import { IsBoolean, IsNotEmpty, IsOptional, IsString } from "class-validator" 3 | 4 | export class UpdateWordsBoxRequestDto { 5 | @IsString() 6 | @IsOptional() 7 | @ApiProperty() 8 | name: string 9 | 10 | @IsBoolean() 11 | @IsOptional() 12 | @ApiProperty() 13 | is_learned: boolean 14 | } -------------------------------------------------------------------------------- /src/common/dtos/sort.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsEnum, IsOptional } from 'class-validator'; 3 | import { SortEnum } from '../enums/sort.enum'; 4 | 5 | export class SortRequestDto { 6 | @ApiPropertyOptional({ enum: SortEnum }) 7 | @IsOptional() 8 | @IsEnum(SortEnum) 9 | sortType?: SortEnum = SortEnum.DESC; 10 | 11 | @IsOptional() 12 | sort?: any; 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/auth/dto/login.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; 3 | 4 | export class LoginRequestDto { 5 | @ApiProperty({ 6 | required: true, 7 | }) 8 | @IsEmail() 9 | email: string; 10 | 11 | @ApiProperty({ 12 | required: true, 13 | }) 14 | @IsNotEmpty() 15 | @MinLength(5) 16 | password: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/word/commands/impl/update-word.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { UpdateWordRequestDto } from "../../dto/update-word.request.dto"; 3 | 4 | export class UpdateWordCommand implements ICommand { 5 | constructor( 6 | public readonly wordId: string, 7 | public readonly userId:string, 8 | public readonly updateWordRequestDto : UpdateWordRequestDto 9 | ){} 10 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/impl/update-words-box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { UpdateWordsBoxRequestDto } from "../../dto"; 3 | 4 | export class UpdateWordsBoxCommand implements ICommand { 5 | constructor( 6 | public readonly userId: string, 7 | public readonly boxId: string, 8 | public readonly updateWordsBoxRequestDto: UpdateWordsBoxRequestDto 9 | ){} 10 | } -------------------------------------------------------------------------------- /src/modules/box/commands/impl/add-wordsBoxes-to-box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { AddWordsBoxesToBoxRequestDto } from "../../dto"; 3 | 4 | export class AddWordsBoxesToBoxCommand implements ICommand { 5 | constructor( 6 | public readonly boxId: string, 7 | public readonly userId:string, 8 | public readonly createBoxRequestDto: AddWordsBoxesToBoxRequestDto 9 | ){} 10 | } -------------------------------------------------------------------------------- /test/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'json', 'ts'], 3 | rootDir: '.', 4 | testEnvironment: 'node', 5 | testRegex: '.spec.ts$', 6 | testTimeout: 15000, 7 | transform: { 8 | '^.+\\.(t|j)s$': 'ts-jest', 9 | }, 10 | moduleNameMapper: { 11 | '@src/(.*)$': '/../src/$1', 12 | '@test/(.*)$': '/$1', 13 | ormconfig: '/../ormconfig.ts', 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.entity' 2 | export * from './box.entity' 3 | export * from './word.entity' 4 | export * from './wordsBox.entity' 5 | 6 | import { BoxEntity } from './box.entity'; 7 | import { UserEntity } from './user.entity'; 8 | import { WordEntity } from './word.entity'; 9 | import { WordsBoxEntity } from './wordsBox.entity'; 10 | 11 | export const entities = [UserEntity,BoxEntity,WordEntity,WordsBoxEntity]; 12 | -------------------------------------------------------------------------------- /src/modules/word/commands/handler/index.ts: -------------------------------------------------------------------------------- 1 | import { CreateWordHandler } from "./create-word.handler"; 2 | import { DeleteWordHandler } from "./delete-word.handler"; 3 | import { UpdateWord } from "./update-word.handler"; 4 | 5 | export * from "./create-word.handler"; 6 | export * from "./update-word.handler"; 7 | export * from "./delete-word.handler"; 8 | 9 | 10 | 11 | export const CommandHandler = [CreateWordHandler,UpdateWord,DeleteWordHandler] -------------------------------------------------------------------------------- /src/modules/box/commands/impl/remove-wordsBox-from-box.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from "@nestjs/cqrs"; 2 | import { RemoveWordsBoxFromBoxRequestDto } from "../../dto"; 3 | 4 | export class RemoveWordsBoxFromBoxCommand implements ICommand{ 5 | constructor( 6 | public readonly boxId: string , 7 | public readonly userId: string, 8 | public readonly removeWordsBoxFromBoxRequestDto: RemoveWordsBoxFromBoxRequestDto 9 | ) {} 10 | } -------------------------------------------------------------------------------- /src/common/dtos/paginate.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsOptional, IsPositive } from 'class-validator'; 4 | 5 | export class PaginateRequestDto { 6 | @ApiPropertyOptional() 7 | @IsOptional() 8 | @Type(() => Number) 9 | @IsPositive() 10 | page?: number; 11 | 12 | @ApiPropertyOptional() 13 | @IsOptional() 14 | @Type(() => Number) 15 | @IsPositive() 16 | limit?: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/common/dtos/filter.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsBoolean, IsOptional, IsString } from 'class-validator'; 3 | import { ToBoolean } from '../utils/validator'; 4 | 5 | export class FilterRequestDto { 6 | @ApiPropertyOptional() 7 | @IsOptional() 8 | @IsString() 9 | search?: string; 10 | 11 | @ApiPropertyOptional() 12 | @ToBoolean() 13 | @IsBoolean() 14 | @IsOptional() 15 | getAll?: boolean; 16 | 17 | @IsOptional() 18 | filters?: any; 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/shared/functions/box.handler.ts: -------------------------------------------------------------------------------- 1 | import { BoxEntity } from "@src/entities"; 2 | import { EntityManager, FindOptionsRelations, FindOptionsWhere } from "typeorm"; 3 | 4 | export const GetBox = async ( 5 | manager: EntityManager, 6 | where: FindOptionsWhere, 7 | relations: FindOptionsRelations = {} 8 | ) => { 9 | const box = await manager.findOne(BoxEntity, { 10 | where, 11 | relations: { 12 | ...relations 13 | }, 14 | }) 15 | return box 16 | } -------------------------------------------------------------------------------- /src/modules/shared/functions/word.helper.ts: -------------------------------------------------------------------------------- 1 | import { WordEntity } from "@src/entities"; 2 | import { EntityManager, FindOptionsRelations, FindOptionsWhere } from "typeorm"; 3 | 4 | export const GetWord = async ( 5 | manager: EntityManager, 6 | where: FindOptionsWhere, 7 | relations: FindOptionsRelations = {} 8 | ) => { 9 | const word = await manager.findOne(WordEntity, { 10 | where, 11 | relations: { 12 | ...relations 13 | }, 14 | }) 15 | return word 16 | } -------------------------------------------------------------------------------- /test/helper/create-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { BoxEntity, } from "@src/entities"; 3 | import { EntityManager } from "typeorm"; 4 | 5 | export const createBox = async (manager: EntityManager, { 6 | name, 7 | user, 8 | wordsBoxes 9 | }: Partial) => { 10 | const boxEntity = manager.create(BoxEntity, { 11 | name: name ? name : faker.lorem.word(), 12 | user, 13 | wordsBoxes 14 | }) 15 | return await manager.save(BoxEntity, boxEntity) 16 | }; 17 | -------------------------------------------------------------------------------- /src/modules/shared/functions/wordsBox.helper.ts: -------------------------------------------------------------------------------- 1 | import { WordsBoxEntity } from "@src/entities"; 2 | import { EntityManager, FindOptionsRelations, FindOptionsWhere } from "typeorm"; 3 | 4 | export const GetWordsBox = async ( 5 | manager: EntityManager, 6 | where: FindOptionsWhere, 7 | relations: FindOptionsRelations = {} 8 | ) => { 9 | const wordsBox = await manager.findOne(WordsBoxEntity, { 10 | where, 11 | relations: { 12 | ...relations 13 | }, 14 | }) 15 | return wordsBox 16 | } -------------------------------------------------------------------------------- /src/swagger.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | 4 | export const setupSwagger = (app: INestApplication) => { 5 | const options = new DocumentBuilder() 6 | .setTitle("api") 7 | .setDescription("nothing for now") 8 | .setVersion("0.0.1") 9 | .addBearerAuth() 10 | .build(); 11 | const document = SwaggerModule.createDocument(app, options); 12 | SwaggerModule.setup('/doc', app, document,{ 13 | swaggerOptions: { persistAuthorization: true }, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/box/dto/get-boxes.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional, IntersectionType } from "@nestjs/swagger"; 2 | import { SortAndFiltersRequestDto } from "@src/common/dtos"; 3 | import { PaginateRequestDto } from "@src/common/dtos/paginate.request.dto"; 4 | 5 | export enum GetBoxesRequestDtoEnum { 6 | boxCreatedAt = 'box.createdAt', 7 | } 8 | 9 | export class GetBoxesRequestDto extends IntersectionType( 10 | PaginateRequestDto, 11 | SortAndFiltersRequestDto 12 | ) { 13 | @ApiPropertyOptional({ enum: GetBoxesRequestDtoEnum }) 14 | sort?: GetBoxesRequestDtoEnum 15 | } -------------------------------------------------------------------------------- /src/common/guard/jwt-guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class JwtAuthGuard extends AuthGuard('jwt') { 7 | handleRequest(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser { 8 | if (err || !user) { 9 | throw new UnauthorizedException('Invalid credentials!'); 10 | } 11 | return user; 12 | } 13 | } -------------------------------------------------------------------------------- /src/modules/word/dto/get-words.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional, IntersectionType } from "@nestjs/swagger"; 2 | import { SortAndFiltersRequestDto } from "../../../common/dtos"; 3 | import { PaginateRequestDto } from "../../../common/dtos/paginate.request.dto"; 4 | 5 | export enum GetWordsRequestDtoEnum { 6 | wordCreatedAt = 'word.createdAt', 7 | } 8 | 9 | export class GetWordsRequestDto extends IntersectionType( 10 | PaginateRequestDto, 11 | SortAndFiltersRequestDto 12 | ) { 13 | @ApiPropertyOptional({enum: GetWordsRequestDtoEnum}) 14 | sort?: GetWordsRequestDtoEnum 15 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/dto/get-box-words-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional, IntersectionType } from "@nestjs/swagger"; 2 | import { SortAndFiltersRequestDto } from "@src/common/dtos"; 3 | import { PaginateRequestDto } from "@src/common/dtos/paginate.request.dto"; 4 | 5 | export enum GetWordsBoxRequestDtoEnum { 6 | wordsBoxCreatedAt = 'wordsBox.createdAt' 7 | } 8 | 9 | export class GetWordsRequestDto extends IntersectionType( 10 | PaginateRequestDto, 11 | SortAndFiltersRequestDto 12 | ) { 13 | @ApiPropertyOptional({enum: GetWordsBoxRequestDtoEnum}) 14 | sort?: GetWordsBoxRequestDtoEnum 15 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # APP 2 | APP_ENV=dev 3 | APP_URL=http://localhost 4 | PORT=3000 5 | 6 | MODE=develop 7 | 8 | # JWT AUTH 9 | JWT_SECRET_KEY=uAsBw6WxqDe234789654rfdvbgnjsagvf34567uiknbvcxdsew2wsexdf 10 | JWT_EXPIRATION_TIME=3600000000000 11 | # db 12 | DB_TYPE=postgres 13 | DB_USER=postgres 14 | DB_PASSWORD=password 15 | DB_HOST=db 16 | DB_PORT=5432 17 | DB_DATABASE=postgres 18 | DB_SYNC=true 19 | DB_NAME=postgres 20 | 21 | 22 | POSTGRES_USER=postgres 23 | POSTGRES_PASSWORD=password 24 | POSTGRES_DB=postgres 25 | 26 | // redis 27 | REDIS_URL=redis://redis:6379 28 | // rate limiter 29 | THROTTLE_TTL=6000 30 | THROTTLE_LIMIT=10 31 | -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/handler/index.ts: -------------------------------------------------------------------------------- 1 | import { AddWordToBoxHandler } from './add-words-to-box.handler' 2 | import { CreateWordsBoxHandler } from './create-words-box.handler' 3 | import { DeleteWordsBoxHandler } from './delete-words-box.handler' 4 | import { RemoveWordsHandler } from './remove-words.handler' 5 | import { UpdateWordsBoxHandler } from './update-words-box.handler' 6 | 7 | export * from './create-words-box.handler' 8 | export * from './add-words-to-box.handler' 9 | 10 | 11 | export const CommandHandler = [CreateWordsBoxHandler, AddWordToBoxHandler, DeleteWordsBoxHandler, RemoveWordsHandler, UpdateWordsBoxHandler] -------------------------------------------------------------------------------- /src/modules/wordsBox/wordsBox.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { WordsBoxController } from "./wordsBox.controler"; 3 | import { CqrsModule } from "@nestjs/cqrs"; 4 | import { TypeOrmModule } from "@nestjs/typeorm"; 5 | import { WordsBoxEntity } from "@src/entities"; 6 | import { CommandHandler } from "./commands/handler"; 7 | import { QueryHandler } from "./queries/handler"; 8 | 9 | @Module({ 10 | imports: [CqrsModule,TypeOrmModule.forFeature([WordsBoxEntity])], 11 | controllers:[WordsBoxController], 12 | providers: [...CommandHandler,...QueryHandler], 13 | }) 14 | export class WordsBoxModule {} -------------------------------------------------------------------------------- /src/modules/word/word.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { CqrsModule } from "@nestjs/cqrs"; 3 | import { CommandHandler } from "./commands/handler"; 4 | import { WordController } from "./word.controler"; 5 | import { QueryHandler } from "./queries/handler"; 6 | import { TypeOrmModule } from "@nestjs/typeorm"; 7 | import { WordEntity } from "../../entities"; 8 | 9 | @Module({ 10 | imports: [ 11 | CqrsModule, 12 | TypeOrmModule.forFeature([WordEntity]) 13 | ], 14 | controllers: [WordController], 15 | providers: [...CommandHandler,...QueryHandler], 16 | }) 17 | export class WordModule {} -------------------------------------------------------------------------------- /src/modules/box/box.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { CommandHandler } from "./commands/handler"; 3 | import { BoxController } from "./box.controller"; 4 | import { CqrsModule } from "@nestjs/cqrs"; 5 | import { BoxEntity } from "@src/entities"; 6 | import { TypeOrmModule } from "@nestjs/typeorm"; 7 | import { QueryHandler } from "./queries/query"; 8 | 9 | @Module({ 10 | imports: [ 11 | CqrsModule, 12 | TypeOrmModule.forFeature([BoxEntity]) 13 | ], 14 | controllers: [BoxController], 15 | providers: [...CommandHandler,...QueryHandler], 16 | exports: [] 17 | }) 18 | export class BoxModule { } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | nest: 4 | env_file: 5 | - .env 6 | build: . 7 | container_name: nest-vocabMinder 8 | depends_on: 9 | - db 10 | ports: 11 | - 3000:3000 12 | db: 13 | image: postgres:latest 14 | container_name: nest-db 15 | env_file: 16 | - .env 17 | restart: always 18 | ports: 19 | - 5432:5432 20 | volumes: 21 | - pgdata:/var/lib/postgresql/data 22 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 23 | redis: 24 | image: redis:latest 25 | container_name: nest-redis 26 | ports: 27 | - 6379:6379 28 | 29 | volumes: 30 | pgdata: 31 | -------------------------------------------------------------------------------- /src/modules/word/dto/create-word.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsString, IsNotEmpty } from "class-validator"; 3 | 4 | export class CreateWordRequestDto { 5 | @ApiProperty() 6 | @IsString() 7 | @IsNotEmpty() 8 | word: string; 9 | 10 | @ApiProperty() 11 | @IsString() 12 | @IsNotEmpty() 13 | definition: string; 14 | 15 | @ApiProperty() 16 | @IsString() 17 | @IsNotEmpty() 18 | usage: string; 19 | 20 | @ApiProperty() 21 | @IsString() 22 | @IsNotEmpty() 23 | pronounce: string; 24 | 25 | @ApiProperty() 26 | @IsString() 27 | @IsNotEmpty() 28 | example: string; 29 | } -------------------------------------------------------------------------------- /src/modules/word/dto/update-word.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsString, IsNotEmpty } from "class-validator"; 3 | 4 | export class UpdateWordRequestDto { 5 | @ApiProperty() 6 | @IsString() 7 | @IsNotEmpty() 8 | word: string; 9 | 10 | @ApiProperty() 11 | @IsString() 12 | @IsNotEmpty() 13 | definition: string; 14 | 15 | @ApiProperty() 16 | @IsString() 17 | @IsNotEmpty() 18 | usage: string; 19 | 20 | @ApiProperty() 21 | @IsString() 22 | @IsNotEmpty() 23 | pronounce: string; 24 | 25 | @ApiProperty() 26 | @IsString() 27 | @IsNotEmpty() 28 | example: string; 29 | } -------------------------------------------------------------------------------- /src/modules/shared/functions/user.helper.ts: -------------------------------------------------------------------------------- 1 | import { CustomError, USER_NOT_FOUND } from "@src/common/errors"; 2 | import { UserEntity } from "@src/entities"; 3 | import { EntityManager, FindOptionsRelations, FindOptionsWhere } from "typeorm"; 4 | 5 | export const GetUser = async ( 6 | manager: EntityManager, 7 | where: FindOptionsWhere, 8 | relations: FindOptionsRelations = {} 9 | ) => { 10 | const user = await manager.findOne(UserEntity, { 11 | where, 12 | relations: { 13 | ...relations 14 | }, 15 | }) 16 | if (!user) { 17 | throw new CustomError(USER_NOT_FOUND) 18 | } 19 | return user 20 | } -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | // cSpell Settings 2 | { 3 | // Version of the setting file. Always 0.2 4 | "version": "0.2", 5 | // language - current active spelling language 6 | "language": "en", 7 | // words - list of words to be always considered correct 8 | "words": [ 9 | "cqrs", 10 | "dtos", 11 | "middlewares", 12 | "typeorm", 13 | "Vendorstatus" 14 | ], 15 | // flagWords - list of words to be always considered incorrect 16 | // This is useful for offensive words and common spelling errors. 17 | // For example "hte" should be "the" 18 | "flagWords": [ 19 | "hte" 20 | ], 21 | "ignorePaths": [ 22 | "node_modules/**" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/box/commands/handler/index.ts: -------------------------------------------------------------------------------- 1 | import { AddWordsBoxesToBoxHandler } from './add-WordsBoxes-to-box.handler' 2 | import { CreateBoxHandler } from './create-box.handler' 3 | import { DeleteBoxHandler } from './delete-box.handler' 4 | import { RemoveWordsBoxFromBoxHandler } from './remove-wordsBox-from-box.handler' 5 | import { UpdateBoxHandler } from './update-box.handler' 6 | 7 | export * from './create-box.handler' 8 | export * from './add-WordsBoxes-to-box.handler' 9 | export * from './delete-box.handler' 10 | export * from './remove-wordsBox-from-box.handler' 11 | export * from './update-box.handler' 12 | export const CommandHandler = [CreateBoxHandler,AddWordsBoxesToBoxHandler,DeleteBoxHandler,RemoveWordsBoxFromBoxHandler,UpdateBoxHandler] -------------------------------------------------------------------------------- /test/helper/createWordsBox.helper.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { WordsBoxEntity } from "@src/entities"; 3 | import { EntityManager } from "typeorm"; 4 | 5 | export const createWordsBox = async (manager: EntityManager, { 6 | is_learned, 7 | last_reviewed_date, 8 | words, 9 | Box, 10 | name, 11 | user, 12 | }: Partial) => { 13 | const wordEntity = manager.create(WordsBoxEntity, { 14 | is_learned: is_learned ? is_learned : faker.datatype.boolean(), 15 | last_reviewed_date, 16 | words, 17 | Box, 18 | name: name ? name : faker.lorem.word(), 19 | user, 20 | }) 21 | return await manager.save(WordsBoxEntity, wordEntity) 22 | }; 23 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication() 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', async () => { 19 | return await request(app.getHttpServer()) 20 | .get('/health') 21 | .expect(200) 22 | .expect('OK...!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/entities/base.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | 9 | export default abstract class BaseModel extends BaseEntity { 10 | @PrimaryGeneratedColumn('uuid', { name: 'id' }) 11 | id: string; 12 | 13 | @CreateDateColumn({ 14 | type: 'timestamptz', 15 | default: () => 'CURRENT_TIMESTAMP(6)', 16 | name: 'created_at', 17 | }) 18 | public createdAt: Date; 19 | 20 | @UpdateDateColumn({ 21 | type: 'timestamptz', 22 | default: () => 'CURRENT_TIMESTAMP(6)', 23 | onUpdate: 'CURRENT_TIMESTAMP(6)', 24 | name: 'updated_at', 25 | }) 26 | public updatedAt: Date; 27 | } 28 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { ConfigModule } from './config'; 5 | 6 | describe('AppController', () => { 7 | let app: TestingModule; 8 | 9 | beforeAll(async () => { 10 | app = await Test.createTestingModule({ 11 | controllers: [AppController], 12 | providers: [AppService], 13 | imports: [ConfigModule], 14 | }).compile(); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "http://localhost"', () => { 19 | const appController = app.get(AppController); 20 | expect(appController.root()).toBe('http://localhost'); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/helper/createWord.helper.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { WordEntity } from "@src/entities"; 3 | import { EntityManager } from "typeorm"; 4 | 5 | export const createWord = async (manager: EntityManager, { 6 | definition, 7 | example, 8 | pronounce, 9 | usage, 10 | word, 11 | user 12 | }: Partial) => { 13 | const wordEntity = manager.create(WordEntity, { 14 | word: word ? word : faker.lorem.word(), 15 | definition: definition ? definition : faker.lorem.word(), 16 | example: example ? example : faker.lorem.word(), 17 | pronounce: pronounce ? pronounce : faker.lorem.word(), 18 | usage: usage ? usage : faker.lorem.word(), 19 | user 20 | }) 21 | return await manager.save(WordEntity,wordEntity) 22 | } -------------------------------------------------------------------------------- /src/modules/auth/dto/register.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; 3 | import { SameAs } from '../../../common/validator/same-as.validator'; 4 | 5 | export class RegisterRequestDto { 6 | @ApiProperty({ 7 | required: true, 8 | }) 9 | @IsEmail() 10 | email: string; 11 | 12 | @ApiProperty({ 13 | required: true, 14 | }) 15 | @IsNotEmpty() 16 | firstName: string; 17 | 18 | @ApiProperty({ 19 | required: true, 20 | }) 21 | @IsNotEmpty() 22 | lastName: string; 23 | 24 | @ApiProperty({ 25 | required: true, 26 | }) 27 | @IsNotEmpty() 28 | @MinLength(5) 29 | password: string; 30 | 31 | @ApiProperty({ required: true }) 32 | @SameAs('password') 33 | passwordConfirmation: string; 34 | } 35 | -------------------------------------------------------------------------------- /src/common/validator/same-as.validator.ts: -------------------------------------------------------------------------------- 1 | import { registerDecorator, ValidationOptions } from 'class-validator'; 2 | 3 | export function SameAs( 4 | property: string, 5 | validationOptions?: ValidationOptions, 6 | ) { 7 | return function (object: Object, propertyName: string) { 8 | registerDecorator({ 9 | name: 'sameAs', 10 | target: object.constructor, 11 | propertyName: propertyName, 12 | options: validationOptions, 13 | constraints: [property], 14 | validator: { 15 | validate(value: any, args: any) { 16 | const [relatedPropertyName] = args.constraints; 17 | return args.object[relatedPropertyName] === value; 18 | }, 19 | defaultMessage() { 20 | return '$property must match $constraint1'; 21 | }, 22 | }, 23 | }); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /ormconfig.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import { join } from 'path'; 3 | import { DataSource, DataSourceOptions } from 'typeorm'; 4 | 5 | dotenv.config({ 6 | path: './.env' 7 | }); 8 | 9 | export const options: DataSourceOptions = { 10 | type: 'postgres', 11 | host: process.env.DB_HOST, 12 | port: Number(process.env.DB_PORT), 13 | username: process.env.DB_USER, 14 | password: process.env.DB_PASSWORD, 15 | database: 16 | process.env.NODE_ENV === 'tEsT' 17 | ? 'test' 18 | : process.env.DB_NAME, 19 | logging: false, 20 | synchronize: true, 21 | name: 'default', 22 | migrationsTableName: 'migrations', 23 | entities: [join(__dirname, 'src/entities/**.entity{.ts,.js}')], 24 | migrations: [join(__dirname, 'src/migrations/**/*{.ts,.js}')], 25 | subscribers: [join(__dirname, 'src/subscriber/**/*{.ts,.js}')], 26 | }; 27 | 28 | export const dataSourceConnection = new DataSource(options); 29 | -------------------------------------------------------------------------------- /src/common/helper/paginate.ts: -------------------------------------------------------------------------------- 1 | import { SelectQueryBuilder } from 'typeorm'; 2 | 3 | export const paginate = async ( 4 | queryBuilder: SelectQueryBuilder, 5 | limit = 10, 6 | page = 1, 7 | ): Promise<{ 8 | items: T[]; 9 | meta: { 10 | totalItems: number; 11 | itemCount: number; 12 | itemsPerPage: number; 13 | totalPages: number; 14 | currentPage: number; 15 | }; 16 | }> => { 17 | const itemsAndCount = await queryBuilder 18 | .take(limit) 19 | .skip((page - 1) * limit) 20 | .getManyAndCount(); 21 | 22 | const totalItems = itemsAndCount[1]; 23 | 24 | const itemCount = itemsAndCount[0].length; 25 | const totalPages = Math.ceil(totalItems / limit); 26 | 27 | const meta = { 28 | totalItems, 29 | itemCount, 30 | itemsPerPage: Number(limit), 31 | totalPages, 32 | currentPage: Number(page), 33 | }; 34 | 35 | return { items: itemsAndCount[0], meta }; 36 | }; 37 | -------------------------------------------------------------------------------- /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 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "allowJs": true, 21 | "paths": { 22 | "@src/*": ["./src/*"], 23 | "@src/entities/*": ["./src/entities/*"], 24 | "@src/errors/*": ["./src/common/errors/*"], 25 | "@src/shared/functions/*": ["./src/modules/shared/functions/*"], 26 | "@test/*": ["./test/*"] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/common/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { ILogger } from '../interfaces/logger'; 3 | 4 | @Injectable() 5 | export class LoggerService extends Logger implements ILogger { 6 | debug(context: string, message: string) { 7 | if (process.env.NODE_ENV !== 'production') { 8 | super.debug(`[DEBUG] ${message}`, context); 9 | } 10 | } 11 | log(context: string, message: string) { 12 | super.log(`[INFO] ${message}`, context); 13 | } 14 | error(context: string, message: string, trace?: string) { 15 | super.error(`[ERROR] ${message}`, trace, context); 16 | } 17 | warn(context: string, message: string) { 18 | super.warn(`[WARN] ${message}`, context); 19 | } 20 | verbose(context: string, message: string) { 21 | if (process.env.NODE_ENV !== 'production') { 22 | super.verbose(`[VERBOSE] ${message}`, context); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/entities/box.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, ManyToOne, ManyToMany, JoinTable, Unique, JoinColumn, OneToMany } from 'typeorm'; 2 | import { UserEntity } from './user.entity'; 3 | import { WordsBoxEntity } from './wordsBox.entity'; 4 | import BaseModel from './base.model'; 5 | 6 | @Entity({ name: 'box' }) 7 | export class BoxEntity extends BaseModel { 8 | 9 | @Column({ name: 'name', type: 'varchar',}) 10 | name: string; 11 | /* -------------------------------------------------------------------------- */ 12 | /* Foreign key */ 13 | /* -------------------------------------------------------------------------- */ 14 | 15 | @ManyToOne(() => UserEntity, (user) => user.box, { cascade: true, onDelete:'CASCADE' }) 16 | @JoinColumn({ name: 'user_id',}) 17 | user: UserEntity; 18 | 19 | @OneToMany(() => WordsBoxEntity, (wordsBox) => wordsBox.Box) 20 | @JoinTable({ name: 'box_words' }) 21 | wordsBoxes: WordsBoxEntity[]; 22 | } -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ['master'] 9 | pull_request: 10 | branches: ['master'] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci --force 29 | - run: npm run build --if-present 30 | # - run: npm run test:e2e 31 | -------------------------------------------------------------------------------- /src/common/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module } from '@nestjs/common'; 2 | import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; 3 | 4 | import * as dotenv from 'dotenv'; 5 | import { entities } from './../../entities'; 6 | 7 | dotenv.config({ 8 | path: './.env' 9 | }); 10 | 11 | export const options: TypeOrmModuleOptions = { 12 | type: 'postgres', 13 | host: process.env.DB_HOST || 'localhost', 14 | port: +process.env.DB_PORT || 5432, 15 | username: process.env.DB_USER || 'postgres', 16 | password: process.env.DB_PASSWORD || 'postgres', 17 | database: 18 | process.env.NODE_ENV === 'tEsT' 19 | ? 'test' 20 | : process.env.POSTGRES_DB || 'postgres', 21 | entities: entities, 22 | migrationsRun: true, 23 | synchronize: true, 24 | 25 | }; 26 | function DatabaseOrmModule(): DynamicModule { 27 | return TypeOrmModule.forRoot(options); 28 | } 29 | 30 | @Global() 31 | @Module({ 32 | imports: [DatabaseOrmModule()], 33 | }) 34 | export class DatabaseModule { } 35 | -------------------------------------------------------------------------------- /src/common/utils/validator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | 3 | export const ToBoolean = () => { 4 | const toPlain = Transform( 5 | ({ value }) => { 6 | return value; 7 | }, 8 | { 9 | toPlainOnly: true, 10 | }, 11 | ); 12 | const toClass = (target: any, key: string) => { 13 | return Transform( 14 | ({ obj }) => { 15 | return valueToBoolean(obj[key]); 16 | }, 17 | { 18 | toClassOnly: true, 19 | }, 20 | )(target, key); 21 | }; 22 | return function (target: any, key: string) { 23 | toPlain(target, key); 24 | toClass(target, key); 25 | }; 26 | }; 27 | 28 | const valueToBoolean = (value: any) => { 29 | if (value === null || value === undefined) { 30 | return undefined; 31 | } 32 | if (typeof value === 'boolean') { 33 | return value; 34 | } 35 | if (['true', 'on', 'yes', '1'].includes(value.toLowerCase())) { 36 | return true; 37 | } 38 | if (['false', 'off', 'no', '0'].includes(value.toLowerCase())) { 39 | return false; 40 | } 41 | return undefined; 42 | }; 43 | -------------------------------------------------------------------------------- /src/common/transformer/abstract-transform.pipe.ts: -------------------------------------------------------------------------------- 1 | import { PipeTransform, ArgumentMetadata } from '@nestjs/common'; 2 | 3 | export abstract class AbstractTransformPipe implements PipeTransform { 4 | protected abstract transformValue(value: any): any; 5 | 6 | protected except(): string[] { 7 | return []; 8 | } 9 | 10 | private isObject(value: any): boolean { 11 | return typeof value === 'object' && value !== null; 12 | } 13 | 14 | private transformObject(values) { 15 | Object.keys(values).forEach((key) => { 16 | if (this.except().includes(key)) { 17 | return; 18 | } 19 | 20 | if (this.isObject(values[key])) { 21 | values[key] = this.transformObject(values[key]); 22 | } else { 23 | values[key] = this.transformValue(values[key]); 24 | } 25 | }); 26 | return values; 27 | } 28 | 29 | transform(values: any, metadata: ArgumentMetadata) { 30 | const { type } = metadata; 31 | if (this.isObject(values) && type === 'body') { 32 | return this.transformObject(values); 33 | } 34 | return values; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy, JwtPayload } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | 5 | import { ConfigService } from '../../config'; 6 | import { UsersService } from './../user'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | readonly configService: ConfigService, 12 | private readonly usersService: UsersService, 13 | ) { 14 | super({ 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | secretOrKey: configService.get('JWT_SECRET_KEY'), 17 | }); 18 | } 19 | 20 | async validate({ iat, exp, id }: JwtPayload, done) { 21 | const timeDiff = exp - iat; 22 | if (timeDiff <= 0) { 23 | throw new UnauthorizedException(); 24 | } 25 | 26 | const user = await this.usersService.getUserById(id); 27 | if (!user) { 28 | throw new UnauthorizedException(); 29 | } 30 | 31 | delete user.password; 32 | done(null, user); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/word/queries/handler/get-word.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from "@nestjs/cqrs"; 2 | import { WordEntity } from "../../../../entities"; 3 | import { InjectRepository } from "@nestjs/typeorm"; 4 | import { Repository } from "typeorm"; 5 | import { GetWordQuery } from "../impl"; 6 | import { CustomError, WORD_NOT_FOUND } from "@src/common/errors"; 7 | 8 | @QueryHandler(GetWordQuery) 9 | export class GetWordHandler implements IQueryHandler { 10 | constructor( 11 | @InjectRepository(WordEntity) private readonly wordRepository: Repository 12 | ) { } 13 | async execute(query: GetWordQuery): Promise { 14 | const { userId, wordId } = query 15 | 16 | const queryBuilder = this.wordRepository.createQueryBuilder('word') 17 | .andWhere('word.id = :wordId', { wordId }) 18 | .leftJoin('word.user', 'user') 19 | .andWhere('user.id = :userId', { userId }) 20 | if(await queryBuilder.getOne()===null){ 21 | throw new CustomError(WORD_NOT_FOUND) 22 | } 23 | return await queryBuilder.getOne() 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /src/common/helper/log-http.ts: -------------------------------------------------------------------------------- 1 | /* -------------------------------------------------------------------------- */ 2 | /* Error Log */ 3 | /* -------------------------------------------------------------------------- */ 4 | 5 | export interface IErrorLog { 6 | level: 'error'; 7 | date: string; 8 | message: string; 9 | statusCode: string; 10 | 11 | body: object; 12 | params: object; 13 | method: string; 14 | query: object; 15 | url: string; 16 | } 17 | 18 | export const createErrorLog = ({ 19 | statusCode, 20 | message, 21 | body, 22 | params, 23 | method, 24 | query, 25 | url, 26 | }: { 27 | message: string; 28 | statusCode: string; 29 | 30 | body: object; 31 | params: object; 32 | method: string; 33 | query: object; 34 | url: string; 35 | }): IErrorLog => { 36 | return { 37 | level: 'error', 38 | date: new Date().toISOString(), 39 | message, 40 | statusCode, 41 | 42 | body: body, 43 | params: params, 44 | method: method, 45 | query: query, 46 | url: url, 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotAcceptableException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { UserEntity } from '../../entities'; 5 | import { RegisterRequestDto } from '../auth'; 6 | 7 | 8 | @Injectable() 9 | export class UsersService { 10 | constructor( 11 | @InjectRepository(UserEntity) 12 | private readonly userRepository: Repository, 13 | ) {} 14 | 15 | async getUserById(id: string) { 16 | return this.userRepository.findOne({ where:{ 17 | id: id 18 | } }); 19 | } 20 | 21 | async getUserByEmail(email: string) { 22 | return await this.userRepository.findOne({ where: { 23 | email 24 | }}); 25 | } 26 | 27 | async createUser(registerRequestDto: RegisterRequestDto ) { 28 | const {email,firstName,lastName,password} = registerRequestDto 29 | const user = await this.getUserByEmail(registerRequestDto.email); 30 | if (user) { 31 | throw new NotAcceptableException( 32 | 'User with provided email already created.', 33 | ); 34 | } 35 | return await this.userRepository.save({lastName,firstName,email,password}); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { useContainer } from 'class-validator'; 4 | import helmet from 'helmet'; 5 | import { TrimStringsPipe } from './common/transformer/trim-strings.pipe'; 6 | import { AppModule } from './app.module'; 7 | import { setupSwagger } from './swagger'; 8 | import { LoggingInterceptor } from './common/interceptors/logger.interceptor'; 9 | import { LoggerService } from './common/logger/logger.service'; 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create(AppModule); 13 | app.setGlobalPrefix('/api'); 14 | // swagger 15 | setupSwagger(app); 16 | // security 17 | app.use(helmet()); 18 | 19 | app.enableCors(); 20 | // pipe 21 | app.useGlobalPipes(new TrimStringsPipe(), new ValidationPipe()); 22 | // interceptor 23 | app.useGlobalInterceptors(new LoggingInterceptor(new LoggerService())); 24 | // container for validator 25 | useContainer(app.select(AppModule), { fallbackOnErrors: true }); 26 | 27 | const PORT = process.env.PORT || 3000; 28 | await app.listen(PORT, async () => { 29 | LoggerService.log(`listening on port ${await app.getUrl()}`); 30 | }); 31 | } 32 | 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /src/common/validator/unique.validator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { 3 | registerDecorator, 4 | ValidationArguments, 5 | ValidationOptions, 6 | ValidatorConstraint, 7 | ValidatorConstraintInterface, 8 | } from 'class-validator'; 9 | import { DataSource } from 'typeorm'; 10 | 11 | @ValidatorConstraint({ name: 'isUnique', async: true }) 12 | @Injectable() 13 | export class IsUniqueConstraint implements ValidatorConstraintInterface { 14 | constructor(private dataSource: DataSource) {} 15 | validate(value: any, args: ValidationArguments) { 16 | return this.dataSource 17 | .getRepository(args.targetName) 18 | .findOne({ 19 | where: { 20 | [args.property]: value, 21 | }, 22 | }) 23 | .then((entity) => { 24 | if (entity) return false; 25 | return true; 26 | }); 27 | } 28 | } 29 | 30 | export function IsUnique(validationOptions?: ValidationOptions) { 31 | return function (object: object, propertyName: string) { 32 | registerDecorator({ 33 | target: object.constructor, 34 | propertyName: propertyName, 35 | options: validationOptions, 36 | constraints: [], 37 | validator: IsUniqueConstraint, 38 | }); 39 | }; 40 | } -------------------------------------------------------------------------------- /src/modules/box/queries/query/get-box-detail.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from "@nestjs/cqrs"; 2 | import { getBoxDetailCommand } from "../impl/get-box-detail.command"; 3 | import { InjectRepository } from "@nestjs/typeorm"; 4 | import { BoxEntity } from "@src/entities"; 5 | import { Repository } from "typeorm"; 6 | import { BOX_NOT_FOUND, CustomError } from "@src/common/errors"; 7 | 8 | @QueryHandler(getBoxDetailCommand) 9 | export class getBoxDetailHandler implements IQueryHandler { 10 | constructor( 11 | @InjectRepository(BoxEntity) private readonly boxRepository: Repository 12 | ) { } 13 | async execute(query: getBoxDetailCommand): Promise { 14 | const { boxId, userId } = query 15 | const queryBuilder = this.boxRepository.createQueryBuilder('box') 16 | .andWhere('box.id = :boxId', { boxId }) 17 | .leftJoinAndSelect('box.wordsBoxes', 'wordsBoxes') 18 | .leftJoin('box.user', 'user') 19 | .andWhere('user.id = :userId', { userId }) 20 | 21 | const result = await queryBuilder.getOne(); 22 | if(!result) { 23 | throw new CustomError(BOX_NOT_FOUND) 24 | } 25 | return result; 26 | } 27 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/queries/handler/get-words-box-detail.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from "@nestjs/cqrs"; 2 | import { getWordsBoxDetailQuery } from "../impl"; 3 | import { InjectRepository } from "@nestjs/typeorm"; 4 | import { WordsBoxEntity } from "@src/entities"; 5 | import { Repository } from "typeorm"; 6 | import { CustomError, WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 7 | 8 | @QueryHandler(getWordsBoxDetailQuery) 9 | export class WordsBoxDetailHandler implements IQueryHandler { 10 | constructor( 11 | @InjectRepository(WordsBoxEntity) private readonly wordsBoxRepository: Repository 12 | ) { } 13 | async execute(query: getWordsBoxDetailQuery): Promise { 14 | const { boxId, userId } = query 15 | const queryBuilder = this.wordsBoxRepository.createQueryBuilder('wordsBox') 16 | .leftJoin('wordsBox.user', 'user').andWhere('user.id = :userId', { userId }) 17 | .leftJoinAndSelect('wordsBox.words', 'words') 18 | .andWhere('wordsBox.id = :boxId', { boxId }) 19 | 20 | if (!await queryBuilder.getOne()) 21 | throw new CustomError(WORDS_BOX_NOT_FOUND) 22 | 23 | return await queryBuilder.getOne() 24 | } 25 | } -------------------------------------------------------------------------------- /src/entities/word.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | ManyToOne, 5 | } from 'typeorm'; 6 | 7 | import BaseModel from './base.model'; 8 | import { WordsBoxEntity } from './wordsBox.entity'; 9 | import { UserEntity } from './user.entity'; 10 | 11 | @Entity({ 12 | name: 'word', 13 | }) 14 | export class WordEntity extends BaseModel { 15 | 16 | @Column({ type: 'varchar', name: 'word', }) 17 | word: string; 18 | 19 | @Column({ type: 'varchar', name: 'definition' }) 20 | definition: string; 21 | 22 | @Column({ type: 'varchar', name: 'usage' }) 23 | usage: string; 24 | 25 | @Column({ type: 'varchar', name: 'pronounce' }) 26 | pronounce: string; 27 | 28 | @Column({ type: 'varchar', name: 'example' }) 29 | example: string; 30 | 31 | /* -------------------------------------------------------------------------- */ 32 | /* Foreign key */ 33 | /* -------------------------------------------------------------------------- */ 34 | 35 | @ManyToOne(() => WordsBoxEntity, (wordsBox) => wordsBox.words,) 36 | wordsBoxes: WordsBoxEntity[]; 37 | 38 | @ManyToOne(() => UserEntity, (user) => user.words, { cascade: true}) 39 | user: UserEntity 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { ConfigModule, ConfigService } from '../../config'; 5 | import { UserModule } from './../user'; 6 | import { AuthService } from './auth.service'; 7 | import { JwtStrategy } from './jwt.strategy'; 8 | import { AuthController } from './auth.controller'; 9 | import { CommonModule } from '@src/common'; 10 | 11 | @Module({ 12 | imports: [ 13 | UserModule, 14 | ConfigModule, 15 | PassportModule.register({ defaultStrategy: 'jwt' }), 16 | JwtModule.registerAsync({ 17 | imports: [ConfigModule], 18 | useFactory: async (configService: ConfigService) => { 19 | return { 20 | secret: configService.get('JWT_SECRET_KEY'), 21 | signOptions: { 22 | expiresIn: Number(configService.get('JWT_EXPIRATION_TIME')), 23 | algorithm: 'HS256', 24 | } 25 | }; 26 | }, 27 | inject: [ConfigService], 28 | }), 29 | CommonModule 30 | ], 31 | controllers: [AuthController], 32 | providers: [AuthService, JwtStrategy], 33 | exports: [PassportModule.register({ defaultStrategy: 'jwt' })], 34 | }) 35 | export class AuthModule { } 36 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { Hash } from '../../common/utils/Hash'; 4 | import { ConfigService } from '../../config'; 5 | import { UsersService } from './../user'; 6 | import { LoginRequestDto } from './dto/login.request.dto'; 7 | import { UserEntity } from '../../entities'; 8 | import { CustomError, USER_NOT_FOUND } from '@src/common/errors'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | constructor( 13 | private readonly jwtService: JwtService, 14 | private readonly configService: ConfigService, 15 | private readonly userService: UsersService, 16 | ) { } 17 | 18 | async createToken(user: UserEntity) { 19 | return { 20 | expiresIn: this.configService.get('JWT_EXPIRATION_TIME'), 21 | accessToken: this.jwtService.sign({ id: user.id, username: user.firstName }), 22 | user, 23 | }; 24 | } 25 | 26 | async validateUser(payload: LoginRequestDto): Promise { 27 | const user = await this.userService.getUserByEmail(payload.email); 28 | if (!user) throw new CustomError(USER_NOT_FOUND) 29 | 30 | const isMatch = await Hash.compare(payload.password, user.password) 31 | 32 | if (!user || !isMatch) { 33 | throw new UnauthorizedException('Invalid credentials!'); 34 | } 35 | return user; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/helper/createUser.helper.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { JwtService } from "@nestjs/jwt"; 3 | import { UserEntity } from "@src/entities"; 4 | import { EntityManager } from "typeorm"; 5 | 6 | export const createUser = async (manager: EntityManager, user: Partial = {}) => { 7 | const { 8 | email = faker.internet.email(), 9 | firstName, 10 | lastName, 11 | password = faker.internet.password() 12 | } = user 13 | const jwtService = new JwtService({ secret: process.env.JWT_SECRET_KEY , 14 | signOptions: { 15 | expiresIn: process.env.JWT_EXPIRATION_TIME 16 | }}); 17 | try { 18 | let getUser = await manager.findOne(UserEntity, { where: { email } }) 19 | let userEntity 20 | if (!getUser) { 21 | userEntity = manager.create(UserEntity, { 22 | firstName: firstName ? firstName : faker.lorem.word(), 23 | lastName: lastName ? lastName : faker.lorem.word(), 24 | email, 25 | password 26 | }) 27 | await manager.save(userEntity) 28 | } 29 | const token = await jwtService.sign({ id: userEntity.id, username: user.firstName},{ 30 | }) 31 | return { 32 | token, 33 | user: userEntity 34 | } 35 | 36 | } catch (error) { 37 | throw error 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /src/common/interceptors/logger.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { tap } from 'rxjs/operators'; 4 | import { LoggerService } from '../logger/logger.service'; 5 | 6 | @Injectable() 7 | export class LoggingInterceptor implements NestInterceptor { 8 | constructor(private readonly logger: LoggerService) { } 9 | 10 | intercept(context: ExecutionContext, next: CallHandler): Observable { 11 | const now = Date.now(); 12 | const httpContext = context.switchToHttp(); 13 | const request = httpContext.getRequest(); 14 | 15 | const ip = this.getIP(request); 16 | 17 | return next.handle().pipe( 18 | tap(() => { 19 | this.logger.log( 20 | `End Request for ${request.path}`, 21 | `method=${request.method} ip=${ip} duration=${Date.now() - now}ms`, 22 | ); 23 | }), 24 | ); 25 | } 26 | 27 | private getIP(request: any): string { 28 | let ip: string; 29 | const ipAddr = request.headers['x-forwarded-for']; 30 | if (ipAddr) { 31 | const list = ipAddr.split(','); 32 | ip = list[list.length - 1]; 33 | } else { 34 | ip = request.connection.remoteAddress; 35 | } 36 | return ip.replace('::ffff:', ''); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import * as fs from 'fs'; 3 | 4 | enum MODE { 5 | 'develop' = 'develop', 6 | 'staging' = 'staging', 7 | 'production' = 'production', 8 | } 9 | export interface EnvData { 10 | DB_TYPE: 'postgres'; 11 | DB_HOST: string; 12 | DB_PORT: number; 13 | DB_USER: string; 14 | DB_PASSWORD: string; 15 | DB_NAME: string; 16 | JWT_EXPIRATION_TIME: string; 17 | JWT_SECRET_KEY: string; 18 | MODE: MODE; 19 | APP_ENV: string; 20 | THROTTLE_LIMIT: number; 21 | THROTTLE_TTL: number; 22 | REDIS_HOST: string; 23 | REDIS_PORT: number; 24 | } 25 | export class ConfigService { 26 | private readonly envConfig: { [key: string]: string }; 27 | 28 | constructor(filePath: string) { 29 | this.envConfig = dotenv.parse(fs.readFileSync(filePath)); 30 | for (const property in this.envConfig) { 31 | if (!this.envConfig[property]) { 32 | console.log(`"${property}" is not defined`); 33 | process.exit(0); 34 | } 35 | } 36 | } 37 | 38 | get(key: string): string { 39 | return this.envConfig[key]; 40 | } 41 | 42 | isEnv(env: string) { 43 | return this.envConfig.APP_ENV === env; 44 | } 45 | isStaging(): boolean { 46 | return this.envConfig.MODE === 'staging'; 47 | } 48 | isDev(): boolean { 49 | return this.envConfig.MODE === 'develop'; 50 | } 51 | 52 | isProd(): boolean { 53 | return this.envConfig.MODE === 'production'; 54 | } 55 | } -------------------------------------------------------------------------------- /src/modules/box/queries/query/get-boxes.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from "@nestjs/cqrs"; 2 | import { GetBoxesCommand } from "../impl"; 3 | import { InjectRepository } from "@nestjs/typeorm"; 4 | import { BoxEntity } from "@src/entities"; 5 | import { Repository } from "typeorm"; 6 | import { paginate } from "@src/common/helper/paginate"; 7 | 8 | @QueryHandler(GetBoxesCommand) 9 | export class GetBoxesHandler implements IQueryHandler{ 10 | constructor( 11 | @InjectRepository(BoxEntity) private readonly boxRepository: Repository 12 | ) { } 13 | async execute(query: GetBoxesCommand): Promise { 14 | const { getBoxesRequestDto, userId } = query 15 | const { getAll, limit, page, search, sort, sortType } = getBoxesRequestDto 16 | 17 | const queryBuilder = this.boxRepository.createQueryBuilder('box') 18 | .leftJoinAndSelect('box.user', 'user') 19 | .andWhere('user.id = :userId', { userId }) 20 | 21 | if (sort && sortType) { 22 | queryBuilder.orderBy(sort, sortType); 23 | } 24 | if (search) { 25 | queryBuilder.andWhere('(box.name ILIKE :search)', { 26 | search: `%${search}%`, 27 | }); 28 | } 29 | 30 | if (getAll) { 31 | return await queryBuilder.getMany() 32 | } 33 | 34 | return await paginate(queryBuilder, limit, page); 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /src/entities/wordsBox.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | ManyToOne, 5 | OneToMany, 6 | JoinColumn, 7 | } from 'typeorm'; 8 | 9 | import BaseModel from './base.model'; 10 | import { UserEntity } from './user.entity'; 11 | import { WordEntity } from './word.entity'; 12 | import { BoxEntity } from './box.entity'; 13 | 14 | @Entity({ 15 | name: 'words_box' 16 | }) 17 | export class WordsBoxEntity extends BaseModel { 18 | 19 | @Column({ name: 'name', type: 'varchar', nullable: false }) 20 | name: string; 21 | 22 | @Column({ default: false, name: 'is_learned' }) 23 | is_learned: boolean; 24 | 25 | @Column({ type: 'timestamp', nullable: true }) 26 | last_reviewed_date: Date; 27 | 28 | /* -------------------------------------------------------------------------- */ 29 | /* Foreign key */ 30 | /* -------------------------------------------------------------------------- */ 31 | 32 | @OneToMany(() => WordEntity, (word) => word.wordsBoxes, { cascade: true }) 33 | @JoinColumn({ name: 'wordsBoxesId' }) 34 | words: WordEntity[]; 35 | 36 | @ManyToOne(() => UserEntity, (user) => user.wordsBoxes, { cascade: true }) 37 | user: UserEntity; 38 | 39 | @ManyToOne(() => BoxEntity, (box) => box.wordsBoxes, { cascade: true, }) 40 | Box: BoxEntity 41 | 42 | markWordAsLearned(): void { 43 | this.is_learned = true; 44 | this.last_reviewed_date = new Date(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/word/queries/handler/get-words.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from "@nestjs/cqrs"; 2 | import { WordEntity } from "../../../../entities"; 3 | import { InjectRepository } from "@nestjs/typeorm"; 4 | import { Repository } from "typeorm"; 5 | import { paginate } from "../../../../common/helper/paginate"; 6 | import { IPaginate } from "../../../../common/interfaces/paginate"; 7 | import { GetWordsQuery } from "../impl"; 8 | 9 | @QueryHandler(GetWordsQuery) 10 | export class GetWordsHandler implements IQueryHandler { 11 | constructor( 12 | @InjectRepository(WordEntity) private readonly wordRepository: Repository 13 | ) { } 14 | async execute(query: GetWordsQuery): Promise | WordEntity [] > { 15 | const { getWordsRequestDto, userId } = query 16 | const { getAll, limit, page, search, sort, sortType } = getWordsRequestDto 17 | 18 | const queryBuilder = this.wordRepository.createQueryBuilder('word') 19 | .leftJoin('word.user', 'user') 20 | .andWhere('user.id = :userId', { userId }) 21 | 22 | if (sort && sortType) { 23 | queryBuilder.orderBy(sort, sortType); 24 | } 25 | if (search) { 26 | queryBuilder.andWhere('(word.word ILIKE :search)', { 27 | search: `%${search}%`, 28 | }); 29 | } 30 | 31 | if (getAll) { 32 | return await queryBuilder.getMany() 33 | } 34 | 35 | return await paginate(queryBuilder, limit, page); 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/queries/handler/get-words-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from "@nestjs/cqrs"; 2 | import { GetWordsBoxQuery } from "../impl"; 3 | import { InjectRepository } from "@nestjs/typeorm"; 4 | import { WordsBoxEntity } from "@src/entities"; 5 | import { Repository } from "typeorm"; 6 | import { paginate } from "@src/common/helper/paginate"; 7 | import { IPaginate } from "@src/common/interfaces/paginate"; 8 | 9 | @QueryHandler(GetWordsBoxQuery) 10 | export class GetWordsBoxHandler implements IQueryHandler { 11 | constructor( 12 | @InjectRepository(WordsBoxEntity) 13 | private readonly wordsBoxRepository: Repository 14 | ) { } 15 | async execute(query: GetWordsBoxQuery): Promise | WordsBoxEntity[]> { 16 | const { getWordsRequestDto, userId } = query 17 | const { getAll, sortType, sort, page, limit, search, } = getWordsRequestDto 18 | 19 | const queryBuilder = this.wordsBoxRepository.createQueryBuilder('wordsBox') 20 | .leftJoin('wordsBox.user', 'user').andWhere('user.id = :userId', { userId }) 21 | 22 | if (sort && sortType) { 23 | queryBuilder.orderBy(sort, sortType); 24 | } 25 | if (search) { 26 | queryBuilder.andWhere('(wordsBox.name ILIKE :search)', { 27 | search: `%${search}%`, 28 | }); 29 | } 30 | 31 | if (getAll) { 32 | return await queryBuilder.getMany() 33 | } 34 | 35 | return paginate(queryBuilder, limit, page); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/common/errors/index.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export interface ICustomError { 4 | status: number; 5 | description: string; 6 | id?: string; 7 | } 8 | 9 | export class CustomError extends HttpException { 10 | constructor({ description, status }: ICustomError) { 11 | super(description, status); 12 | } 13 | } 14 | export const INTERNAL_SERVER_ERROR: ICustomError = { 15 | status: HttpStatus.INTERNAL_SERVER_ERROR, 16 | description: 'internal server error', 17 | }; 18 | 19 | export const USER_NOT_FOUND : ICustomError = { 20 | status:HttpStatus.NOT_FOUND, 21 | description:'user not found' 22 | } 23 | export const WORDS_BOX_NOT_FOUND : ICustomError = { 24 | status:HttpStatus.NOT_FOUND, 25 | description:'words box not found' 26 | } 27 | 28 | export const WORDS_BOX_ALREADY_EXISTS : ICustomError = { 29 | description: "word already exists", 30 | status: HttpStatus.CONFLICT, 31 | } 32 | 33 | export const BOX_ALREADY_EXISTS : ICustomError = { 34 | description: "box already exists", 35 | status: HttpStatus.CONFLICT, 36 | } 37 | 38 | export const BOX_NOT_FOUND : ICustomError = { 39 | description: "box not found", 40 | status: HttpStatus.NOT_FOUND, 41 | } 42 | export const WORD_NOT_FOUND : ICustomError = { 43 | description: "word not found", 44 | status: HttpStatus.NOT_FOUND, 45 | } 46 | export const WORD_NOT_YOUR_BOX : ICustomError = { 47 | description: "this word is not your box", 48 | status: HttpStatus.NOT_FOUND, 49 | } 50 | export const WORDS_BOX_NOT_IN_YOUR_BOX : ICustomError = { 51 | description: "this words box is not in your box", 52 | status: HttpStatus.NOT_FOUND, 53 | } -------------------------------------------------------------------------------- /src/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | OneToMany, 6 | JoinTable, 7 | JoinColumn, 8 | } from 'typeorm'; 9 | 10 | import { PasswordTransformer } from '../common/helper/password.transformer'; 11 | import BaseModel from './base.model'; 12 | import { WordsBoxEntity } from './wordsBox.entity'; 13 | import { BoxEntity } from './box.entity'; 14 | import { WordEntity } from './word.entity'; 15 | import { IsUnique } from '@src/common/validator/unique.validator'; 16 | 17 | @Entity({ 18 | name: 'user', 19 | }) 20 | export class UserEntity extends BaseModel { 21 | @Column({ length: 255 }) 22 | firstName: string; 23 | 24 | @Column({ length: 255 }) 25 | lastName: string; 26 | // @IsUnique({ always: true, message: 'username already exists' }) 27 | @Column({ length: 255 }) 28 | email: string; 29 | 30 | @Column({ 31 | name: 'password', 32 | length: 255, 33 | // this is for the password encryption 34 | transformer: new PasswordTransformer(), 35 | }) 36 | password: string; 37 | 38 | // exclude password from the response 39 | toJSON() { 40 | const { password, ...self } = this; 41 | return self; 42 | } 43 | 44 | /* -------------------------------------------------------------------------- */ 45 | /* foreign key */ 46 | /* -------------------------------------------------------------------------- */ 47 | 48 | @OneToMany(() => BoxEntity, (box) => box.user,) 49 | box: BoxEntity[] 50 | 51 | @OneToMany(() => WordsBoxEntity, (wordsBox) => wordsBox.user) 52 | wordsBoxes: WordsBoxEntity[]; 53 | 54 | @OneToMany(() => WordEntity, (word) => word.user) 55 | words: WordEntity[]; 56 | } 57 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from './modules/auth'; 3 | import { CommonModule } from './common'; 4 | import { ConfigModule, ConfigService } from './config'; 5 | import { AppController } from './app.controller'; 6 | import { AppService } from './app.service'; 7 | import { DatabaseModule } from './common/database/database.module'; 8 | import { CqrsModule } from '@nestjs/cqrs'; 9 | import { WordModule } from './modules/word/word.module'; 10 | import { WordsBoxModule } from './modules/wordsBox/wordsBox.module'; 11 | import { BoxModule } from './modules/box/box.module'; 12 | import { ThrottlerGuard, ThrottlerModule, } from '@nestjs/throttler'; 13 | import { APP_GUARD } from '@nestjs/core'; 14 | import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; 15 | import { Redis } from 'ioredis'; 16 | 17 | @Module({ 18 | imports: [ 19 | DatabaseModule, 20 | ConfigModule, 21 | AuthModule, 22 | CommonModule, 23 | CqrsModule, 24 | WordModule, 25 | WordsBoxModule, 26 | BoxModule, 27 | ThrottlerModule.forRootAsync({ 28 | imports: [ConfigModule,], 29 | inject: [ConfigService], 30 | useFactory: (config: ConfigService,) => ({ 31 | throttlers: [ 32 | { 33 | limit: Number(config.get('THROTTLE_LIMIT')), 34 | ttl: Number(config.get('THROTTLE_TTL')), 35 | }, 36 | ], 37 | storage: new ThrottlerStorageRedisService(new Redis({ 38 | host: config.get('REDIS_HOST'), 39 | port: Number(config.get('REDIS_PORT')), 40 | })), 41 | }), 42 | }), 43 | ], 44 | controllers: [AppController], 45 | providers: [AppService, 46 | { 47 | provide: APP_GUARD, 48 | useClass: ThrottlerGuard 49 | }], 50 | }) 51 | export class AppModule { } 52 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; 3 | import { AuthService, LoginRequestDto, RegisterRequestDto } from './'; 4 | import { CurrentUser } from '../../common/decorator/current-user.decorator'; 5 | import { UsersService } from './../user'; 6 | import { UserEntity } from '../../entities'; 7 | import { JwtAuthGuard } from './../../common/guard/jwt-guard'; 8 | 9 | @Controller('/auth') 10 | @ApiTags('authentication') 11 | export class AuthController { 12 | constructor( 13 | private readonly authService: AuthService, 14 | private readonly userService: UsersService, 15 | ) {} 16 | 17 | @Post('login') 18 | @ApiResponse({ status: 201, description: 'Successful Login' }) 19 | @ApiResponse({ status: 400, description: 'Bad Request' }) 20 | @ApiResponse({ status: 401, description: 'Unauthorized' }) 21 | async login(@Body() loginRequestDto: LoginRequestDto): Promise { 22 | const user = await this.authService.validateUser(loginRequestDto); 23 | return await this.authService.createToken(user); 24 | } 25 | 26 | @Post('register') 27 | @ApiResponse({ status: 201, description: 'Successful Registration' }) 28 | @ApiResponse({ status: 400, description: 'Bad Request' }) 29 | @ApiResponse({ status: 401, description: 'Unauthorized' }) 30 | async register(@Body() registerRequestDto: RegisterRequestDto): Promise { 31 | const user = await this.userService.createUser(registerRequestDto); 32 | 33 | return await this.authService.createToken(user); 34 | } 35 | 36 | @ApiBearerAuth() 37 | @UseGuards(JwtAuthGuard) 38 | @Get('me') 39 | @ApiResponse({ status: 200, description: 'Successful Response' }) 40 | @ApiResponse({ status: 401, description: 'Unauthorized' }) 41 | async getLoggedInUser(@CurrentUser() user: UserEntity): Promise { 42 | return user; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/modules/box/queries/get-box-detail.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { TestingModule, Test } from "@nestjs/testing"; 3 | import { AppModule } from "@src/app.module"; 4 | import { ROUTES } from "@src/common/routes/routes"; 5 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 6 | import { createUser } from "@test/helper"; 7 | import { createBox } from "@test/helper/create-box.handler"; 8 | import { options } from "ormconfig"; 9 | import * as request from 'supertest'; 10 | import { EntityManager, DataSource } from "typeorm"; 11 | 12 | const URL = ROUTES.BOX.ROOT + ROUTES.BOX.GET_BOX_DETAIL.URL 13 | 14 | let dataSource: DataSource; 15 | describe(ROUTES.BOX.GET_BOX_DETAIL.DESCRIPTION, () => { 16 | let app: INestApplication; 17 | 18 | let manager: EntityManager; 19 | 20 | 21 | beforeAll(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | imports: [AppModule], 24 | }).compile(); 25 | app = module.createNestApplication(); 26 | await app.init(); 27 | dataSource = new DataSource(options); 28 | await dataSource.initialize(); 29 | manager = dataSource.manager; 30 | }); 31 | 32 | beforeEach(async () => { 33 | await dataSource.dropDatabase(); 34 | await dataSource.synchronize(); 35 | }); 36 | 37 | afterAll(async () => { 38 | await dataSource.destroy(); 39 | await app.close(); 40 | }); 41 | 42 | it("should get box by id", async () => { 43 | const { token, user } = await createUser(manager) 44 | const Box = await createBox(manager, { user }) 45 | const response = await request(app.getHttpServer()) 46 | .get(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.GET_BOX_DETAIL.PARAM]: Box.id })) 47 | .auth(token, { type: 'bearer' }) 48 | .expect(200) 49 | 50 | expect(response.body.id).toEqual(Box.id) 51 | expect(response.body.name).toEqual(Box.name) 52 | expect(response.body.wordsBoxes).toBeDefined() 53 | }) 54 | }); -------------------------------------------------------------------------------- /src/modules/box/commands/handler/update-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { UpdateBoxCommand } from "../impl"; 3 | import { DataSource, QueryRunner } from "typeorm"; 4 | import { GetUser } from "@src/modules/shared/functions"; 5 | import { GetBox } from "@src/modules/shared/functions/box.handler"; 6 | import { BOX_NOT_FOUND, CustomError } from "@src/common/errors"; 7 | import { BoxEntity } from "@src/entities"; 8 | 9 | @CommandHandler(UpdateBoxCommand) 10 | export class UpdateBoxHandler implements ICommandHandler { 11 | queryRunner: QueryRunner 12 | constructor( 13 | private dataSource: DataSource 14 | ) { } 15 | async execute(command: UpdateBoxCommand): Promise { 16 | const { userId, boxId, updateBoxRequestDto } = command 17 | const { name } = updateBoxRequestDto 18 | this.queryRunner = this.dataSource.createQueryRunner() 19 | try { 20 | await this.queryRunner.connect() 21 | await this.queryRunner.startTransaction() 22 | const manager = this.queryRunner.manager 23 | 24 | const user = await GetUser(manager, { id: userId }) 25 | const box = await GetBox(manager, { 26 | id: boxId, 27 | user: { 28 | id: user.id 29 | }, 30 | }) 31 | if (!box) { 32 | throw new CustomError(BOX_NOT_FOUND) 33 | } 34 | const updateBox = await this.updateBox(box, { name }) 35 | 36 | await this.queryRunner.commitTransaction() 37 | 38 | return Promise.resolve(updateBox) 39 | } catch (err) { 40 | await this.queryRunner.rollbackTransaction() 41 | throw err 42 | } finally { 43 | await this.queryRunner.release() 44 | } 45 | } 46 | async updateBox(box:Partial,prop:Partial) { 47 | Object.assign(box,{...prop}) 48 | return await this.queryRunner.manager.save(BoxEntity,box) 49 | } 50 | } -------------------------------------------------------------------------------- /src/modules/word/commands/handler/delete-word.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { DeleteWordCommand } from "../impl"; 3 | import { DataSource, QueryRunner } from "typeorm"; 4 | import { GetWord } from "@src/modules/shared/functions/word.helper"; 5 | import { CustomError, WORD_NOT_FOUND } from "@src/common/errors"; 6 | import { GetUser } from "@src/modules/shared/functions"; 7 | 8 | @CommandHandler(DeleteWordCommand) 9 | export class DeleteWordHandler implements ICommandHandler { 10 | queryRunner: QueryRunner 11 | constructor( 12 | private dataSource: DataSource 13 | ) { } 14 | async execute(command: DeleteWordCommand): Promise { 15 | this.queryRunner = this.dataSource.createQueryRunner() 16 | const { userId, wordId } = command 17 | try { 18 | /* -------------------------------------------------------------------------- */ 19 | /* start transaction */ 20 | /* -------------------------------------------------------------------------- */ 21 | await this.queryRunner.connect() 22 | await this.queryRunner.startTransaction() 23 | const user = await GetUser(this.queryRunner.manager,{id:userId}) 24 | /* -------------------------------- get word -------------------------------- */ 25 | const word = await GetWord(this.queryRunner.manager, { 26 | id: wordId, 27 | user: { 28 | id: user.id 29 | } 30 | }) 31 | if (!word) { 32 | throw new CustomError(WORD_NOT_FOUND) 33 | } 34 | 35 | /* ------------------------------- delete word ------------------------------ */ 36 | await this.queryRunner.manager.remove(word) 37 | await this.queryRunner.commitTransaction() 38 | 39 | return Promise.resolve({ 40 | is_deleted: true, 41 | wordId 42 | }) 43 | 44 | } catch (err) { 45 | await this.queryRunner.rollbackTransaction() 46 | throw err 47 | } finally { 48 | await this.queryRunner.release() 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /test/modules/word/commands/create-word.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { TestingModule, Test } from "@nestjs/testing"; 3 | import { AppModule } from "@src/app.module"; 4 | import { ROUTES } from "@src/common/routes/routes"; 5 | import { UserEntity,} from "@src/entities"; 6 | import { CreateWordRequestDto } from "@src/modules/word/dto"; 7 | import { createUser } from "@test/helper"; 8 | import { options } from "ormconfig"; 9 | import * as request from 'supertest'; 10 | import { EntityManager, DataSource } from "typeorm"; 11 | 12 | const URL = ROUTES.WORD.ROOT + ROUTES.WORD.CREATE_WORD.URL 13 | 14 | let dataSource: DataSource; 15 | describe(ROUTES.WORD.CREATE_WORD.DESCRIPTION, () => { 16 | let app: INestApplication; 17 | let manager: EntityManager; 18 | 19 | let createWordRequestDto: CreateWordRequestDto 20 | 21 | beforeAll(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | imports: [AppModule], 24 | }).compile(); 25 | app = module.createNestApplication(); 26 | await app.init(); 27 | dataSource = new DataSource(options); 28 | await dataSource.initialize(); 29 | manager = dataSource.manager; 30 | }); 31 | 32 | beforeEach(async () => { 33 | await dataSource.dropDatabase(); 34 | await dataSource.synchronize(); 35 | }); 36 | 37 | afterAll(async () => { 38 | await dataSource.destroy(); 39 | await app.close(); 40 | }); 41 | it("should create word", async () => { 42 | const { token, user } = await createUser(manager); 43 | createWordRequestDto = { 44 | definition: "test", 45 | example: "test", 46 | usage: "test", 47 | pronounce: "test", 48 | word: "test" 49 | } 50 | const response = await request(app.getHttpServer()) 51 | .post(URL) 52 | .auth(token, { type: 'bearer' }) 53 | .send(createWordRequestDto).expect(201) 54 | const getUser =await manager.findOne(UserEntity,{ 55 | where:{ 56 | id:user.id 57 | }, 58 | relations:{ 59 | words:true 60 | } 61 | }) 62 | expect(getUser.id).toEqual(response.body.user.id); 63 | expect(getUser.words[0].id).toEqual(response.body.id); 64 | expect(user.id).toEqual(response.body.user.id); 65 | expect(response.body.word).toEqual(createWordRequestDto.word); 66 | }) 67 | }); -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/handler/delete-words-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { DeleteWordsBoxCommand } from "../impl"; 3 | import { DataSource, QueryRunner } from "typeorm"; 4 | import { GetWordsBox } from "@src/modules/shared/functions/wordsBox.helper"; 5 | import { GetUser } from "@src/modules/shared/functions"; 6 | import { CustomError, WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 7 | 8 | @CommandHandler(DeleteWordsBoxCommand) 9 | export class DeleteWordsBoxHandler implements ICommandHandler { 10 | queryRunner: QueryRunner; 11 | constructor(private dataSource:DataSource){} 12 | async execute(command: DeleteWordsBoxCommand): Promise { 13 | this.queryRunner = this.dataSource.createQueryRunner(); 14 | const {userId,boxId: wordsBoxId} = command 15 | try { 16 | /* -------------------------------------------------------------------------- */ 17 | /* start transaction */ 18 | /* -------------------------------------------------------------------------- */ 19 | await this.queryRunner.connect() 20 | await this.queryRunner.startTransaction() 21 | /* -------------------------------- get user -------------------------------- */ 22 | const user= await GetUser(this.queryRunner.manager,{id:userId}) 23 | /* ------------------------------ get words box ----------------------------- */ 24 | const wordsBox = await GetWordsBox(this.queryRunner.manager,{id:wordsBoxId,user:{ 25 | id:user.id 26 | }}) 27 | if(!wordsBox){ 28 | throw new CustomError(WORDS_BOX_NOT_FOUND) 29 | } 30 | /* ----------------------------- delete wordsBox ---------------------------- */ 31 | await this.queryRunner.manager.remove(wordsBox) 32 | 33 | await this.queryRunner.commitTransaction() 34 | return { 35 | is_deleted:true, 36 | id:wordsBoxId 37 | } 38 | } catch (err) { 39 | await this.queryRunner.rollbackTransaction() 40 | throw err 41 | }finally{ 42 | await this.queryRunner.release() 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/modules/box/commands/handler/delete-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { DeleteBoxCommand } from "../impl"; 3 | import { DataSource, QueryRunner } from "typeorm"; 4 | import { GetUser } from "@src/modules/shared/functions"; 5 | import { GetBox } from "@src/modules/shared/functions/box.handler"; 6 | import { CustomError, BOX_NOT_FOUND } from "@src/common/errors"; 7 | import { BoxEntity } from "@src/entities"; 8 | 9 | @CommandHandler(DeleteBoxCommand) 10 | export class DeleteBoxHandler implements ICommandHandler { 11 | queryRunner: QueryRunner; 12 | constructor(private dataSource: DataSource) { } 13 | async execute(command: DeleteBoxCommand) { 14 | this.queryRunner = this.dataSource.createQueryRunner() 15 | const { boxId, userId } = command 16 | try { 17 | /* -------------------------------------------------------------------------- */ 18 | /* start Transaction */ 19 | /* -------------------------------------------------------------------------- */ 20 | await this.queryRunner.connect(); 21 | await this.queryRunner.startTransaction(); 22 | const manager = this.queryRunner.manager; 23 | /* -------------------------------- get user -------------------------------- */ 24 | const user = await GetUser(manager, { id: userId }) 25 | /* --------------------------------- get box -------------------------------- */ 26 | const getBox = await GetBox(manager, { 27 | id: boxId, 28 | user: { 29 | id: user.id 30 | } 31 | }) 32 | if (!getBox) { 33 | throw new CustomError(BOX_NOT_FOUND) 34 | } 35 | /* ------------------------------- delete box ------------------------------- */ 36 | await this.queryRunner.manager.remove(BoxEntity, getBox) 37 | await this.queryRunner.commitTransaction() 38 | return { 39 | is_deleted: true, 40 | id:boxId, 41 | } 42 | } catch (error) { 43 | await this.queryRunner.rollbackTransaction() 44 | throw error 45 | } finally { 46 | await this.queryRunner.release(); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/modules/box/commands/handler/create-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { CreateBoxCommand } from "../impl"; 3 | import { QueryRunner, DataSource } from "typeorm"; 4 | import { BoxEntity } from "@src/entities"; 5 | import { GetUser } from "@src/modules/shared/functions"; 6 | import { GetBox } from "@src/modules/shared/functions/box.handler"; 7 | import { BOX_ALREADY_EXISTS, CustomError } from "@src/common/errors"; 8 | 9 | @CommandHandler(CreateBoxCommand) 10 | export class CreateBoxHandler implements ICommandHandler { 11 | queryRunner: QueryRunner; 12 | constructor(private dataSource: DataSource) { } 13 | async execute(command: CreateBoxCommand): Promise { 14 | this.queryRunner = this.dataSource.createQueryRunner() 15 | 16 | const { createBoxRequestDto, userId } = command 17 | const { name } = createBoxRequestDto 18 | 19 | try { 20 | /* -------------------------------------------------------------------------- */ 21 | /* start Transaction */ 22 | /* -------------------------------------------------------------------------- */ 23 | await this.queryRunner.connect(); 24 | await this.queryRunner.startTransaction(); 25 | const manager = this.queryRunner.manager; 26 | /* -------------------------------- get user -------------------------------- */ 27 | const user = await GetUser(manager, { id: userId }) 28 | /* --------------------------------- get box -------------------------------- */ 29 | const getBox = await GetBox(manager, { 30 | name, 31 | user: { 32 | id: userId 33 | } 34 | }) 35 | if (getBox) { 36 | throw new CustomError(BOX_ALREADY_EXISTS) 37 | } 38 | /* ------------------------------- create box ------------------------------- */ 39 | const box = await this.queryRunner.manager.save(BoxEntity, { 40 | name, 41 | user 42 | }) 43 | 44 | await this.queryRunner.commitTransaction(); 45 | return Promise.resolve(box) 46 | } catch (err) { 47 | await this.queryRunner.rollbackTransaction() 48 | throw err 49 | } finally { 50 | await this.queryRunner.release(); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/modules/word/commands/handler/create-word.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { CreateWordCommand } from "../impl"; 3 | import { DataSource, QueryRunner } from "typeorm"; 4 | import { WordEntity } from "@src/entities"; 5 | import { GetUser } from "@src/modules/shared/functions"; 6 | 7 | @CommandHandler(CreateWordCommand) 8 | export class CreateWordHandler implements ICommandHandler { 9 | queryRunner: QueryRunner; 10 | constructor(private dataSource: DataSource) { } 11 | async execute(command: CreateWordCommand): Promise { 12 | 13 | const { userId, createWordRequestDto } = command; 14 | const { definition, usage, pronounce, word, example } = createWordRequestDto; 15 | 16 | this.queryRunner = this.dataSource.createQueryRunner(); 17 | try { 18 | /* -------------------------------------------------------------------------- */ 19 | /* start Transaction */ 20 | /* -------------------------------------------------------------------------- */ 21 | await this.queryRunner.connect(); 22 | await this.queryRunner.startTransaction(); 23 | const manager = this.queryRunner.manager; 24 | /* -------------------------------------------------------------------------- */ 25 | /* get user */ 26 | /* -------------------------------------------------------------------------- */ 27 | const user = await GetUser(manager,{ id: userId }) 28 | /* -------------------------------------------------------------------------- */ 29 | /* create word */ 30 | /* -------------------------------------------------------------------------- */ 31 | const createWord = await this.queryRunner.manager.save(WordEntity, { 32 | definition, 33 | usage, 34 | pronounce, 35 | example, 36 | word, 37 | user 38 | }) 39 | 40 | await this.queryRunner.commitTransaction(); 41 | return Promise.resolve(createWord); 42 | } catch (err) { 43 | await this.queryRunner.rollbackTransaction() 44 | throw err 45 | } finally { 46 | await this.queryRunner.release(); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/modules/word/commands/handler/update-word.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { QueryRunner, DataSource } from "typeorm"; 3 | import { GetWord } from "@src/modules/shared/functions/word.helper"; 4 | import { UpdateWordCommand } from "../impl"; 5 | import { WordEntity } from "@src/entities"; 6 | import { GetUser } from "@src/modules/shared/functions"; 7 | import { CustomError, WORD_NOT_FOUND } from "@src/common/errors"; 8 | 9 | @CommandHandler(UpdateWordCommand) 10 | export class UpdateWord implements ICommandHandler { 11 | queryRunner: QueryRunner; 12 | constructor(private dataSource: DataSource) { } 13 | async execute(command: UpdateWordCommand): Promise { 14 | const { updateWordRequestDto, wordId, userId } = command 15 | const { definition, usage, pronounce, example, word } = updateWordRequestDto 16 | this.queryRunner = this.dataSource.createQueryRunner(); 17 | try { 18 | /* -------------------------------------------------------------------------- */ 19 | /* start Transaction */ 20 | /* -------------------------------------------------------------------------- */ 21 | await this.queryRunner.connect(); 22 | await this.queryRunner.startTransaction(); 23 | const manager = this.queryRunner.manager; 24 | const user = await GetUser(manager, { id: userId }) 25 | /* -------------------------------- get word -------------------------------- */ 26 | const getWord = await GetWord(manager, { 27 | id: wordId, 28 | user: { 29 | id: user.id 30 | } 31 | }) 32 | if(!getWord) { 33 | throw new CustomError(WORD_NOT_FOUND) 34 | } 35 | /* ------------------------------- update word ------------------------------ */ 36 | const updateWord = await this.updateWord(getWord, { definition, usage, pronounce, example, word }) 37 | 38 | await this.queryRunner.commitTransaction(); 39 | return Promise.resolve(updateWord); 40 | } catch (err) { 41 | await this.queryRunner.rollbackTransaction() 42 | throw err 43 | } finally { 44 | await this.queryRunner.release(); 45 | } 46 | } 47 | async updateWord(word: Partial, prop: Partial) { 48 | Object.assign(word, { ...prop }) 49 | return await this.queryRunner.manager.save(WordEntity, word) 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/handler/update-words-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { UpdateWordsBoxCommand } from "../impl"; 3 | import { DataSource, QueryRunner } from "typeorm"; 4 | import { GetWordsBox } from "@src/modules/shared/functions/wordsBox.helper"; 5 | import { CustomError, WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 6 | import { WordsBoxEntity } from "@src/entities"; 7 | 8 | @CommandHandler(UpdateWordsBoxCommand) 9 | export class UpdateWordsBoxHandler implements ICommandHandler { 10 | queryRunner: QueryRunner 11 | constructor(private dataSource: DataSource) { } 12 | async execute(command: UpdateWordsBoxCommand): Promise { 13 | const { boxId, userId, updateWordsBoxRequestDto } = command 14 | const { is_learned, name } = updateWordsBoxRequestDto 15 | this.queryRunner = this.dataSource.createQueryRunner() 16 | try { 17 | /* -------------------------------------------------------------------------- */ 18 | /* start transaction */ 19 | /* -------------------------------------------------------------------------- */ 20 | await this.queryRunner.connect() 21 | await this.queryRunner.startTransaction() 22 | /* ------------------------------ get words box ----------------------------- */ 23 | const wordsBox = await GetWordsBox(this.queryRunner.manager, { 24 | id: boxId, 25 | user: { id: userId } 26 | }) 27 | if (!wordsBox) { 28 | throw new CustomError(WORDS_BOX_NOT_FOUND) 29 | } 30 | /* ------------------------------ update words box -------------------------- */ 31 | if (is_learned) { 32 | wordsBox.markWordAsLearned() 33 | } 34 | const updateWordsBox = await this.updateWordsBox(wordsBox, { 35 | name: name ? name : wordsBox.name, 36 | is_learned 37 | }) 38 | await this.queryRunner.commitTransaction() 39 | return Promise.resolve(updateWordsBox) 40 | } catch (err) { 41 | await this.queryRunner.rollbackTransaction() 42 | throw err 43 | } finally { 44 | this.queryRunner.release() 45 | } 46 | } 47 | async updateWordsBox(wordsBox: Partial, prop: Partial) { 48 | Object.assign(wordsBox, { ...prop }) 49 | return await this.queryRunner.manager.save(WordsBoxEntity, wordsBox) 50 | } 51 | } -------------------------------------------------------------------------------- /test/modules/word/queries/get-word-query.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORD_NOT_FOUND } from "@src/common/errors"; 6 | import { IPaginate } from "@src/common/interfaces/paginate"; 7 | import { ROUTES } from "@src/common/routes/routes"; 8 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 9 | import { WordEntity } from "@src/entities"; 10 | import { createUser, createWord } from "@test/helper"; 11 | import { options } from "ormconfig"; 12 | import * as request from 'supertest'; 13 | import { EntityManager, DataSource } from "typeorm"; 14 | 15 | const URL = ROUTES.WORD.ROOT + ROUTES.WORD.GET_WORD_BY_ID.URL 16 | 17 | let dataSource: DataSource; 18 | describe(ROUTES.WORD.GET_WORD_BY_ID.DESCRIPTION, () => { 19 | let app: INestApplication; 20 | let manager: EntityManager; 21 | 22 | beforeAll(async () => { 23 | const module: TestingModule = await Test.createTestingModule({ 24 | imports: [AppModule], 25 | }).compile(); 26 | app = module.createNestApplication(); 27 | await app.init(); 28 | dataSource = new DataSource(options); 29 | await dataSource.initialize(); 30 | manager = dataSource.manager; 31 | }); 32 | 33 | beforeEach(async () => { 34 | await dataSource.dropDatabase(); 35 | await dataSource.synchronize(); 36 | }); 37 | 38 | afterAll(async () => { 39 | await dataSource.destroy(); 40 | await app.close(); 41 | }); 42 | it("should get word by id", async () => { 43 | const { token, user } = await createUser(manager); 44 | const word = await createWord(manager, { user }) 45 | 46 | const response = await request(app.getHttpServer()) 47 | .get(URL_REPLACE_PARAMS(URL, { [ROUTES.WORD.GET_WORD_BY_ID.PARAM]: word.id })) 48 | .auth(token, { type: 'bearer' }) 49 | .expect(200) 50 | 51 | expect(response.body.id).toEqual(word.id); 52 | }) 53 | it("should throw error WORD_NOT_FOUND", async () => { 54 | const { token } = await createUser(manager); 55 | 56 | const response = await request(app.getHttpServer()) 57 | .get(URL_REPLACE_PARAMS(URL, { [ROUTES.WORD.GET_WORD_BY_ID.PARAM]: faker.string.uuid() })) 58 | .auth(token, { type: 'bearer' }) 59 | .expect(404) 60 | 61 | expect(response.statusCode).toEqual(WORD_NOT_FOUND.status); 62 | expect(response.body.message).toEqual(WORD_NOT_FOUND.description); 63 | expect(response.body.statusCode).toEqual(WORD_NOT_FOUND.status); 64 | }) 65 | }); -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/handler/create-words-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { DataSource, QueryRunner } from "typeorm"; 3 | import { WordsBoxEntity } from "@src/entities"; 4 | import { CustomError, WORDS_BOX_ALREADY_EXISTS } from "@src/common/errors"; 5 | import { CreateWordsBoxCommand } from "../impl"; 6 | import { GetUser } from "@src/modules/shared/functions"; 7 | import { GetWordsBox } from "@src/modules/shared/functions/wordsBox.helper"; 8 | 9 | @CommandHandler(CreateWordsBoxCommand) 10 | export class CreateWordsBoxHandler implements ICommandHandler { 11 | queryRunner: QueryRunner; 12 | constructor(private dataSource: DataSource) { } 13 | async execute(command: CreateWordsBoxCommand): Promise { 14 | const { userId, createWordsBoxRequestDto } = command; 15 | const { name } = createWordsBoxRequestDto; 16 | this.queryRunner = this.dataSource.createQueryRunner(); 17 | 18 | try { 19 | /* -------------------------------------------------------------------------- */ 20 | /* start transaction */ 21 | /* -------------------------------------------------------------------------- */ 22 | await this.queryRunner.connect(); 23 | await this.queryRunner.startTransaction(); 24 | const manager = this.queryRunner.manager; 25 | /* -------------------------------- get user -------------------------------- */ 26 | const user = await GetUser(manager, { id: userId }) 27 | /* ------------------------------ get words box ----------------------------- */ 28 | const wordsBox = await GetWordsBox(manager, { 29 | name, user:{ 30 | id:user.id 31 | } 32 | }) 33 | if (wordsBox) { 34 | throw new CustomError(WORDS_BOX_ALREADY_EXISTS) 35 | } 36 | /* ---------------------------- create words box ---------------------------- */ 37 | const createWordsBox = await this.createWordsBox({ 38 | name, 39 | user 40 | }) 41 | 42 | await this.queryRunner.commitTransaction(); 43 | 44 | return Promise.resolve(createWordsBox) 45 | } catch (err) { 46 | await this.queryRunner.rollbackTransaction() 47 | throw err 48 | } finally { 49 | await this.queryRunner.release(); 50 | } 51 | } 52 | async createWordsBox(wordsBox: Partial) { 53 | return await this.queryRunner.manager.save(WordsBoxEntity, wordsBox); 54 | } 55 | } -------------------------------------------------------------------------------- /test/modules/wordsbox/queries/get-words-box-detail.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { createUser, createWord } from "@test/helper"; 9 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 10 | import { options } from "ormconfig"; 11 | import * as request from 'supertest'; 12 | import { EntityManager, DataSource } from "typeorm"; 13 | 14 | const URL = ROUTES.WORDS_BOX.ROOT + ROUTES.WORDS_BOX.GET_WORDS_BOX_DETAIL.URL 15 | 16 | let dataSource: DataSource; 17 | describe(ROUTES.WORDS_BOX.GET_WORDS_BOX_DETAIL.DESCRIPTION, () => { 18 | let app: INestApplication; 19 | let manager: EntityManager; 20 | 21 | beforeAll(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | imports: [AppModule], 24 | }).compile(); 25 | app = module.createNestApplication(); 26 | await app.init(); 27 | dataSource = new DataSource(options); 28 | await dataSource.initialize(); 29 | manager = dataSource.manager; 30 | }); 31 | 32 | beforeEach(async () => { 33 | await dataSource.dropDatabase(); 34 | await dataSource.synchronize(); 35 | }); 36 | 37 | afterAll(async () => { 38 | await dataSource.destroy(); 39 | await app.close(); 40 | }); 41 | 42 | it("should get words box detail by id", async () => { 43 | const { token, user } = await createUser(manager) 44 | const word = await createWord(manager, { user }) 45 | const wordsBox = await createWordsBox(manager, { user,words: [word] }) 46 | 47 | const response = await request(app.getHttpServer()) 48 | .get(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.GET_WORDS_BOX_DETAIL.PARAM]: wordsBox.id })) 49 | .auth(token, { type: 'bearer' }) 50 | 51 | expect(response.body.id).toEqual(wordsBox.id) 52 | expect(response.body.words[0].id).toEqual(word.id) 53 | 54 | }) 55 | it('should throw error when WORDS_BOX_NOT_FOUND', async () => { 56 | const { token, user } = await createUser(manager) 57 | 58 | const response = await request(app.getHttpServer()) 59 | .get(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.GET_WORDS_BOX_DETAIL.PARAM]:faker.string.uuid()})) 60 | .auth(token, { type: 'bearer' }) 61 | 62 | expect(response.status).toEqual(404) 63 | expect(response.body.message).toEqual(WORDS_BOX_NOT_FOUND.description) 64 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_FOUND.status) 65 | }) 66 | }); -------------------------------------------------------------------------------- /test/modules/word/commands/delete-word.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORD_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { WordEntity } from "@src/entities"; 9 | import { createWord } from "@test/helper"; 10 | import { createUser } from "@test/helper/createUser.helper"; 11 | import { options } from "ormconfig"; 12 | import * as request from 'supertest'; 13 | import { EntityManager, DataSource } from "typeorm"; 14 | 15 | const URL = ROUTES.WORD.ROOT + ROUTES.WORD.DELETE_WORD_BY_ID.URL 16 | 17 | let dataSource: DataSource; 18 | describe(ROUTES.WORD.DELETE_WORD_BY_ID.DESCRIPTION, () => { 19 | let app: INestApplication; 20 | let manager: EntityManager; 21 | 22 | beforeAll(async () => { 23 | const module: TestingModule = await Test.createTestingModule({ 24 | imports: [AppModule], 25 | }).compile(); 26 | app = module.createNestApplication(); 27 | await app.init(); 28 | dataSource = new DataSource(options); 29 | await dataSource.initialize(); 30 | manager = dataSource.manager; 31 | }); 32 | 33 | beforeEach(async () => { 34 | await dataSource.dropDatabase(); 35 | await dataSource.synchronize(); 36 | }); 37 | 38 | afterAll(async () => { 39 | await dataSource.destroy(); 40 | await app.close(); 41 | }); 42 | it("should delete word", async () => { 43 | const { token, user } = await createUser(manager); 44 | const word = await createWord(manager, { user }) 45 | 46 | const response = await request(app.getHttpServer()) 47 | .delete(URL_REPLACE_PARAMS(URL,{[ROUTES.WORD.DELETE_WORD_BY_ID.PARAM]:word.id})) 48 | .auth(token, { type: 'bearer' }) 49 | 50 | const getWord = await manager.findOne(WordEntity, { where: { id: word.id } }) 51 | 52 | expect(response.body.wordId).toEqual(word.id) 53 | expect(word.id).toEqual(response.body.wordId); 54 | expect(response.body.is_deleted).toEqual(true); 55 | expect(getWord).toBeNull() 56 | }) 57 | 58 | it("should throw error WORD_NOT_FOUND", async () => { 59 | const { token } = await createUser(manager); 60 | const response = await request(app.getHttpServer()) 61 | .delete(URL_REPLACE_PARAMS(URL,{[ROUTES.WORD.DELETE_WORD_BY_ID.PARAM]:faker.string.uuid()})) 62 | .auth(token, { type: 'bearer' }) 63 | 64 | expect(response.status).toEqual(404) 65 | expect(response.body.message).toEqual(WORD_NOT_FOUND.description) 66 | expect(response.body.statusCode).toEqual(WORD_NOT_FOUND.status) 67 | }) 68 | }); -------------------------------------------------------------------------------- /test/modules/box/commands/delete-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { BOX_ALREADY_EXISTS, BOX_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { CreateBoxRequestDto } from "@src/modules/box/dto"; 10 | import { createUser } from "@test/helper"; 11 | import { createBox } from "@test/helper/create-box.handler"; 12 | import { options } from "ormconfig"; 13 | import * as request from 'supertest'; 14 | import { EntityManager, DataSource } from "typeorm"; 15 | 16 | const URL = ROUTES.BOX.ROOT + ROUTES.BOX.DELETE_BOX.URL 17 | 18 | let dataSource: DataSource; 19 | describe(ROUTES.BOX.CREATE_BOX.DESCRIPTION, () => { 20 | let app: INestApplication; 21 | let manager: EntityManager; 22 | 23 | 24 | beforeAll(async () => { 25 | const module: TestingModule = await Test.createTestingModule({ 26 | imports: [AppModule], 27 | }).compile(); 28 | app = module.createNestApplication(); 29 | await app.init(); 30 | dataSource = new DataSource(options); 31 | await dataSource.initialize(); 32 | manager = dataSource.manager; 33 | }); 34 | 35 | beforeEach(async () => { 36 | await dataSource.dropDatabase(); 37 | await dataSource.synchronize(); 38 | }); 39 | 40 | afterAll(async () => { 41 | await dataSource.destroy(); 42 | await app.close(); 43 | }); 44 | 45 | it("should delete box", async () => { 46 | const { token, user } = await createUser(manager) 47 | 48 | const box = await createBox(manager, { user }) 49 | 50 | const response = await request(app.getHttpServer()) 51 | .delete(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.DELETE_BOX.PARAM]: box.id })) 52 | .auth(token, { type: 'bearer' }) 53 | 54 | const getUser = await manager.findOne(UserEntity, { 55 | where: { id: user.id, }, relations: { 56 | box: true 57 | } 58 | }) 59 | expect(response.body.is_deleted).toEqual(true) 60 | expect(response.body.id).toEqual(box.id) 61 | expect(getUser.box).toHaveProperty("length", 0) 62 | }) 63 | it('should throw error BOX_NOT_FOUND', async () => { 64 | const { token, user } = await createUser(manager) 65 | 66 | const response = await request(app.getHttpServer()) 67 | .delete(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.DELETE_BOX.PARAM]: faker.string.uuid() })) 68 | .auth(token, { type: 'bearer' }) 69 | 70 | expect(response.body.statusCode).toEqual(BOX_NOT_FOUND.status) 71 | expect(response.body.message).toEqual(BOX_NOT_FOUND.description) 72 | }) 73 | }); -------------------------------------------------------------------------------- /test/modules/word/queries/get-words-query.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { TestingModule, Test } from "@nestjs/testing"; 3 | import { AppModule } from "@src/app.module"; 4 | import { IPaginate } from "@src/common/interfaces/paginate"; 5 | import { ROUTES } from "@src/common/routes/routes"; 6 | import { WordEntity } from "@src/entities"; 7 | import { GetWordsRequestDto } from "@src/modules/wordsBox/dto"; 8 | import { createUser, createWord } from "@test/helper"; 9 | import { options } from "ormconfig"; 10 | import * as request from 'supertest'; 11 | import { EntityManager, DataSource } from "typeorm"; 12 | 13 | const URL = ROUTES.WORD.ROOT + ROUTES.WORD.GET_WORDS.URL 14 | 15 | let dataSource: DataSource; 16 | describe(ROUTES.WORD.GET_WORDS.DESCRIPTION, () => { 17 | let query: GetWordsRequestDto 18 | let app: INestApplication; 19 | let manager: EntityManager; 20 | 21 | beforeAll(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | imports: [AppModule], 24 | }).compile(); 25 | app = module.createNestApplication(); 26 | await app.init(); 27 | dataSource = new DataSource(options); 28 | await dataSource.initialize(); 29 | manager = dataSource.manager; 30 | }); 31 | 32 | beforeEach(async () => { 33 | await dataSource.dropDatabase(); 34 | await dataSource.synchronize(); 35 | }); 36 | 37 | afterAll(async () => { 38 | await dataSource.destroy(); 39 | await app.close(); 40 | }); 41 | it("should get all words", async () => { 42 | const { token, user } = await createUser(manager); 43 | const count = 15 44 | for (let i = 0; i < count; i++) { 45 | const word = await createWord(manager, { user }) 46 | } 47 | 48 | query = { 49 | limit: 5, 50 | page: 2, 51 | } 52 | 53 | const response = await request(app.getHttpServer()) 54 | .get(URL) 55 | .auth(token, { type: 'bearer' }).query(query) 56 | .expect(200) 57 | 58 | const { items, meta } = response.body as IPaginate 59 | 60 | expect(meta.totalItems).toEqual(count) 61 | expect(meta.itemCount).toEqual(items.length) 62 | expect(meta.currentPage).toEqual(query.page) 63 | expect(meta.itemsPerPage).toEqual(query.limit) 64 | expect(meta.totalPages).toEqual(Math.ceil(count / query.limit)); 65 | expect(meta.currentPage).toEqual(query.page) 66 | }) 67 | it("should search words", async () => { 68 | const { token, user } = await createUser(manager); 69 | const word = await createWord(manager, { user }) 70 | query = { 71 | search: word.word 72 | } 73 | 74 | const response = await request(app.getHttpServer()) 75 | .get(URL) 76 | .auth(token, { type: 'bearer' }).query(query) 77 | .expect(200) 78 | 79 | const { items, meta } = response.body as IPaginate 80 | 81 | expect(items[0].word).toEqual(query.search) 82 | expect(meta.totalItems).toEqual(items.length) 83 | }) 84 | }); -------------------------------------------------------------------------------- /test/modules/box/commands/create-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { TestingModule, Test } from "@nestjs/testing"; 3 | import { AppModule } from "@src/app.module"; 4 | import { BOX_ALREADY_EXISTS } from "@src/common/errors"; 5 | import { ROUTES } from "@src/common/routes/routes"; 6 | import { UserEntity, } from "@src/entities"; 7 | import { CreateBoxRequestDto } from "@src/modules/box/dto"; 8 | import { createUser } from "@test/helper"; 9 | import { createBox } from "@test/helper/create-box.handler"; 10 | import { options } from "ormconfig"; 11 | import * as request from 'supertest'; 12 | import { EntityManager, DataSource } from "typeorm"; 13 | 14 | const URL = ROUTES.BOX.ROOT + ROUTES.BOX.CREATE_BOX.URL 15 | 16 | let dataSource: DataSource; 17 | describe(ROUTES.BOX.CREATE_BOX.DESCRIPTION, () => { 18 | let app: INestApplication; 19 | let manager: EntityManager; 20 | 21 | let createBoxRequestDto: CreateBoxRequestDto 22 | 23 | beforeAll(async () => { 24 | const module: TestingModule = await Test.createTestingModule({ 25 | imports: [AppModule], 26 | }).compile(); 27 | app = module.createNestApplication(); 28 | await app.init(); 29 | dataSource = new DataSource(options); 30 | await dataSource.initialize(); 31 | manager = dataSource.manager; 32 | }); 33 | 34 | beforeEach(async () => { 35 | await dataSource.dropDatabase(); 36 | await dataSource.synchronize(); 37 | }); 38 | 39 | afterAll(async () => { 40 | await dataSource.destroy(); 41 | await app.close(); 42 | }); 43 | 44 | it("should create box", async () => { 45 | const { token, user } = await createUser(manager) 46 | 47 | createBoxRequestDto = { 48 | name: "test" 49 | } 50 | 51 | const response = await request(app.getHttpServer()) 52 | .post(URL) 53 | .auth(token, { type: 'bearer' }) 54 | .send(createBoxRequestDto).expect(201) 55 | 56 | const getUser = await manager.findOne(UserEntity, { 57 | where: { id: user.id, }, relations: { 58 | box: true 59 | } 60 | }) 61 | 62 | expect(response.body.id).toEqual(getUser.box[0].id) 63 | expect(getUser.box[0].name).toEqual(createBoxRequestDto.name) 64 | 65 | }) 66 | it('should throw error BOX_ALREADY_EXISTS', async () => { 67 | const { token, user } = await createUser(manager) 68 | 69 | await createBox(manager, { user, name: "test" }) 70 | 71 | createBoxRequestDto = { 72 | name: "test" 73 | } 74 | const response = await request(app.getHttpServer()) 75 | .post(URL) 76 | .auth(token, { type: 'bearer' }) 77 | .send(createBoxRequestDto).expect(409) 78 | 79 | expect(response.body.statusCode).toEqual(BOX_ALREADY_EXISTS.status) 80 | expect(response.body.message).toEqual(BOX_ALREADY_EXISTS.description) 81 | 82 | }) 83 | }); -------------------------------------------------------------------------------- /test/modules/wordsbox/command/create-words-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { TestingModule, Test } from "@nestjs/testing"; 3 | import { AppModule } from "@src/app.module"; 4 | import { BOX_ALREADY_EXISTS, WORDS_BOX_ALREADY_EXISTS } from "@src/common/errors"; 5 | import { ROUTES } from "@src/common/routes/routes"; 6 | import { UserEntity, } from "@src/entities"; 7 | import { CreateWordsBoxRequestDto } from "@src/modules/wordsBox/dto"; 8 | import { createUser } from "@test/helper"; 9 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 10 | import { options } from "ormconfig"; 11 | import * as request from 'supertest'; 12 | import { EntityManager, DataSource } from "typeorm"; 13 | 14 | const URL = ROUTES.WORDS_BOX.ROOT + ROUTES.WORDS_BOX.CREATE_WORDS_BOX.URL 15 | 16 | let dataSource: DataSource; 17 | describe(ROUTES.WORDS_BOX.CREATE_WORDS_BOX.DESCRIPTION, () => { 18 | let app: INestApplication; 19 | let manager: EntityManager; 20 | 21 | let createWordsBoxRequestDto: CreateWordsBoxRequestDto 22 | 23 | beforeAll(async () => { 24 | const module: TestingModule = await Test.createTestingModule({ 25 | imports: [AppModule], 26 | }).compile(); 27 | app = module.createNestApplication(); 28 | await app.init(); 29 | dataSource = new DataSource(options); 30 | await dataSource.initialize(); 31 | manager = dataSource.manager; 32 | }); 33 | 34 | beforeEach(async () => { 35 | await dataSource.dropDatabase(); 36 | await dataSource.synchronize(); 37 | }); 38 | 39 | afterAll(async () => { 40 | await dataSource.destroy(); 41 | await app.close(); 42 | }); 43 | 44 | it("should create words box", async () => { 45 | const { token, user } = await createUser(manager) 46 | 47 | createWordsBoxRequestDto = { 48 | name: "test" 49 | } 50 | 51 | const response = await request(app.getHttpServer()) 52 | .post(URL) 53 | .auth(token, { type: 'bearer' }) 54 | .send(createWordsBoxRequestDto).expect(201) 55 | 56 | const getUser = await manager.findOne(UserEntity, { 57 | where: { 58 | id: user.id 59 | }, 60 | relations: { 61 | wordsBoxes: true 62 | } 63 | }) 64 | 65 | expect(getUser.wordsBoxes[0].id).toEqual(response.body.id) 66 | expect(getUser.id).toEqual(response.body.user.id) 67 | expect(response.body.name).toEqual(createWordsBoxRequestDto.name) 68 | }) 69 | it('should throw error WORDS_BOX_ALREADY_EXISTS', async () => { 70 | const { token, user } = await createUser(manager) 71 | 72 | createWordsBoxRequestDto = { 73 | name: "test" 74 | } 75 | const wordsBox = await createWordsBox(manager, { name: createWordsBoxRequestDto.name, user, }) 76 | 77 | const response = await request(app.getHttpServer()) 78 | .post(URL) 79 | .auth(token, { type: 'bearer' }) 80 | .send(createWordsBoxRequestDto).expect(409) 81 | 82 | expect(response.body.statusCode).toEqual(WORDS_BOX_ALREADY_EXISTS.status) 83 | expect(response.body.message).toEqual(WORDS_BOX_ALREADY_EXISTS.description) 84 | 85 | }) 86 | }); -------------------------------------------------------------------------------- /test/modules/wordsbox/command/delete-words-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { createUser } from "@test/helper"; 10 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 11 | import { options } from "ormconfig"; 12 | import * as request from 'supertest'; 13 | import { EntityManager, DataSource } from "typeorm"; 14 | 15 | const URL = ROUTES.WORDS_BOX.ROOT + ROUTES.WORDS_BOX.DELETE_WORDS_BOX.URL 16 | 17 | let dataSource: DataSource; 18 | describe(ROUTES.WORDS_BOX.DELETE_WORDS_BOX.DESCRIPTION, () => { 19 | let app: INestApplication; 20 | let manager: EntityManager; 21 | 22 | 23 | 24 | beforeAll(async () => { 25 | const module: TestingModule = await Test.createTestingModule({ 26 | imports: [AppModule], 27 | }).compile(); 28 | app = module.createNestApplication(); 29 | await app.init(); 30 | dataSource = new DataSource(options); 31 | await dataSource.initialize(); 32 | manager = dataSource.manager; 33 | }); 34 | 35 | beforeEach(async () => { 36 | await dataSource.dropDatabase(); 37 | await dataSource.synchronize(); 38 | }); 39 | 40 | afterAll(async () => { 41 | await dataSource.destroy(); 42 | await app.close(); 43 | }); 44 | 45 | it("should delete words box", async () => { 46 | const { token, user } = await createUser(manager) 47 | 48 | const wordsBox = await createWordsBox(manager, { user }) 49 | 50 | const response = await request(app.getHttpServer()) 51 | .delete(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.DELETE_WORDS_BOX.PARAM]: wordsBox.id })) 52 | .auth(token, { type: 'bearer' }) 53 | 54 | const getUser = await manager.findOne(UserEntity, { 55 | where: { id: user.id }, 56 | relations: { 57 | wordsBoxes: true 58 | } 59 | }) 60 | 61 | expect(response.body.id).toEqual(wordsBox.id) 62 | expect(response.body.is_deleted).toEqual(true) 63 | expect(getUser.wordsBoxes).toHaveProperty("length", 0) 64 | 65 | }) 66 | it("should throw an error WORDS_BOX_NOT_FOUND", async () => { 67 | const { token, user } = await createUser(manager) 68 | 69 | const response = await request(app.getHttpServer()) 70 | .delete(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.PARAM]: faker.string.uuid() })) 71 | .auth(token, { type: 'bearer' }) 72 | 73 | 74 | expect(response.status).toEqual(404) 75 | expect(response.body.message).toEqual(WORDS_BOX_NOT_FOUND.description) 76 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_FOUND.status) 77 | }) 78 | }); -------------------------------------------------------------------------------- /test/modules/box/queries/get-boxes.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { TestingModule, Test } from "@nestjs/testing"; 3 | import { AppModule } from "@src/app.module"; 4 | import { IPaginate } from "@src/common/interfaces/paginate"; 5 | import { ROUTES } from "@src/common/routes/routes"; 6 | import { BoxEntity, } from "@src/entities"; 7 | import { GetBoxesRequestDto } from "@src/modules/box/dto"; 8 | import { createUser } from "@test/helper"; 9 | import { createBox } from "@test/helper/create-box.handler"; 10 | import { options } from "ormconfig"; 11 | import * as request from 'supertest'; 12 | import { EntityManager, DataSource } from "typeorm"; 13 | 14 | const URL = ROUTES.BOX.ROOT + ROUTES.BOX.GET_ALL_BOX.URL 15 | 16 | let dataSource: DataSource; 17 | describe(ROUTES.BOX.GET_ALL_BOX.DESCRIPTION, () => { 18 | let app: INestApplication; 19 | 20 | let manager: EntityManager; 21 | let query: GetBoxesRequestDto 22 | 23 | beforeAll(async () => { 24 | const module: TestingModule = await Test.createTestingModule({ 25 | imports: [AppModule], 26 | }).compile(); 27 | app = module.createNestApplication(); 28 | await app.init(); 29 | dataSource = new DataSource(options); 30 | await dataSource.initialize(); 31 | manager = dataSource.manager; 32 | }); 33 | 34 | beforeEach(async () => { 35 | await dataSource.dropDatabase(); 36 | await dataSource.synchronize(); 37 | }); 38 | 39 | afterAll(async () => { 40 | await dataSource.destroy(); 41 | await app.close(); 42 | }); 43 | 44 | it("should get all box", async () => { 45 | const { token, user } = await createUser(manager) 46 | 47 | const count = 15 48 | 49 | for (let i = 0; i < count; i++) { 50 | const Box = await createBox(manager, { user }) 51 | } 52 | query = { 53 | page: 2, 54 | limit: 5, 55 | } 56 | 57 | const response = await request(app.getHttpServer()) 58 | .get(URL) 59 | .auth(token, { type: 'bearer' }).query(query) 60 | .expect(200) 61 | 62 | const { items, meta } = response.body as IPaginate 63 | 64 | expect(meta.totalItems).toEqual(count) 65 | expect(meta.itemCount).toEqual(items.length) 66 | expect(meta.currentPage).toEqual(query.page) 67 | expect(meta.itemsPerPage).toEqual(query.limit) 68 | expect(meta.totalPages).toEqual(Math.ceil(count / query.limit)); 69 | expect(meta.currentPage).toEqual(query.page) 70 | }) 71 | it('get box by name', async () => { 72 | const { token, user } = await createUser(manager) 73 | await createBox(manager, { user, name: "test" }) 74 | query = { 75 | search: "test" 76 | } 77 | const response = await request(app.getHttpServer()) 78 | .get(URL) 79 | .auth(token, { type: 'bearer' }) 80 | .query(query).expect(200) 81 | 82 | expect(response.body.items[0].name).toEqual(query.search) 83 | expect(response.body.meta.totalItems).toEqual(1) 84 | }) 85 | }); -------------------------------------------------------------------------------- /test/modules/box/commands/update-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { BOX_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { UpdateBoxRequestDto } from "@src/modules/box/dto"; 10 | import { createUser } from "@test/helper"; 11 | import { createBox } from "@test/helper/create-box.handler"; 12 | import { options } from "ormconfig"; 13 | import * as request from 'supertest'; 14 | import { EntityManager, DataSource } from "typeorm"; 15 | 16 | const URL = ROUTES.BOX.ROOT + ROUTES.BOX.UPDATE_BOX.URL 17 | 18 | let dataSource: DataSource; 19 | describe(ROUTES.BOX.CREATE_BOX.DESCRIPTION, () => { 20 | let app: INestApplication; 21 | let manager: EntityManager; 22 | 23 | let updateBoxRequestDto: UpdateBoxRequestDto 24 | 25 | beforeAll(async () => { 26 | const module: TestingModule = await Test.createTestingModule({ 27 | imports: [AppModule], 28 | }).compile(); 29 | app = module.createNestApplication(); 30 | await app.init(); 31 | dataSource = new DataSource(options); 32 | await dataSource.initialize(); 33 | manager = dataSource.manager; 34 | }); 35 | 36 | beforeEach(async () => { 37 | await dataSource.dropDatabase(); 38 | await dataSource.synchronize(); 39 | }); 40 | 41 | afterAll(async () => { 42 | await dataSource.destroy(); 43 | await app.close(); 44 | }); 45 | 46 | it("should update box", async () => { 47 | const { token, user } = await createUser(manager) 48 | const box =await createBox(manager, { user, name: "test22" }) 49 | updateBoxRequestDto = { 50 | name: "test" 51 | } 52 | 53 | const response = await request(app.getHttpServer()) 54 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.UPDATE_BOX.PARAM]: box.id })) 55 | .auth(token, { type: 'bearer' }) 56 | .send(updateBoxRequestDto) 57 | 58 | const getUser = await manager.findOne(UserEntity, { 59 | where: { id: user.id, }, relations: { 60 | box: true 61 | } 62 | }) 63 | expect(getUser.box[0].name).toEqual(updateBoxRequestDto.name) 64 | expect(response.body.name).toEqual(updateBoxRequestDto.name) 65 | expect(response.body.id).toEqual(box.id) 66 | 67 | }) 68 | it('should throw error BOX_NOT_FOUND', async () => { 69 | const { token, user } = await createUser(manager) 70 | 71 | const response = await request(app.getHttpServer()) 72 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.UPDATE_BOX.PARAM]: faker.string.uuid() })) 73 | .auth(token, { type: 'bearer' }) 74 | .send(updateBoxRequestDto) 75 | 76 | expect(response.body.statusCode).toEqual(BOX_NOT_FOUND.status) 77 | expect(response.body.message).toEqual(BOX_NOT_FOUND.description) 78 | 79 | }) 80 | }); -------------------------------------------------------------------------------- /src/common/routes/routes.ts: -------------------------------------------------------------------------------- 1 | const roots = { 2 | word: '/word', 3 | wordsBox: '/wordsBox', 4 | box: '/box', 5 | }; 6 | 7 | export const ROUTES = { 8 | WORD: { 9 | ROOT: roots.word, 10 | CREATE_WORD: { 11 | URL: '', 12 | DESCRIPTION: 'create word', 13 | }, 14 | GET_WORDS: { 15 | URL: '', 16 | DESCRIPTION: 'get all word', 17 | }, 18 | GET_WORD_BY_ID: { 19 | URL: '/:wordId', 20 | DESCRIPTION: 'get word by id', 21 | PARAM: 'wordId' 22 | }, 23 | UPDATE_WORD_BY_ID: { 24 | URL: '/:wordId', 25 | DESCRIPTION: 'update word by id', 26 | PARAM: 'wordId' 27 | }, 28 | DELETE_WORD_BY_ID: { 29 | URL: '/:wordId', 30 | DESCRIPTION: 'delete word by id', 31 | PARAM: 'wordId' 32 | } 33 | }, 34 | WORDS_BOX: { 35 | ROOT: roots.wordsBox, 36 | CREATE_WORDS_BOX:{ 37 | URL:'', 38 | DESCRIPTION:'create words box' 39 | }, 40 | UPDATE_ADD_WORDS_TO_BOX:{ 41 | URL:'/add-word-to-box/:boxId', 42 | PARAM:'boxId', 43 | DESCRIPTION:'add words to box ' 44 | }, 45 | DELETE_WORDS_BOX:{ 46 | URL:'/:boxId', 47 | PARAM:'boxId', 48 | DESCRIPTION:'delete words box by id' 49 | }, 50 | UPDATE_WORDS_BOX:{ 51 | URL:'/:boxId', 52 | PARAM:'boxId', 53 | DESCRIPTION:'update words box by id' 54 | }, 55 | REMOVE_WORD_FROM_WORDS_BOX:{ 56 | URL:'/remove-word-from-box/:boxId', 57 | PARAM:'boxId', 58 | DESCRIPTION:'remove words by box id' 59 | }, 60 | GET_ALL_WORDS_BOX:{ 61 | URL:'', 62 | DESCRIPTION:'get all words box' 63 | }, 64 | GET_WORDS_BOX_DETAIL:{ 65 | URL:"/:boxId", 66 | PARAM:'boxId', 67 | DESCRIPTION:'get words box by id' 68 | } 69 | }, 70 | BOX: { 71 | ROOT: roots.box, 72 | CREATE_BOX:{ 73 | URL:'', 74 | DESCRIPTION:'create box' 75 | }, 76 | UPDATE_BOX:{ 77 | URL:'/:boxId', 78 | PARAM:'boxId', 79 | DESCRIPTION:'update box by id' 80 | }, 81 | DELETE_BOX:{ 82 | URL:'/:boxId', 83 | PARAM:'boxId', 84 | DESCRIPTION:'delete box by id' 85 | }, 86 | ADD_WORDS_BOX_TO_BOX:{ 87 | URL:'/add-word-to-box/:boxId', 88 | PARAM:'boxId', 89 | DESCRIPTION:'add words to box ' 90 | }, 91 | REMOVE_WORDS_BOX_FROM_BOX:{ 92 | URL:'/remove-word-from-box/:boxId', 93 | PARAM:'boxId', 94 | DESCRIPTION:'remove words by box id' 95 | }, 96 | GET_ALL_BOX:{ 97 | URL:'', 98 | DESCRIPTION:'get all box' 99 | }, 100 | GET_BOX_DETAIL:{ 101 | URL:"/:boxId", 102 | PARAM:'boxId', 103 | DESCRIPTION:'get box by id' 104 | }, 105 | } 106 | } -------------------------------------------------------------------------------- /test/modules/wordsbox/queries/get-words-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { TestingModule, Test } from "@nestjs/testing"; 3 | import { AppModule } from "@src/app.module"; 4 | import { IPaginate } from "@src/common/interfaces/paginate"; 5 | import { ROUTES } from "@src/common/routes/routes"; 6 | import { WordsBoxEntity, } from "@src/entities"; 7 | import { GetWordsRequestDto } from "@src/modules/wordsBox/dto"; 8 | import { createUser } from "@test/helper"; 9 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 10 | import { options } from "ormconfig"; 11 | import * as request from 'supertest'; 12 | import { EntityManager, DataSource } from "typeorm"; 13 | 14 | const URL = ROUTES.WORDS_BOX.ROOT + ROUTES.WORDS_BOX.GET_ALL_WORDS_BOX.URL 15 | 16 | let dataSource: DataSource; 17 | describe(ROUTES.WORDS_BOX.CREATE_WORDS_BOX.DESCRIPTION, () => { 18 | let app: INestApplication; 19 | let manager: EntityManager; 20 | let query: GetWordsRequestDto 21 | 22 | beforeAll(async () => { 23 | const module: TestingModule = await Test.createTestingModule({ 24 | imports: [AppModule], 25 | }).compile(); 26 | app = module.createNestApplication(); 27 | await app.init(); 28 | dataSource = new DataSource(options); 29 | await dataSource.initialize(); 30 | manager = dataSource.manager; 31 | }); 32 | 33 | beforeEach(async () => { 34 | await dataSource.dropDatabase(); 35 | await dataSource.synchronize(); 36 | }); 37 | 38 | afterAll(async () => { 39 | await dataSource.destroy(); 40 | await app.close(); 41 | }); 42 | 43 | it("should get all words box", async () => { 44 | const { token, user } = await createUser(manager) 45 | const count = 15 46 | for (let i = 0; i < count; i++) { 47 | await createWordsBox(manager, { user }) 48 | } 49 | query = { 50 | page: 2, 51 | limit: 5, 52 | } 53 | 54 | const response = await request(app.getHttpServer()) 55 | .get(URL) 56 | .auth(token, { type: 'bearer' }) 57 | .query(query).expect(200) 58 | 59 | const { items, meta } = response.body as IPaginate 60 | 61 | expect(meta.totalItems).toEqual(count) 62 | expect(meta.itemCount).toEqual(items.length) 63 | expect(meta.currentPage).toEqual(query.page) 64 | expect(meta.itemsPerPage).toEqual(query.limit) 65 | expect(meta.totalPages).toEqual(Math.ceil(count / query.limit)); 66 | expect(meta.currentPage).toEqual(query.page) 67 | }) 68 | it('get word by name', async () => { 69 | const { token, user } = await createUser(manager) 70 | await createWordsBox(manager, { user, name: "test" }) 71 | await createWordsBox(manager, { user, name: "nothing else matters" }) 72 | 73 | query = { 74 | search: "test" 75 | } 76 | 77 | const response = await request(app.getHttpServer()) 78 | .get(URL) 79 | .auth(token, { type: 'bearer' }) 80 | .query(query).expect(200) 81 | 82 | expect(response.body.items[0].name).toEqual(query.search) 83 | expect(response.body.meta.totalItems).toEqual(1) 84 | expect(response.body.items).toHaveLength(1) 85 | }) 86 | }); -------------------------------------------------------------------------------- /src/common/filters/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | BadRequestException, 4 | Catch, 5 | ExceptionFilter, 6 | HttpException, 7 | HttpStatus, 8 | Inject 9 | } from '@nestjs/common'; 10 | import { HttpAdapterHost } from '@nestjs/core'; 11 | import { JwtService } from '@nestjs/jwt'; 12 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 13 | import { Logger } from 'winston'; 14 | 15 | import { INTERNAL_SERVER_ERROR } from '../errors'; 16 | import { createErrorLog } from '../helper'; 17 | 18 | @Catch() 19 | export class AllExceptionsFilter implements ExceptionFilter { 20 | constructor( 21 | private jwtService: JwtService, 22 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, 23 | private readonly httpAdapterHost: HttpAdapterHost, 24 | ) {} 25 | 26 | catch(exception: unknown, host: ArgumentsHost): void { 27 | const { httpAdapter } = this.httpAdapterHost; 28 | const ctx = host.switchToHttp(); 29 | 30 | /* -------------------------------------------------------------------------- */ 31 | /* Http Status */ 32 | /* -------------------------------------------------------------------------- */ 33 | 34 | const httpStatus = 35 | exception instanceof HttpException 36 | ? exception.getStatus() 37 | : HttpStatus.INTERNAL_SERVER_ERROR; 38 | 39 | /* -------------------------------------------------------------------------- */ 40 | /* Creating Error Message */ 41 | /* -------------------------------------------------------------------------- */ 42 | 43 | let message = exception; 44 | if (exception instanceof BadRequestException) { 45 | message = exception.getResponse()['message']; 46 | } else if (exception instanceof HttpException) { 47 | message = exception.message; 48 | } else { 49 | message = exception; 50 | } 51 | 52 | /* -------------------------------------------------------------------------- */ 53 | /* Creating Error Log */ 54 | /* -------------------------------------------------------------------------- */ 55 | 56 | const { body, params, method, query, url } = ctx.getRequest(); 57 | 58 | this.logger.log( 59 | createErrorLog({ 60 | statusCode: `${httpStatus}`, 61 | message: `${message}`, 62 | body, 63 | params, 64 | method, 65 | query, 66 | url, 67 | }), 68 | ); 69 | 70 | /* -------------------------------------------------------------------------- */ 71 | /* Responding Error */ 72 | /* -------------------------------------------------------------------------- */ 73 | 74 | const responseBody = { 75 | statusCode: httpStatus, 76 | timestamp: new Date().toISOString(), 77 | path: httpAdapter.getRequestUrl(ctx.getRequest()), 78 | message: 79 | httpStatus === 500 && process.env?.NODE_ENV === 'production' 80 | ? INTERNAL_SERVER_ERROR.description 81 | : message, 82 | }; 83 | 84 | httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/handler/add-words-to-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { AddWordToBox } from "../impl"; 3 | import { QueryRunner, DataSource } from "typeorm"; 4 | import { WordEntity, WordsBoxEntity } from "@src/entities"; 5 | import { CustomError, WORDS_BOX_NOT_FOUND, WORD_NOT_FOUND } from "@src/common/errors"; 6 | import { GetUser } from "@src/modules/shared/functions"; 7 | import { GetWordsBox } from "@src/modules/shared/functions/wordsBox.helper"; 8 | import { GetWord } from "@src/modules/shared/functions/word.helper"; 9 | 10 | @CommandHandler(AddWordToBox) 11 | export class AddWordToBoxHandler implements ICommandHandler { 12 | queryRunner: QueryRunner; 13 | constructor(private dataSource: DataSource) { } 14 | async execute(command: AddWordToBox): Promise { 15 | const { userId, addWordToBoxRequestDto, boxId } = command 16 | const { ids } = addWordToBoxRequestDto 17 | this.queryRunner = this.dataSource.createQueryRunner(); 18 | try { 19 | /* -------------------------------------------------------------------------- */ 20 | /* start transaction */ 21 | /* -------------------------------------------------------------------------- */ 22 | await this.queryRunner.connect(); 23 | await this.queryRunner.startTransaction(); 24 | const manager = this.queryRunner.manager; 25 | /* -------------------------------- find user ------------------------------- */ 26 | const user = await GetUser(manager, { id: userId }) 27 | /* ------------------------------- get words ------------------------------- */ 28 | const words = await this.getWords(ids, user.id) 29 | /* ------------------------------ get words box ----------------------------- */ 30 | const wordBox = await GetWordsBox(manager, { id: boxId }, { words: true }) 31 | if (!wordBox) { 32 | throw new CustomError(WORDS_BOX_NOT_FOUND) 33 | } 34 | /* ---------------------------- update words box ---------------------------- */ 35 | const updateWordsBox = await this.updateWordsBox(wordBox, { 36 | words, 37 | }) 38 | await this.queryRunner.commitTransaction(); 39 | return Promise.resolve(updateWordsBox) 40 | } catch (err) { 41 | await this.queryRunner.rollbackTransaction() 42 | throw err 43 | } finally { 44 | await this.queryRunner.release(); 45 | } 46 | } 47 | async updateWordsBox(wordsBox: Partial, prop: Partial) { 48 | Object.assign(wordsBox, { ...prop }); 49 | return await this.queryRunner.manager.save(WordsBoxEntity, wordsBox); 50 | } 51 | 52 | async getWords(ids: string[], userId: string) { 53 | const words: WordEntity[] = [] 54 | for (let i = 0; i < ids.length; i++) { 55 | const word = await GetWord(this.queryRunner.manager, { 56 | id: ids[i], user: { 57 | id: userId 58 | } 59 | }) 60 | if (!word) { 61 | throw new CustomError(WORD_NOT_FOUND) 62 | } 63 | words.push(word) 64 | } 65 | return words 66 | } 67 | } -------------------------------------------------------------------------------- /test/modules/word/commands/update-word.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORD_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { UpdateWordRequestDto } from "@src/modules/word/dto/update-word.request.dto"; 10 | import { createUser, createWord } from "@test/helper"; 11 | import { options } from "ormconfig"; 12 | import * as request from 'supertest'; 13 | import { EntityManager, DataSource } from "typeorm"; 14 | 15 | const URL = ROUTES.WORD.ROOT + ROUTES.WORD.UPDATE_WORD_BY_ID.URL 16 | 17 | let dataSource: DataSource; 18 | describe(ROUTES.WORD.UPDATE_WORD_BY_ID.DESCRIPTION, () => { 19 | let app: INestApplication; 20 | let manager: EntityManager; 21 | 22 | let updateWordRequestDto: UpdateWordRequestDto 23 | 24 | beforeAll(async () => { 25 | const module: TestingModule = await Test.createTestingModule({ 26 | imports: [AppModule], 27 | }).compile(); 28 | app = module.createNestApplication(); 29 | await app.init(); 30 | dataSource = new DataSource(options); 31 | await dataSource.initialize(); 32 | manager = dataSource.manager; 33 | }); 34 | 35 | beforeEach(async () => { 36 | await dataSource.dropDatabase(); 37 | await dataSource.synchronize(); 38 | }); 39 | 40 | afterAll(async () => { 41 | await dataSource.destroy(); 42 | await app.close(); 43 | }); 44 | it("should update word by id", async () => { 45 | const { token, user } = await createUser(manager); 46 | const word = await createWord(manager, { 47 | user, 48 | }) 49 | updateWordRequestDto = { 50 | definition: "test", 51 | example: "test", 52 | usage: "test", 53 | pronounce: "test", 54 | word: "test" 55 | } 56 | const response = await request(app.getHttpServer()) 57 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORD.UPDATE_WORD_BY_ID.PARAM]: word.id })) 58 | .auth(token, { type: 'bearer' }) 59 | .send(updateWordRequestDto) 60 | 61 | const getUser = await manager.findOne(UserEntity, { 62 | where: { 63 | id: user.id 64 | }, 65 | relations: { 66 | words: true 67 | } 68 | }) 69 | 70 | expect(response.body.id).toEqual(word.id); 71 | expect(getUser.words[0].word).toEqual(updateWordRequestDto.word); 72 | expect(getUser.words[0].id).toEqual(word.id); 73 | expect(user.id).toEqual(getUser.id); 74 | }) 75 | it("should throw error WORD_NOT_FOUND", async () => { 76 | const { token } = await createUser(manager); 77 | const response = await request(app.getHttpServer()) 78 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORD.UPDATE_WORD_BY_ID.PARAM]: faker.string.uuid() })) 79 | .auth(token, { type: 'bearer' }) 80 | 81 | expect(response.status).toEqual(WORD_NOT_FOUND.status) 82 | expect(response.body.message).toEqual(WORD_NOT_FOUND.description) 83 | expect(response.body.statusCode).toEqual(WORD_NOT_FOUND.status) 84 | }) 85 | }); -------------------------------------------------------------------------------- /src/modules/box/commands/handler/add-WordsBoxes-to-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { AddWordsBoxesToBoxCommand } from "../impl/add-wordsBoxes-to-box.command"; 3 | import { QueryRunner, DataSource } from "typeorm"; 4 | import { BoxEntity, WordsBoxEntity } from "@src/entities"; 5 | import { GetBox } from "@src/modules/shared/functions/box.handler"; 6 | import { BOX_NOT_FOUND, CustomError, WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 7 | import { GetWordsBox } from "@src/modules/shared/functions/wordsBox.helper"; 8 | import { GetUser } from "@src/modules/shared/functions"; 9 | 10 | @CommandHandler(AddWordsBoxesToBoxCommand) 11 | export class AddWordsBoxesToBoxHandler implements ICommandHandler { 12 | queryRunner: QueryRunner; 13 | constructor(private dataSource: DataSource) { } 14 | async execute(command: AddWordsBoxesToBoxCommand): Promise { 15 | this.queryRunner = this.dataSource.createQueryRunner() 16 | 17 | const { createBoxRequestDto, boxId,userId } = command 18 | const { WordBoxIds, } = createBoxRequestDto 19 | try { 20 | /* -------------------------------------------------------------------------- */ 21 | /* start Transaction */ 22 | /* -------------------------------------------------------------------------- */ 23 | await this.queryRunner.connect(); 24 | await this.queryRunner.startTransaction(); 25 | /* -------------------------------- get user -------------------------------- */ 26 | const user = await GetUser(this.queryRunner.manager,{ 27 | id:userId 28 | }) 29 | /* ------------------------------- get the box ------------------------------ */ 30 | const getBox = await GetBox(this.queryRunner.manager, { id: boxId }) 31 | if (!getBox) { 32 | throw new CustomError(BOX_NOT_FOUND) 33 | } 34 | /* ------------------------------ get words box ----------------------------- */ 35 | const wordsBoxes = await this.getWordsBoxes(WordBoxIds,user.id) 36 | /* -------------------------------- save box -------------------------------- */ 37 | const saveBox = await this.updateBox(getBox, { wordsBoxes }) 38 | 39 | await this.queryRunner.commitTransaction(); 40 | return Promise.resolve(saveBox) 41 | } catch (err) { 42 | await this.queryRunner.rollbackTransaction() 43 | throw err 44 | } finally { 45 | await this.queryRunner.release(); 46 | } 47 | } 48 | async getWordsBoxes(WordBoxIds: string[],userId:string) { 49 | const wordsBoxes: WordsBoxEntity[] = [] 50 | for (let i = 0; i < WordBoxIds.length; i++) { 51 | const getWordsBox = await GetWordsBox(this.queryRunner.manager, { id: WordBoxIds[i],user:{ 52 | id:userId 53 | } }) 54 | if(!getWordsBox) { 55 | throw new CustomError(WORDS_BOX_NOT_FOUND) 56 | } 57 | wordsBoxes.push(getWordsBox) 58 | } 59 | return wordsBoxes 60 | } 61 | 62 | async updateBox( 63 | box: Partial, prop: Partial 64 | ) { 65 | Object.assign(box, { ...prop }) 66 | return await this.queryRunner.manager.save(BoxEntity, box) 67 | } 68 | } -------------------------------------------------------------------------------- /src/modules/word/word.controler.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post, Put, Query, UseGuards } from "@nestjs/common"; 2 | import { CommandBus, QueryBus } from "@nestjs/cqrs"; 3 | import { ApiBearerAuth, ApiOperation, ApiProperty, ApiTags } from "@nestjs/swagger"; 4 | import { CreateWordRequestDto } from "./dto/create-word.request.dto"; 5 | import { CreateWordCommand, DeleteWordCommand, UpdateWordCommand } from "./commands/impl"; 6 | import { CurrentUser } from "../../common/decorator/current-user.decorator"; 7 | import { JwtAuthGuard } from "./../../common/guard/jwt-guard"; 8 | import { GetWordQuery, GetWordsQuery } from "./queries/impl"; 9 | import { GetWordsRequestDto } from "./dto/get-words.request.dto"; 10 | import { UpdateWordRequestDto } from "./dto/update-word.request.dto"; 11 | import { ROUTES } from "@src/common/routes/routes"; 12 | 13 | @Controller(ROUTES.WORD.ROOT) 14 | @ApiTags(ROUTES.WORD.ROOT) 15 | export class WordController { 16 | constructor( 17 | private readonly commandBus: CommandBus, 18 | private readonly queryBus: QueryBus 19 | ) { } 20 | 21 | @Post(ROUTES.WORD.CREATE_WORD.URL) 22 | @UseGuards(JwtAuthGuard) 23 | @ApiBearerAuth() 24 | @ApiOperation({ 25 | description: ROUTES.WORD.CREATE_WORD.DESCRIPTION 26 | }) 27 | async createWord( 28 | @CurrentUser() userId: string, 29 | @Body() createWordRequestDto: CreateWordRequestDto 30 | ) { 31 | return await this.commandBus.execute(new CreateWordCommand(userId, createWordRequestDto)); 32 | } 33 | 34 | @Get(ROUTES.WORD.GET_WORDS.URL) 35 | @ApiOperation({ 36 | description: ROUTES.WORD.GET_WORDS.DESCRIPTION 37 | }) 38 | @ApiBearerAuth() 39 | @UseGuards(JwtAuthGuard) 40 | async getWords( 41 | @Query() query: GetWordsRequestDto, 42 | @CurrentUser() userId: string 43 | ) { 44 | return await this.queryBus.execute(new GetWordsQuery(userId, query)); 45 | } 46 | 47 | @Get(ROUTES.WORD.GET_WORD_BY_ID.URL) 48 | @ApiBearerAuth() 49 | @ApiOperation({ 50 | description: ROUTES.WORD.GET_WORD_BY_ID.DESCRIPTION 51 | }) 52 | @UseGuards(JwtAuthGuard) 53 | async getWord( 54 | @CurrentUser() userId: string, 55 | @Param(ROUTES.WORD.GET_WORD_BY_ID.PARAM) wordId: string 56 | ) { 57 | return await this.queryBus.execute(new GetWordQuery(userId, wordId)); 58 | } 59 | 60 | @Put(ROUTES.WORD.UPDATE_WORD_BY_ID.URL) 61 | @ApiOperation({ 62 | description: ROUTES.WORD.GET_WORD_BY_ID.DESCRIPTION 63 | }) 64 | @ApiBearerAuth() 65 | @UseGuards(JwtAuthGuard) 66 | async updateWord( 67 | @Body() updateWordRequestDto: UpdateWordRequestDto, 68 | @Param(ROUTES.WORD.UPDATE_WORD_BY_ID.PARAM, new ParseUUIDPipe({ version: '4' })) wordId: string, 69 | @CurrentUser() userId: string 70 | ) { 71 | return await this.commandBus.execute(new UpdateWordCommand(wordId, userId, updateWordRequestDto)) 72 | } 73 | 74 | @Delete(ROUTES.WORD.DELETE_WORD_BY_ID.URL) 75 | @ApiBearerAuth() 76 | @ApiOperation({ 77 | description: ROUTES.WORD.DELETE_WORD_BY_ID.DESCRIPTION 78 | }) 79 | @UseGuards(JwtAuthGuard) 80 | async deleteWord( 81 | @CurrentUser() userId: string, 82 | @Param(ROUTES.WORD.DELETE_WORD_BY_ID.PARAM, new ParseUUIDPipe({ version: '4' })) wordId: string 83 | ) { 84 | return await this.commandBus.execute(new DeleteWordCommand(userId, wordId)) 85 | } 86 | } -------------------------------------------------------------------------------- /test/modules/wordsbox/command/update-words-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { UpdateWordsBoxRequestDto } from "@src/modules/wordsBox/dto"; 10 | import { createUser } from "@test/helper"; 11 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 12 | import { options } from "ormconfig"; 13 | import * as request from 'supertest'; 14 | import { EntityManager, DataSource } from "typeorm"; 15 | 16 | const URL = ROUTES.WORDS_BOX.ROOT + ROUTES.WORDS_BOX.UPDATE_WORDS_BOX.URL 17 | 18 | let dataSource: DataSource; 19 | describe(ROUTES.WORDS_BOX.UPDATE_WORDS_BOX.DESCRIPTION, () => { 20 | let app: INestApplication; 21 | let manager: EntityManager; 22 | 23 | let updateWordsBoxRequestDto: UpdateWordsBoxRequestDto 24 | 25 | beforeAll(async () => { 26 | const module: TestingModule = await Test.createTestingModule({ 27 | imports: [AppModule], 28 | }).compile(); 29 | app = module.createNestApplication(); 30 | await app.init(); 31 | dataSource = new DataSource(options); 32 | await dataSource.initialize(); 33 | manager = dataSource.manager; 34 | }); 35 | 36 | beforeEach(async () => { 37 | await dataSource.dropDatabase(); 38 | await dataSource.synchronize(); 39 | }); 40 | 41 | afterAll(async () => { 42 | await dataSource.destroy(); 43 | await app.close(); 44 | }); 45 | 46 | it("should update words box", async () => { 47 | const { token, user } = await createUser(manager) 48 | const wordsBox = await createWordsBox(manager, { user }) 49 | 50 | updateWordsBoxRequestDto = { 51 | is_learned: true, 52 | name: "test" 53 | } 54 | 55 | const response = await request(app.getHttpServer()) 56 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.UPDATE_WORDS_BOX.PARAM]: wordsBox.id })) 57 | .auth(token, { type: 'bearer' }) 58 | .send(updateWordsBoxRequestDto) 59 | 60 | const getUser = await manager.findOne(UserEntity, { 61 | where: { 62 | id: user.id 63 | }, 64 | relations: { 65 | wordsBoxes: true 66 | } 67 | }) 68 | 69 | expect(getUser.wordsBoxes[0].id).toEqual(response.body.id) 70 | expect(response.body.name).toEqual(updateWordsBoxRequestDto.name) 71 | }) 72 | it('should throw error WORDS_BOX_NOT_FOUND', async () => { 73 | const { token, user } = await createUser(manager) 74 | 75 | updateWordsBoxRequestDto = { 76 | name: "test", 77 | is_learned: true 78 | } 79 | 80 | const response = await request(app.getHttpServer()) 81 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.UPDATE_WORDS_BOX.PARAM]: faker.string.uuid() })) 82 | .auth(token, { type: 'bearer' }) 83 | .send(updateWordsBoxRequestDto) 84 | 85 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_FOUND.status) 86 | expect(response.body.message).toEqual(WORDS_BOX_NOT_FOUND.description) 87 | }) 88 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS App 2 | # Vocabulary Learning and Retention Application 3 | 4 | ## Project Description 5 | 6 | Welcome to the Vocabulary Learning and Retention Application! This project is designed to empower users with efficient techniques for learning and retaining new vocabulary. With its intuitive interface and powerful features, language learners can seamlessly create, organize, and review their vocabulary items. 7 | 8 | ## Key Features 9 | 10 | - **Vocabulary Creation:** Effortlessly add new words to your personal vocabulary list. Each entry includes the term and its definition, ensuring clarity and comprehensive understanding. 11 | 12 | - **Word Boxes:** The application introduces the concept of "Word Boxes," which enable users to group related words into customizable collections. These thematic Word Boxes facilitate focused learning by allowing users to target specific topics or categories. 13 | 14 | - **Retention Strategies:** Employ personalized retention strategies by marking Word Boxes as "Learned" when you feel confident in your grasp of the vocabulary. This adaptable approach empowers learners to customize their learning experience. 15 | 16 | - **Box Organization:** Enhance your learning organization by creating hierarchies of Word Boxes within larger "Boxes." This hierarchical structure provides a flexible framework for structuring your vocabulary learning process. 17 | 18 | ## How It Works 19 | 20 | 1. **Vocabulary Creation:** Start your learning journey by adding new words to your personal vocabulary list. Each entry consists of a term and its corresponding definition. 21 | 22 | 2. **Word Box Creation:** Organize your vocabulary effectively by grouping related words into Word Boxes. Customize the title and description of each Word Box to tailor your learning experience. 23 | 24 | 3. **Retention Planning:** Boost retention by designating Word Boxes as "Learned" once you're confident in your understanding. This enables targeted review while allowing you to focus on acquiring new vocabulary. 25 | 26 | 4. **Hierarchy Creation:** For comprehensive organization, group Word Boxes into larger "Boxes." This feature enhances management and tracking of your overall vocabulary progress. 27 | 28 | 5. **Continuous Learning:** Regularly revisit your vocabulary items and Word Boxes to ensure retention. Adapt your learning strategy by transferring words between boxes as your proficiency grows. 29 | 30 | ## To-Do 31 | 32 | - [ ] Develop the user interface (UI) for the application. 33 | - [ ] add Progress Tracking for user for back-end 34 | 35 | Feel free to customize the task description as needed. This "To-Do" section allows you to outline tasks you plan to work on, keeping yourself and potential contributors informed about the ongoing development of the project. 36 | 37 | ### Installation: 38 | ``` bash 39 | #1. clone this project 40 | $ git clone https://github.com/sg-milad/nest-cqrs-vocabMinder 41 | #2. go to directory 42 | $ cd /nest-cqrs-vocabMinder 43 | $ cp .env.example .env 44 | $ docker-compose up 45 | ``` 46 | 47 | ### [open doc](https://localhost:3000/doc) 48 | ### Test 49 | ``` 50 | $ npm run test:e2e 51 | ``` 52 | 53 | ## Features 54 | 55 |
JWT Authentication
56 |
Installed and configured JWT authentication.
57 | 58 |
Environment Configuration
59 |
development, staging and production environment configurations
60 | 61 |
Swagger Api Documentation
62 |
Already integrated API documentation. To see all available endpoints visit http://localhost:3000/doc
63 | -------------------------------------------------------------------------------- /src/modules/wordsBox/commands/handler/remove-words.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { RemoveWordsCommand } from "../impl"; 3 | import { DataSource, QueryRunner } from "typeorm"; 4 | import { GetUser } from "@src/modules/shared/functions"; 5 | import { WordEntity, WordsBoxEntity } from "@src/entities"; 6 | import { CustomError, WORDS_BOX_NOT_FOUND, WORD_NOT_FOUND, WORD_NOT_YOUR_BOX } from "@src/common/errors"; 7 | import { GetWord } from "@src/modules/shared/functions/word.helper"; 8 | import { GetWordsBox } from "@src/modules/shared/functions/wordsBox.helper"; 9 | 10 | @CommandHandler(RemoveWordsCommand) 11 | export class RemoveWordsHandler implements ICommandHandler { 12 | queryRunner: QueryRunner; 13 | constructor( 14 | private dataSource: DataSource 15 | ) { } 16 | async execute(command: RemoveWordsCommand): Promise { 17 | const { userId, boxId, removeWordsRequestDto } = command 18 | const { wordsIds } = removeWordsRequestDto 19 | this.queryRunner = this.dataSource.createQueryRunner(); 20 | try { 21 | await this.queryRunner.connect() 22 | await this.queryRunner.startTransaction() 23 | const manager = this.queryRunner.manager 24 | 25 | /* -------------------------------- find user ------------------------------- */ 26 | const user = await GetUser(manager, { id: userId }) 27 | /* ------------------------------- get words ------------------------------- */ 28 | await this.getWords(wordsIds, user.id) 29 | /* ------------------------------ get words box ----------------------------- */ 30 | const wordsBox = await GetWordsBox(manager, { id: boxId }, { words: true }) 31 | if (!wordsBox) { 32 | throw new CustomError(WORDS_BOX_NOT_FOUND) 33 | } 34 | /* -------------------- Filter out the words to remove ------------------- */ 35 | const wordsToRemove = wordsBox.words.filter(word => wordsIds.includes(word.id)); 36 | if(wordsToRemove.length === 0){ 37 | throw new CustomError(WORD_NOT_YOUR_BOX) 38 | } 39 | /* ---------------------------- update words box ---------------------------- */ 40 | const removeWords = await this.removeWords(wordsBox, wordsToRemove); 41 | 42 | await this.queryRunner.commitTransaction() 43 | return Promise.resolve(removeWords) 44 | } catch (err) { 45 | await this.queryRunner.rollbackTransaction() 46 | throw err 47 | } finally { 48 | await this.queryRunner.release() 49 | } 50 | } 51 | async removeWords(wordsBox: WordsBoxEntity, wordsToRemove: WordEntity[]) { 52 | wordsBox.words = wordsBox.words.filter(word => !wordsToRemove.includes(word)); 53 | return await this.queryRunner.manager.save(WordsBoxEntity, wordsBox); 54 | } 55 | 56 | 57 | async getWords(ids: string[], userId: string) { 58 | const words: WordEntity[] = [] 59 | for (let i = 0; i < ids.length; i++) { 60 | const word = await GetWord(this.queryRunner.manager, { 61 | id: ids[i], user: { 62 | id: userId 63 | } 64 | }) 65 | if (!word) { 66 | throw new CustomError(WORD_NOT_FOUND) 67 | } 68 | words.push(word) 69 | } 70 | return words 71 | } 72 | } -------------------------------------------------------------------------------- /src/modules/box/commands/handler/remove-wordsBox-from-box.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; 2 | import { RemoveWordsBoxFromBoxCommand } from "../impl"; 3 | import { CustomError, BOX_NOT_FOUND, WORDS_BOX_NOT_IN_YOUR_BOX, WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 4 | import { GetUser } from "@src/modules/shared/functions"; 5 | import { GetBox } from "@src/modules/shared/functions/box.handler"; 6 | import { QueryRunner, DataSource } from "typeorm"; 7 | import { BoxEntity, WordsBoxEntity } from "@src/entities"; 8 | import { GetWordsBox } from "@src/modules/shared/functions/wordsBox.helper"; 9 | 10 | @CommandHandler(RemoveWordsBoxFromBoxCommand) 11 | export class RemoveWordsBoxFromBoxHandler implements ICommandHandler { 12 | queryRunner: QueryRunner; 13 | constructor(private dataSource: DataSource) { } 14 | async execute(command: RemoveWordsBoxFromBoxCommand): Promise { 15 | const { boxId, userId, removeWordsBoxFromBoxRequestDto } = command 16 | const { wordsBoxIds } = removeWordsBoxFromBoxRequestDto 17 | this.queryRunner = this.dataSource.createQueryRunner() 18 | try { 19 | /* -------------------------------------------------------------------------- */ 20 | /* start Transaction */ 21 | /* -------------------------------------------------------------------------- */ 22 | await this.queryRunner.connect(); 23 | await this.queryRunner.startTransaction(); 24 | const manager = this.queryRunner.manager; 25 | /* -------------------------------- get user -------------------------------- */ 26 | const user = await GetUser(manager, { id: userId }) 27 | /* --------------------------------- get box -------------------------------- */ 28 | const getBox = await GetBox(manager, { 29 | id: boxId, 30 | user: { 31 | id: user.id 32 | }, 33 | 34 | }, { 35 | wordsBoxes: true 36 | } 37 | ) 38 | if (!getBox) { 39 | throw new CustomError(BOX_NOT_FOUND) 40 | } 41 | await this.getWordsBox(wordsBoxIds, user.id) 42 | /* -------------------- Filter out the words box to remove ------------------- */ 43 | const wordsToRemove = getBox.wordsBoxes.filter(wordsBox => wordsBoxIds.includes(wordsBox.id)); 44 | if (wordsToRemove.length === 0) { 45 | throw new CustomError(WORDS_BOX_NOT_IN_YOUR_BOX) 46 | } 47 | 48 | /* ---------------------------- remove words box ---------------------------- */ 49 | const removeWordsBox = await this.removeWordsBox(getBox, wordsToRemove) 50 | 51 | await this.queryRunner.commitTransaction() 52 | return Promise.resolve(removeWordsBox) 53 | } catch (err) { 54 | await this.queryRunner.rollbackTransaction() 55 | throw err 56 | } finally { 57 | await this.queryRunner.release(); 58 | } 59 | } 60 | async removeWordsBox(box: Partial, wordsToRemove: WordsBoxEntity[]) { 61 | box.wordsBoxes = box.wordsBoxes.filter(word => !wordsToRemove.includes(word)); 62 | return await this.queryRunner.manager.save(BoxEntity, box); 63 | 64 | } 65 | async getWordsBox(ids: string[], userId: string) { 66 | const wordsBoxes: WordsBoxEntity[] = [] 67 | for (let i = 0; i < ids.length; i++) { 68 | const getWordsBox = await GetWordsBox(this.queryRunner.manager, { 69 | id: ids[i], user: { 70 | id: userId 71 | } 72 | }) 73 | if (!getWordsBox) { 74 | throw new CustomError(WORDS_BOX_NOT_FOUND) 75 | } 76 | wordsBoxes.push(getWordsBox) 77 | } 78 | return wordsBoxes 79 | } 80 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-cqrs-vocabMinder", 3 | "version": "1.0.0", 4 | "description": "", 5 | "author": "@sg-milad", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "nest build", 9 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 10 | "start": "ts-node src/index.ts", 11 | "start:dev": "nest start --watch", 12 | "start:debug": "nest start --debug --watch", 13 | "start:prod": "node dist/src/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": "NODE_ENV=tEsT jest --runInBand --detectOpenHandles --config ./test/jest-e2e.js", 20 | "webpack": "webpack --config webpack.config.js", 21 | "migration:create": "ts-node ./node_modules/typeorm/cli.js migration:create", 22 | "migration:generate": "ts-node ./node_modules/typeorm/cli.js migration:generate -d ormconfig.ts", 23 | "migration:up": "ts-node ./node_modules/typeorm/cli.js migration:run -d ormconfig.ts", 24 | "migration:down": "ts-node ./node_modules/typeorm/cli.js migration:revert -d ormconfig.ts", 25 | "db:sync": "ts-node ./node_modules/typeorm/cli.js schema:sync -d ormconfig.ts", 26 | "db:drop": "ts-node ./node_modules/typeorm/cli.js schema:drop -d ormconfig.ts", 27 | "db:test:sync": "cross-env NODE_ENV=tEsT ts-node ./node_modules/typeorm/cli.js schema:sync -d ormconfig.ts", 28 | "db:test:drop": "cross-env NODE_ENV=tEsT ts-node ./node_modules/typeorm/cli.js schema:drop -d ormconfig.ts", 29 | "seed:create": "ts-node ./node_modules/typeorm/cli.js migration:create", 30 | "seed:up": "ts-node ./node_modules/typeorm/cli.js migration:run -d ormconfig-seeder.ts", 31 | "seed:down": "ts-node ./node_modules/typeorm/cli.js migration:revert -d ormconfig-seeder.ts" 32 | }, 33 | "dependencies": { 34 | "@faker-js/faker": "^8.0.2", 35 | "@nestjs/common": "^10.0.0", 36 | "@nestjs/config": "^3.0.0", 37 | "@nestjs/core": "^10.0.0", 38 | "@nestjs/cqrs": "^10.1.0", 39 | "@nestjs/jwt": "^10.1.0", 40 | "@nestjs/passport": "^10.0.0", 41 | "@nestjs/platform-express": "^10.0.0", 42 | "@nestjs/swagger": "^7.1.6", 43 | "@nestjs/throttler": "^5.0.0", 44 | "@nestjs/typeorm": "^9.0.1", 45 | "bcrypt": "^5.0.1", 46 | "class-transformer": "^0.5.1", 47 | "class-validator": "^0.14.0", 48 | "helmet": "^7.0.0", 49 | "ioredis": "^5.3.2", 50 | "nest-winston": "^1.9.3", 51 | "nestjs-throttler-storage-redis": "^0.4.0", 52 | "passport": "^0.6.0", 53 | "passport-jwt": "^4.0.0", 54 | "pg": "^8.4.0", 55 | "reflect-metadata": "^0.1.13", 56 | "rxjs": "^7.8.1", 57 | "swagger-ui-express": "^4.3.0", 58 | "typeorm": "0.3.17", 59 | "typescript": "^4.6.2", 60 | "winston": "^3.10.0" 61 | }, 62 | "devDependencies": { 63 | "@nestjs/cli": "^10.0.1", 64 | "@nestjs/schematics": "^10.0.1", 65 | "@nestjs/testing": "^10.0.0", 66 | "@swc/cli": "^0.1.62", 67 | "@swc/core": "^1.3.64", 68 | "@types/bcrypt": "^5.0.0", 69 | "@types/express": "^4.17.17", 70 | "@types/jest": "^29.5.2", 71 | "@types/node": "^16.11.10", 72 | "@types/supertest": "^2.0.12", 73 | "@typescript-eslint/eslint-plugin": "^5.59.11", 74 | "@typescript-eslint/parser": "^5.59.11", 75 | "eslint": "^8.42.0", 76 | "eslint-config-prettier": "^8.8.0", 77 | "eslint-plugin-prettier": "^4.2.1", 78 | "jest": "^29.5.0", 79 | "prettier": "^2.8.8", 80 | "source-map-support": "^0.5.20", 81 | "supertest": "^6.3.3", 82 | "ts-jest": "^29.1.0", 83 | "ts-loader": "^9.4.3", 84 | "ts-node": "10.7.0", 85 | "tsconfig-paths": "4.1.0", 86 | "typescript": "^4.7.4" 87 | }, 88 | "jest": { 89 | "moduleFileExtensions": [ 90 | "js", 91 | "json", 92 | "ts" 93 | ], 94 | "rootDir": "src", 95 | "testRegex": ".spec.ts$", 96 | "transform": { 97 | "^.+\\.(t|j)s$": "ts-jest" 98 | }, 99 | "coverageDirectory": "../coverage", 100 | "testEnvironment": "node" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/modules/wordsbox/command/add-words-to-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORDS_BOX_NOT_FOUND, WORD_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { AddWordToBoxRequestDto } from "@src/modules/wordsBox/dto"; 10 | import { createUser, createWord } from "@test/helper"; 11 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 12 | import { options } from "ormconfig"; 13 | import * as request from 'supertest'; 14 | import { EntityManager, DataSource } from "typeorm"; 15 | 16 | const URL = ROUTES.WORDS_BOX.ROOT + ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.URL 17 | 18 | let dataSource: DataSource; 19 | describe(ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.DESCRIPTION, () => { 20 | let app: INestApplication; 21 | let manager: EntityManager; 22 | 23 | let addWordToBoxRequestDto: AddWordToBoxRequestDto 24 | 25 | beforeAll(async () => { 26 | const module: TestingModule = await Test.createTestingModule({ 27 | imports: [AppModule], 28 | }).compile(); 29 | app = module.createNestApplication(); 30 | await app.init(); 31 | dataSource = new DataSource(options); 32 | await dataSource.initialize(); 33 | manager = dataSource.manager; 34 | }); 35 | 36 | beforeEach(async () => { 37 | await dataSource.dropDatabase(); 38 | await dataSource.synchronize(); 39 | }); 40 | 41 | afterAll(async () => { 42 | await dataSource.destroy(); 43 | await app.close(); 44 | }); 45 | 46 | it("should add words to box", async () => { 47 | const { token, user } = await createUser(manager) 48 | 49 | const wordsBox = await createWordsBox(manager, { user }) 50 | let wordsId = [] 51 | for (let i = 0; i < 10; i++) { 52 | const words = await createWord(manager, { user }) 53 | wordsId.push(words.id) 54 | } 55 | addWordToBoxRequestDto = { 56 | ids: wordsId 57 | } 58 | const response = await request(app.getHttpServer()) 59 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.PARAM]: wordsBox.id })) 60 | .auth(token, { type: 'bearer' }) 61 | .send(addWordToBoxRequestDto) 62 | 63 | const getUser = await manager.findOne(UserEntity, { 64 | where: { id: user.id }, 65 | relations: { 66 | wordsBoxes: true 67 | } 68 | }) 69 | 70 | expect(response.body.id).toEqual(getUser.wordsBoxes[0].id); 71 | expect(wordsBox.id).toEqual(response.body.id) 72 | response.body.words.forEach((word, index) => { 73 | expect(word.id).toEqual(wordsId[index]); 74 | }); 75 | }) 76 | it("should throw an error WORD_NOT_FOUND", async () => { 77 | const { token, user } = await createUser(manager) 78 | 79 | const response = await request(app.getHttpServer()) 80 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.PARAM]: faker.string.uuid() })) 81 | .auth(token, { type: 'bearer' }) 82 | .send(addWordToBoxRequestDto) 83 | 84 | expect(response.status).toEqual(404) 85 | expect(response.body.message).toEqual(WORD_NOT_FOUND.description) 86 | expect(response.body.statusCode).toEqual(WORD_NOT_FOUND.status) 87 | }) 88 | it("should throw an error WORDS_BOX_NOT_FOUND", async () => { 89 | const { token, user } = await createUser(manager) 90 | 91 | const words = await createWord(manager, { user }) 92 | 93 | addWordToBoxRequestDto = { 94 | ids: [words.id] 95 | } 96 | 97 | const response = await request(app.getHttpServer()) 98 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.PARAM]: faker.string.uuid() })) 99 | .auth(token, { type: 'bearer' }) 100 | .send(addWordToBoxRequestDto) 101 | 102 | expect(response.status).toEqual(404) 103 | expect(response.body.message).toEqual(WORDS_BOX_NOT_FOUND.description) 104 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_FOUND.status) 105 | }) 106 | }); -------------------------------------------------------------------------------- /test/modules/box/commands/add-WordsBoxes-to-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { BOX_NOT_FOUND, WORDS_BOX_NOT_FOUND } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { AddWordsBoxesToBoxRequestDto } from "@src/modules/box/dto"; 10 | import { createUser } from "@test/helper"; 11 | import { createBox } from "@test/helper/create-box.handler"; 12 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 13 | import { options } from "ormconfig"; 14 | import * as request from 'supertest'; 15 | import { EntityManager, DataSource } from "typeorm"; 16 | 17 | const URL = ROUTES.BOX.ROOT + ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.URL 18 | 19 | let dataSource: DataSource; 20 | describe(ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.DESCRIPTION, () => { 21 | let app: INestApplication; 22 | let manager: EntityManager; 23 | 24 | let addWordsBoxesToBoxRequestDto: AddWordsBoxesToBoxRequestDto 25 | 26 | beforeAll(async () => { 27 | const module: TestingModule = await Test.createTestingModule({ 28 | imports: [AppModule], 29 | }).compile(); 30 | app = module.createNestApplication(); 31 | await app.init(); 32 | dataSource = new DataSource(options); 33 | await dataSource.initialize(); 34 | manager = dataSource.manager; 35 | }); 36 | 37 | beforeEach(async () => { 38 | await dataSource.dropDatabase(); 39 | await dataSource.synchronize(); 40 | }); 41 | 42 | afterAll(async () => { 43 | await dataSource.destroy(); 44 | await app.close(); 45 | }); 46 | 47 | it("should add words-box to box", async () => { 48 | const { token, user } = await createUser(manager) 49 | 50 | const wordsBoxesId = [] 51 | for (let i = 0; i < 10; i++) { 52 | const wordsBox = await createWordsBox(manager, { user }) 53 | wordsBoxesId.push(wordsBox.id) 54 | } 55 | 56 | const box = await createBox(manager, { user, }) 57 | 58 | addWordsBoxesToBoxRequestDto = { 59 | WordBoxIds: wordsBoxesId 60 | } 61 | const response = await request(app.getHttpServer()) 62 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.PARAM]: box.id })) 63 | .auth(token, { type: 'bearer' }) 64 | .send(addWordsBoxesToBoxRequestDto) 65 | 66 | const getUser = await manager.findOne(UserEntity, { 67 | where: { id: user.id, }, relations: { 68 | box: true, 69 | wordsBoxes: true 70 | } 71 | }) 72 | 73 | response.body.wordsBoxes.forEach((box, index) => { 74 | expect(box.id).toEqual(wordsBoxesId[index]); 75 | }); 76 | expect(box.id).toEqual(response.body.id) 77 | expect(response.body.name).toEqual(box.name); 78 | expect(getUser.box[0].id).toEqual(box.id) 79 | }) 80 | it('should throw error BOX_NOT_FOUND', async () => { 81 | const { token, user } = await createUser(manager) 82 | 83 | addWordsBoxesToBoxRequestDto = { 84 | WordBoxIds: [] 85 | } 86 | 87 | const response = await request(app.getHttpServer()) 88 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.PARAM]: faker.string.uuid() })) 89 | .auth(token, { type: 'bearer' }) 90 | .send(addWordsBoxesToBoxRequestDto) 91 | 92 | expect(response.body.message).toEqual(BOX_NOT_FOUND.description) 93 | expect(response.body.statusCode).toEqual(BOX_NOT_FOUND.status) 94 | 95 | }) 96 | it('should throw error WORDS_BOX_NOT_FOUND', async () => { 97 | const { token, user } = await createUser(manager) 98 | 99 | addWordsBoxesToBoxRequestDto = { 100 | WordBoxIds: [faker.string.uuid()] 101 | } 102 | const box =await createBox(manager, { user }) 103 | const response = await request(app.getHttpServer()) 104 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.PARAM]: box.id })) 105 | .auth(token, { type: 'bearer' }) 106 | .send(addWordsBoxesToBoxRequestDto) 107 | 108 | expect(response.body.message).toEqual(WORDS_BOX_NOT_FOUND.description) 109 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_FOUND.status) 110 | }) 111 | }); -------------------------------------------------------------------------------- /src/modules/box/box.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post, Put, Query, UseGuards } from "@nestjs/common"; 2 | import { CommandBus, QueryBus } from "@nestjs/cqrs"; 3 | import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; 4 | import { JwtAuthGuard } from "@src/common/guard/jwt-guard"; 5 | import { CreateBoxCommand, DeleteBoxCommand, RemoveWordsBoxFromBoxCommand, UpdateBoxCommand } from "./commands/impl"; 6 | import { AddWordsBoxesToBoxRequestDto, CreateBoxRequestDto, GetBoxesRequestDto, RemoveWordsBoxFromBoxRequestDto, UpdateBoxRequestDto } from "./dto"; 7 | import { CurrentUser } from "@src/common/decorator/current-user.decorator"; 8 | import { AddWordsBoxesToBoxCommand } from "./commands/impl/add-wordsBoxes-to-box.command"; 9 | import { GetBoxesCommand } from "./queries/impl"; 10 | import { getBoxDetailCommand } from "./queries/impl/get-box-detail.command"; 11 | import { ROUTES } from "@src/common/routes/routes"; 12 | 13 | @ApiTags(ROUTES.BOX.ROOT) 14 | @Controller(ROUTES.BOX.ROOT) 15 | export class BoxController { 16 | constructor( 17 | private readonly commandBus: CommandBus, 18 | private readonly queryBus: QueryBus 19 | ) { } 20 | 21 | @Post(ROUTES.BOX.CREATE_BOX.URL) 22 | @UseGuards(JwtAuthGuard) 23 | @ApiBearerAuth() 24 | @ApiOperation({ 25 | description:ROUTES.BOX.CREATE_BOX.DESCRIPTION 26 | }) 27 | async createBox( 28 | @CurrentUser() userId: string, 29 | @Body() createBoxRequestDto: CreateBoxRequestDto 30 | ) { 31 | return await this.commandBus.execute(new CreateBoxCommand(userId, createBoxRequestDto)); 32 | } 33 | 34 | @Put(ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.URL) 35 | @UseGuards(JwtAuthGuard) 36 | @ApiBearerAuth() 37 | @ApiOperation({ 38 | description: ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.DESCRIPTION 39 | }) 40 | async addWordsBoxesToBox( 41 | @Body() addWordsBoxesToBoxRequestDto: AddWordsBoxesToBoxRequestDto, 42 | @Param(ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 43 | @CurrentUser() userId :string 44 | ) { 45 | return await this.commandBus.execute(new AddWordsBoxesToBoxCommand(boxId,userId,addWordsBoxesToBoxRequestDto)); 46 | } 47 | 48 | @Delete(ROUTES.BOX.DELETE_BOX.URL) 49 | @UseGuards(JwtAuthGuard) 50 | @ApiBearerAuth() 51 | @ApiOperation({ 52 | description: ROUTES.BOX.DELETE_BOX.DESCRIPTION 53 | }) 54 | async deleteBox( 55 | @Param(ROUTES.BOX.DELETE_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 56 | @CurrentUser() userId :string 57 | ) { 58 | return await this.commandBus.execute(new DeleteBoxCommand(userId,boxId)); 59 | } 60 | 61 | @Put(ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.URL) 62 | @UseGuards(JwtAuthGuard) 63 | @ApiBearerAuth() 64 | @ApiOperation({ 65 | description: ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.DESCRIPTION 66 | }) 67 | async removeWordsBoxFromBox( 68 | @Param(ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 69 | @CurrentUser() userId :string, 70 | @Body() removeWordsBoxFromBoxRequestDto: RemoveWordsBoxFromBoxRequestDto 71 | ) { 72 | return await this.commandBus.execute(new RemoveWordsBoxFromBoxCommand(boxId,userId,removeWordsBoxFromBoxRequestDto)); 73 | } 74 | 75 | @Put(ROUTES.BOX.UPDATE_BOX.URL) 76 | @UseGuards(JwtAuthGuard) 77 | @ApiBearerAuth() 78 | @ApiOperation({ 79 | description: ROUTES.BOX.UPDATE_BOX.DESCRIPTION 80 | }) 81 | async updateBox( 82 | @Param(ROUTES.BOX.ADD_WORDS_BOX_TO_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 83 | @CurrentUser() userId :string, 84 | @Body() updateBoxRequestDto: UpdateBoxRequestDto, 85 | ) { 86 | return await this.commandBus.execute(new UpdateBoxCommand(userId,boxId,updateBoxRequestDto)); 87 | } 88 | 89 | @Get(ROUTES.BOX.GET_ALL_BOX.URL) 90 | @UseGuards(JwtAuthGuard) 91 | @ApiBearerAuth() 92 | @ApiOperation({ 93 | description: ROUTES.BOX.GET_ALL_BOX.DESCRIPTION 94 | }) 95 | async getBoxes( 96 | @CurrentUser() userId: string, 97 | @Query() getBoxesRequestDto: GetBoxesRequestDto 98 | ) { 99 | return await this.queryBus.execute(new GetBoxesCommand(userId, getBoxesRequestDto)) 100 | } 101 | 102 | @Get(ROUTES.BOX.GET_BOX_DETAIL.URL) 103 | @UseGuards(JwtAuthGuard) 104 | @ApiBearerAuth() 105 | @ApiOperation({ 106 | description: ROUTES.BOX.GET_BOX_DETAIL.DESCRIPTION 107 | }) 108 | async getBoxDetail( 109 | @CurrentUser() userId: string, 110 | @Param(ROUTES.BOX.GET_BOX_DETAIL.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 111 | ) { 112 | return await this.queryBus.execute(new getBoxDetailCommand(userId,boxId )) 113 | } 114 | } -------------------------------------------------------------------------------- /test/modules/wordsbox/command/remove-words.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { WORDS_BOX_NOT_FOUND, WORD_NOT_FOUND, WORD_NOT_YOUR_BOX } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { UserEntity, } from "@src/entities"; 9 | import { RemoveWordsRequestDto } from "@src/modules/wordsBox/dto"; 10 | import { createUser, createWord } from "@test/helper"; 11 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 12 | import { options } from "ormconfig"; 13 | import * as request from 'supertest'; 14 | import { EntityManager, DataSource } from "typeorm"; 15 | 16 | const URL = ROUTES.WORDS_BOX.ROOT + ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.URL 17 | 18 | let dataSource: DataSource; 19 | describe(ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.DESCRIPTION, () => { 20 | let app: INestApplication; 21 | let manager: EntityManager; 22 | 23 | let removeWordsRequestDto: RemoveWordsRequestDto 24 | 25 | beforeAll(async () => { 26 | const module: TestingModule = await Test.createTestingModule({ 27 | imports: [AppModule], 28 | }).compile(); 29 | app = module.createNestApplication(); 30 | await app.init(); 31 | dataSource = new DataSource(options); 32 | await dataSource.initialize(); 33 | manager = dataSource.manager; 34 | }); 35 | 36 | beforeEach(async () => { 37 | await dataSource.dropDatabase(); 38 | await dataSource.synchronize(); 39 | }); 40 | 41 | afterAll(async () => { 42 | await dataSource.destroy(); 43 | await app.close(); 44 | }); 45 | 46 | it("should remove words from box", async () => { 47 | const { token, user } = await createUser(manager) 48 | 49 | const words = [] 50 | for (let i = 0; i < 10; i++) { 51 | const word = await createWord(manager, { user }) 52 | words.push(word) 53 | } 54 | 55 | const wordsBox = await createWordsBox(manager, { user, words: words }) 56 | 57 | removeWordsRequestDto = { 58 | wordsIds: words.map(word => word.id) 59 | } 60 | 61 | const response = await request(app.getHttpServer()) 62 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.PARAM]: wordsBox.id })) 63 | .auth(token, { type: 'bearer' }) 64 | .send(removeWordsRequestDto) 65 | 66 | const getUser = await manager.findOne(UserEntity, { 67 | where: { 68 | id: user.id 69 | }, 70 | relations: { 71 | wordsBoxes: true 72 | } 73 | }) 74 | 75 | expect(response.body.id).toEqual(getUser.wordsBoxes[0].id) 76 | expect(wordsBox.id).toEqual(response.body.id) 77 | expect(response.body.words).toHaveProperty("length", 0) 78 | }) 79 | it('should throw error WORD_NOT_FOUND', async () => { 80 | const { token, user } = await createUser(manager) 81 | 82 | const wordsBox = await createWordsBox(manager, { user }) 83 | 84 | removeWordsRequestDto = { 85 | wordsIds: [faker.string.uuid()] 86 | } 87 | 88 | const response = await request(app.getHttpServer()) 89 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.PARAM]: wordsBox.id })) 90 | .auth(token, { type: 'bearer' }) 91 | .send(removeWordsRequestDto) 92 | 93 | expect(response.status).toEqual(404) 94 | expect(response.body.message).toEqual(WORD_NOT_FOUND.description) 95 | expect(response.body.statusCode).toEqual(WORD_NOT_FOUND.status) 96 | }) 97 | it('should throw error WORDS_BOX_NOT_FOUND', async () => { 98 | const { token, user } = await createUser(manager) 99 | 100 | removeWordsRequestDto = { 101 | wordsIds: [] 102 | } 103 | const response = await request(app.getHttpServer()) 104 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.PARAM]: faker.string.uuid() })) 105 | .auth(token, { type: 'bearer' }).send(removeWordsRequestDto) 106 | 107 | expect(response.status).toEqual(404) 108 | expect(response.body.message).toEqual(WORDS_BOX_NOT_FOUND.description) 109 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_FOUND.status) 110 | }) 111 | it('should throw error WORD_NOT_YOUR_BOX', async () => { 112 | const { token, user } = await createUser(manager) 113 | const wordsBox = await createWordsBox(manager, { user }) 114 | const word = await createWord(manager, { user }) 115 | 116 | removeWordsRequestDto = { 117 | wordsIds: [word.id] 118 | } 119 | const response = await request(app.getHttpServer()) 120 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.PARAM]: wordsBox.id })) 121 | .auth(token, { type: 'bearer' }).send(removeWordsRequestDto) 122 | 123 | expect(response.body.message).toEqual(WORD_NOT_YOUR_BOX.description) 124 | expect(response.body.statusCode).toEqual(WORD_NOT_YOUR_BOX.status) 125 | }) 126 | }); -------------------------------------------------------------------------------- /src/modules/wordsBox/wordsBox.controler.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post, Put, Query, UseGuards } from "@nestjs/common"; 2 | import { CommandBus, QueryBus } from "@nestjs/cqrs"; 3 | import { AddWordToBoxRequestDto, CreateWordsBoxRequestDto, GetWordsRequestDto, RemoveWordsRequestDto, UpdateWordsBoxRequestDto } from "./dto"; 4 | import { CurrentUser } from "@src/common/decorator/current-user.decorator"; 5 | import { ApiProperty, ApiBearerAuth, ApiTags, ApiOperation } from "@nestjs/swagger"; 6 | import { JwtAuthGuard } from "@src/common/guard/jwt-guard"; 7 | import { AddWordToBox } from "./commands/impl/add-word-to-box.command"; 8 | import { CreateWordsBoxCommand, DeleteWordsBoxCommand, RemoveWordsCommand, UpdateWordsBoxCommand } from "./commands/impl"; 9 | import { GetWordsBoxQuery, getWordsBoxDetailQuery } from "./queries/impl"; 10 | import { ROUTES } from "@src/common/routes/routes"; 11 | 12 | @ApiTags(ROUTES.WORDS_BOX.ROOT) 13 | @Controller(ROUTES.WORDS_BOX.ROOT) 14 | export class WordsBoxController { 15 | constructor( 16 | private readonly commandBus: CommandBus, 17 | private readonly queryBus: QueryBus 18 | ) { } 19 | 20 | @Post(ROUTES.WORDS_BOX.CREATE_WORDS_BOX.URL) 21 | @ApiOperation({ 22 | description:ROUTES.WORDS_BOX.CREATE_WORDS_BOX.DESCRIPTION 23 | }) 24 | @UseGuards(JwtAuthGuard) 25 | @ApiBearerAuth() 26 | createWordsBox( 27 | @Body() createWordRequestDto: CreateWordsBoxRequestDto, 28 | @CurrentUser() userId: string 29 | ) { 30 | return this.commandBus.execute(new CreateWordsBoxCommand(userId, createWordRequestDto)); 31 | } 32 | 33 | @Put(ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.URL) 34 | @ApiOperation({ 35 | description:ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.DESCRIPTION 36 | }) 37 | @UseGuards(JwtAuthGuard) 38 | @ApiBearerAuth() 39 | addWordToBox( 40 | @Body() addWordToBoxRequestDto: AddWordToBoxRequestDto, 41 | @Param(ROUTES.WORDS_BOX.UPDATE_ADD_WORDS_TO_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 42 | @CurrentUser() userId: string, 43 | ) { 44 | return this.commandBus.execute(new AddWordToBox(userId, boxId, addWordToBoxRequestDto)); 45 | } 46 | 47 | @Delete(ROUTES.WORDS_BOX.DELETE_WORDS_BOX.URL) 48 | @ApiOperation({ 49 | description:ROUTES.WORDS_BOX.DELETE_WORDS_BOX.DESCRIPTION 50 | }) 51 | @UseGuards(JwtAuthGuard) 52 | @ApiBearerAuth() 53 | async deleteWordsBox( 54 | @Param(ROUTES.WORDS_BOX.DELETE_WORDS_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 55 | @Body() removeWordsRequestDto: RemoveWordsRequestDto, 56 | @CurrentUser() userId: string, 57 | ) { 58 | return await this.commandBus.execute(new DeleteWordsBoxCommand(userId, boxId, removeWordsRequestDto)); 59 | } 60 | 61 | @Put(ROUTES.WORDS_BOX.UPDATE_WORDS_BOX.URL) 62 | @ApiOperation({ 63 | description:ROUTES.WORDS_BOX.UPDATE_WORDS_BOX.DESCRIPTION 64 | }) 65 | @UseGuards(JwtAuthGuard) 66 | @ApiBearerAuth() 67 | async updateWordsBox( 68 | @Param(ROUTES.WORDS_BOX.UPDATE_WORDS_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 69 | @CurrentUser() userId: string, 70 | @Body() updateWordsBoxRequestDto: UpdateWordsBoxRequestDto 71 | ) { 72 | return await this.commandBus.execute(new UpdateWordsBoxCommand(userId, boxId, updateWordsBoxRequestDto)); 73 | } 74 | 75 | @Put(ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.URL) 76 | @ApiOperation({ 77 | description:ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.DESCRIPTION 78 | }) 79 | @UseGuards(JwtAuthGuard) 80 | @ApiBearerAuth() 81 | async removeWordsBox( 82 | @Param(ROUTES.WORDS_BOX.REMOVE_WORD_FROM_WORDS_BOX.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string, 83 | @Body() removeWordsRequestDto: RemoveWordsRequestDto, 84 | @CurrentUser() userId: string, 85 | ) { 86 | return await this.commandBus.execute(new RemoveWordsCommand(userId, boxId, removeWordsRequestDto)); 87 | } 88 | 89 | @Get(ROUTES.WORDS_BOX.GET_ALL_WORDS_BOX.URL) 90 | @ApiOperation({ 91 | description:ROUTES.WORDS_BOX.GET_ALL_WORDS_BOX.DESCRIPTION 92 | }) 93 | @UseGuards(JwtAuthGuard) 94 | @ApiBearerAuth() 95 | async getWordsBox( 96 | @CurrentUser() userId: string, 97 | @Query() getWordsRequestDto: GetWordsRequestDto 98 | ) { 99 | return await this.queryBus.execute(new GetWordsBoxQuery(userId, getWordsRequestDto)); 100 | } 101 | 102 | @Get(ROUTES.WORDS_BOX.GET_WORDS_BOX_DETAIL.URL) 103 | @ApiOperation({ 104 | description:ROUTES.WORDS_BOX.GET_WORDS_BOX_DETAIL.DESCRIPTION 105 | }) 106 | @UseGuards(JwtAuthGuard) 107 | @ApiBearerAuth() 108 | async getWordsBoxDetail( 109 | @CurrentUser() userId: string, 110 | @Param(ROUTES.WORDS_BOX.GET_WORDS_BOX_DETAIL.PARAM, new ParseUUIDPipe({ version: '4' })) boxId: string 111 | ) { 112 | return await this.queryBus.execute(new getWordsBoxDetailQuery(userId, boxId)); 113 | } 114 | } -------------------------------------------------------------------------------- /test/modules/box/commands/remove-wordsBox-from-box.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import { TestingModule, Test } from "@nestjs/testing"; 4 | import { AppModule } from "@src/app.module"; 5 | import { BOX_ALREADY_EXISTS, BOX_NOT_FOUND, WORDS_BOX_NOT_FOUND, WORDS_BOX_NOT_IN_YOUR_BOX } from "@src/common/errors"; 6 | import { ROUTES } from "@src/common/routes/routes"; 7 | import { URL_REPLACE_PARAMS } from "@src/common/utils"; 8 | import { BoxEntity, UserEntity, } from "@src/entities"; 9 | import { CreateBoxRequestDto, RemoveWordsBoxFromBoxRequestDto } from "@src/modules/box/dto"; 10 | import { createUser } from "@test/helper"; 11 | import { createBox } from "@test/helper/create-box.handler"; 12 | import { createWordsBox } from "@test/helper/createWordsBox.helper"; 13 | import { options } from "ormconfig"; 14 | import * as request from 'supertest'; 15 | import { EntityManager, DataSource } from "typeorm"; 16 | 17 | const URL = ROUTES.BOX.ROOT + ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.URL 18 | 19 | let dataSource: DataSource; 20 | describe(ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.DESCRIPTION, () => { 21 | let app: INestApplication; 22 | let manager: EntityManager; 23 | 24 | let removeWordsBoxFromBoxRequestDto: RemoveWordsBoxFromBoxRequestDto 25 | 26 | beforeAll(async () => { 27 | const module: TestingModule = await Test.createTestingModule({ 28 | imports: [AppModule], 29 | }).compile(); 30 | app = module.createNestApplication(); 31 | await app.init(); 32 | dataSource = new DataSource(options); 33 | await dataSource.initialize(); 34 | manager = dataSource.manager; 35 | }); 36 | 37 | beforeEach(async () => { 38 | await dataSource.dropDatabase(); 39 | await dataSource.synchronize(); 40 | }); 41 | 42 | afterAll(async () => { 43 | await dataSource.destroy(); 44 | await app.close(); 45 | }); 46 | 47 | it("should remove words boxes from box", async () => { 48 | const { token, user } = await createUser(manager) 49 | 50 | const wordsBoxArray = [] 51 | for (let i = 0; i < 5; i++) { 52 | const wordsBox = await createWordsBox(manager, { user }) 53 | wordsBoxArray.push(wordsBox) 54 | } 55 | const box = await createBox(manager, { user, wordsBoxes: wordsBoxArray }) 56 | 57 | removeWordsBoxFromBoxRequestDto = { 58 | wordsBoxIds: wordsBoxArray.map(wordsBox => wordsBox.id) 59 | } 60 | const response = await request(app.getHttpServer()) 61 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.PARAM]: box.id })) 62 | .auth(token, { type: 'bearer' }) 63 | .send(removeWordsBoxFromBoxRequestDto) 64 | 65 | const getBox = await manager.findOne(BoxEntity, { 66 | where: { id: box.id, }, 67 | relations: { 68 | wordsBoxes: true 69 | } 70 | }) 71 | 72 | expect(response.body.wordsBoxes).toEqual(getBox.wordsBoxes) 73 | expect(response.body.id).toEqual(box.id) 74 | }) 75 | it('should throw error BOX_NOT_FOUND', async () => { 76 | const { token, user } = await createUser(manager) 77 | 78 | const response = await request(app.getHttpServer()) 79 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.PARAM]: faker.string.uuid() })) 80 | .auth(token, { type: 'bearer' }) 81 | .send(removeWordsBoxFromBoxRequestDto) 82 | 83 | expect(response.body.statusCode).toEqual(BOX_NOT_FOUND.status) 84 | expect(response.body.message).toEqual(BOX_NOT_FOUND.description) 85 | }) 86 | it('should throw error WORDS_BOX_NOT_IN_YOUR_BOX', async () => { 87 | const { token, user } = await createUser(manager) 88 | 89 | const box = await createBox(manager, { user, }) 90 | 91 | const wordsBox = await createWordsBox(manager, { user }) 92 | removeWordsBoxFromBoxRequestDto = { 93 | wordsBoxIds: [wordsBox.id] 94 | } 95 | const response = await request(app.getHttpServer()) 96 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.PARAM]: box.id })) 97 | .auth(token, { type: 'bearer' }) 98 | .send(removeWordsBoxFromBoxRequestDto) 99 | 100 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_IN_YOUR_BOX.status) 101 | expect(response.body.message).toEqual(WORDS_BOX_NOT_IN_YOUR_BOX.description) 102 | }) 103 | it('should throw error WORDS_BOX_NOT_FOUND', async () => { 104 | const { token, user } = await createUser(manager) 105 | 106 | const box = await createBox(manager, { user, }) 107 | 108 | const wordsBox = await createWordsBox(manager, { user }) 109 | removeWordsBoxFromBoxRequestDto = { 110 | wordsBoxIds: [faker.string.uuid()] 111 | } 112 | const response = await request(app.getHttpServer()) 113 | .put(URL_REPLACE_PARAMS(URL, { [ROUTES.BOX.REMOVE_WORDS_BOX_FROM_BOX.PARAM]: box.id })) 114 | .auth(token, { type: 'bearer' }) 115 | .send(removeWordsBoxFromBoxRequestDto) 116 | 117 | expect(response.body.statusCode).toEqual(WORDS_BOX_NOT_FOUND.status) 118 | expect(response.body.message).toEqual(WORDS_BOX_NOT_FOUND.description) 119 | }) 120 | }); --------------------------------------------------------------------------------