├── .gitignore ├── .sequelizerc ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── Makefile ├── README.md ├── build.js ├── docker-compose.yml ├── env ├── development.env ├── production.env └── test.env ├── nodemon.json ├── nodemon.test.json ├── package-lock.json ├── package.json ├── scripts ├── create-databases.js └── setup.sh ├── src ├── adapters │ ├── common │ │ ├── criteria.ts │ │ ├── interfaces │ │ │ ├── data-mappers.ts │ │ │ └── unit-of-work.ts │ │ ├── models │ │ │ ├── customer-persistence-data.ts │ │ │ ├── http-request.ts │ │ │ ├── http-response.ts │ │ │ ├── line-item-persistence-data.ts │ │ │ ├── order-persistence-data.ts │ │ │ └── product-persistence-data.ts │ │ ├── repositories │ │ │ ├── customer.rep.ts │ │ │ ├── order.rep.ts │ │ │ └── product.rep.ts │ │ ├── search-options.ts │ │ └── services │ │ │ ├── invoice.service.ts │ │ │ └── unit-of-work.service.ts │ ├── detail-order │ │ ├── http-detail-order.controller.ts │ │ └── http-detail-order.presenter.ts │ ├── generate-order-invoice │ │ ├── generate-order-invoice.gateway.ts │ │ ├── http-generate-order-invoice.controller.ts │ │ └── http-generate-order-invoice.presenter.ts │ ├── generate-order │ │ ├── generate-order.gateway.ts │ │ ├── http-generate-order.controller.ts │ │ └── http-generate-order.presenter.ts │ └── get-order-data │ │ └── get-order-data.gateway.ts ├── entities │ ├── address.ts │ ├── common │ │ ├── domain-error.ts │ │ ├── entity.ts │ │ ├── id-generator-factory.ts │ │ ├── id-generator.ts │ │ ├── identifier.ts │ │ ├── unique-entity-id.ts │ │ └── value-object.ts │ ├── customer.ts │ ├── index.ts │ ├── line-item.ts │ ├── order.ts │ └── product.ts ├── http-server.ts ├── index.ts ├── infrastructure │ ├── container.ts │ ├── db │ │ ├── data-mappers │ │ │ ├── sequelize-customer-data-mapper.ts │ │ │ ├── sequelize-line-item-data-mapper.ts │ │ │ ├── sequelize-order-data-mapper.ts │ │ │ └── sequelize-product-data-mapper.ts │ │ ├── migrations │ │ │ └── store │ │ │ │ ├── 20200612161858-create-products-table.js │ │ │ │ ├── 20200724142904-create-customers-table.js │ │ │ │ ├── 20200724142945-create-orders-table.js │ │ │ │ └── 20200724143002-create-line_items-table.js │ │ ├── models.ts │ │ ├── models │ │ │ └── store │ │ │ │ ├── customer.ts │ │ │ │ ├── line-item.ts │ │ │ │ ├── order.ts │ │ │ │ └── product.ts │ │ ├── relations.ts │ │ ├── seeders │ │ │ └── store │ │ │ │ ├── 20211231191642-insert-product.js │ │ │ │ └── 20211231192509-insert-customer.js │ │ ├── sequelize-data-mapper.ts │ │ └── sequelize-unit-of-work.ts │ ├── plugins │ │ ├── gerencianet │ │ │ ├── credentials.js │ │ │ └── gerencianet-invoice.gateway.ts │ │ └── uuid-id-generator.ts │ └── web │ │ ├── execute-rule.ts │ │ ├── express-response-handler.ts │ │ ├── middlewares │ │ └── create-scope-container.middleware.ts │ │ └── routes │ │ ├── index.ts │ │ └── order.ts ├── load-env.ts ├── package.json └── use-cases │ ├── common │ ├── errors.ts │ ├── get-order-data │ │ ├── get-order-data.dtos.ts │ │ ├── get-order-data.gateway.ts │ │ ├── get-order-data.interactor.ts │ │ └── index.ts │ ├── interactor.ts │ └── presenter.ts │ ├── detail-order │ ├── detail-order.interactor.ts │ └── index.ts │ ├── generate-order-invoice │ ├── generate-order-invoice.gateway.ts │ ├── generate-order-invoice.interactor.ts │ └── index.ts │ ├── generate-order │ ├── generate-order.dtos.ts │ ├── generate-order.gateway.ts │ ├── generate-order.interactor.ts │ └── index.ts │ └── index.ts ├── tsconfig.json ├── tsconfig.prod.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /.setup 4 | /src/infrastructure/db/config.ts 5 | /src/infrastructure/db/databases.json 6 | /src/infrastructure/plugins/gerencianet/credentials -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var underscore = require('underscore.string'); 3 | var args = process.argv.slice(2); 4 | var folder = 'store'; 5 | 6 | args.forEach(function(arg) { 7 | if(underscore.startsWith(arg, '--env=')) { 8 | migrationFolder = arg.replace('--env', ''); 9 | } 10 | }); 11 | 12 | module.exports = { 13 | 'models-path': path.resolve('src', 'infrastructure', 'db', 'models', folder), 14 | 'migrations-path': path.resolve('src', 'infrastructure', 'db', 'migrations', folder), 15 | 'seeders-path': path.resolve('src', 'infrastructure', 'db', 'seeders', folder), 16 | 'config': './src/infrastructure/db/databases.json' 17 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Debug CRS - Docker", 8 | "address": "127.0.0.1", 9 | "port": 9229, 10 | "localRoot": "${workspaceFolder}/", 11 | "remoteRoot": "/application", 12 | "outFiles": [ 13 | "${workspaceRoot}/dist/**/*.js" 14 | ] 15 | }, 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vsicons.presets.nestjs": true 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS dev 2 | 3 | ENV PATH="/application/node_modules/.bin:${PATH}" 4 | # ENV NODE_EXTRA_CA_CERTS="/etc/ssl/certs/ca-certificates.crt" 5 | # COPY docker/Gerencianet-CA.crt /usr/local/share/ca-certificates/Gerencianet-CA.crt 6 | RUN apk add sudo bash ca-certificates 7 | RUN apk add git ca-certificates bash sudo make build-base python3 libcap openssh 8 | RUN update-ca-certificates 9 | RUN echo "node ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node 10 | RUN setcap cap_net_bind_service=+ep /usr/local/bin/node 11 | RUN mkdir -p /home/node/.ssh && \ 12 | chmod 0700 /home/node/.ssh && \ 13 | chown node:node /home/node/.ssh 14 | 15 | CMD cd "/application" && \ 16 | npm start 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build up down start stop restart logs ps login 2 | 3 | build: 4 | docker-compose build 5 | 6 | up: 7 | docker-compose up -d 8 | 9 | down: 10 | docker-compose down 11 | 12 | debug: down 13 | DEBUG=1 docker-compose up -d 14 | 15 | start: 16 | docker-compose start 17 | 18 | stop: 19 | docker-compose stop 20 | 21 | restart: down up 22 | 23 | logs: 24 | docker-compose logs --tail=10 -f 25 | 26 | ps: 27 | docker-compose ps 28 | 29 | login: 30 | docker-compose run --rm -w /application clean_arquitecture /bin/bash 31 | 32 | setup: 33 | node ./scripts/create-databases.js & docker network create shared-services || true && docker-compose run --rm -w /application clean_arquitecture /bin/bash -c "npm run setup" 34 | 35 | database: 36 | docker run --name dbmysql -e MYSQL_USER=root -e MYSQL_ROOT_PASSWORD=root -p 3306:3306 -d -t mysql:8.0.18 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean Architecture with Typescript 2 | This repository provides an implementation (or at least an attempt) of Uncle Bob's Clean Architecture with Typescript. 3 | 4 | ![CleanArchitecture](https://user-images.githubusercontent.com/10949632/92665934-3390a380-f2de-11ea-8c63-5447e5773e2d.jpg) 5 | 6 | Basically there is a folder representing each required layer: 7 | 8 | - **entities**: This folder contains all enterprise business rules. It's represented by domain classes with most critical business rules. 9 | - **use-cases**: This folder contains all aplication business rules. It's encapsulated in modules containing the use case interactors and its ports (one specific use case gateway interface and/or one specific use case presenter interface) 10 | - **adapters**: This folder contains all kind of code that adapts interfaces most familiar to infrastructure layer to interfaces most familiar do use case layer. For example, sometimes it's needed to adapt one or more data access classes to an specific use case gateway interface. 11 | - **infrastructure**: This folder contains all libraries, frameworks and drivers needed by the aplication. It's less important aplication layer, always depending on adapter's layer. 12 | 13 | 14 | ## Execution Instructions 15 | 16 | To configure and execute this project, the [Docker][docker] framework was used. And for the database [MySQL][mysql] was chosen. 17 | 18 | ### Initial configurations 19 | 20 | On the first stage, it is important to create the database in which store our data for the app. 21 | 22 | ``` 23 | make database 24 | ``` 25 | 26 | On the second stage, we will build the required images from Dockerfile/Docker Compose to initialize its contexts(network, port, volume) 27 | 28 | ``` 29 | make build 30 | ``` 31 | 32 | After having our database properly created, it is necessary to run a setup. At this point we are executing the migrations, seeds and building the packages we need to run the App. 33 | 34 | ``` 35 | make setup 36 | ``` 37 | 38 | ### Running the App 39 | 40 | Finally, after configuring the project, you can start up the project running following command. 41 | 42 | ``` 43 | make up logs 44 | ``` 45 | 46 | 47 | [mysql]:https://www.mysql.com/ 48 | [docker]:https://www.docker.com/ -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const childProcess = require('child_process'); 3 | 4 | 5 | try { 6 | // Remove current build 7 | fs.removeSync('./dist/'); 8 | // Copy front-end files 9 | // fs.copySync('./src/public', './dist/public'); 10 | // fs.copySync('./src/views', './dist/views'); 11 | // Transpile the typescript files 12 | childProcess.exec('tsc --build tsconfig.prod.json'); 13 | } catch (err) { 14 | console.log(err); 15 | } 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | x-logging: 4 | &default-logging 5 | driver: json-file 6 | options: 7 | max-size: '100k' 8 | max-file: '1' 9 | 10 | networks: 11 | shared-services: 12 | external: true 13 | 14 | services: 15 | clean_arquitecture: 16 | container_name: clean_arquitecture 17 | build: 18 | context: . 19 | target: dev 20 | environment: 21 | - NODE_TLS_REJECT_UNAUTHORIZED=0 22 | volumes: 23 | - ".:/application" 24 | networks: 25 | - shared-services 26 | ports: 27 | - "9229:9229" 28 | - "8081:8081" -------------------------------------------------------------------------------- /env/development.env: -------------------------------------------------------------------------------- 1 | # Environment 2 | NODE_ENV=development 3 | 4 | # Server 5 | PORT=7000 6 | HOST=localhost 7 | -------------------------------------------------------------------------------- /env/production.env: -------------------------------------------------------------------------------- 1 | # Environment 2 | NODE_ENV=production 3 | 4 | # Server 5 | PORT=8081 6 | HOST= 7 | -------------------------------------------------------------------------------- /env/test.env: -------------------------------------------------------------------------------- 1 | # Environment 2 | NODE_ENV=testing 3 | 4 | # Server 5 | PORT=4000 6 | HOST=localost 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/public"], 5 | "exec": "ts-node -r tsconfig-paths/register ./src --env=development" 6 | } 7 | -------------------------------------------------------------------------------- /nodemon.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["spec"], 3 | "ext": "spec.ts", 4 | "ignore": ["spec/support"], 5 | "exec": "ts-node -r tsconfig-paths/register ./spec" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clean_architecture_typescript", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "build": "node build.js", 6 | "lint": "tslint --project \"tsconfig.json\"", 7 | "start": "nodemon --watch \"src/**\" --ext \"ts,json\" --ignore \"src/**/*.spec.ts\" --exec \"ts-node -r tsconfig-paths/register src/index.ts\"", 8 | "start:dev": "nodemon --config nodemon.json", 9 | "test": "nodemon --config nodemon.test.json", 10 | "setup": "rm -f .setup && rm -rf ./dist && sh scripts/setup.sh" 11 | }, 12 | "dependencies": { 13 | "awilix": "^4.3.4", 14 | "command-line-args": "^5.1.1", 15 | "cookie-parser": "^1.4.5", 16 | "dotenv": "^8.2.0", 17 | "express": "^4.17.1", 18 | "express-async-errors": "^3.1.1", 19 | "gn-api-sdk-node": "^3.0.4", 20 | "helmet": "^3.22.0", 21 | "http-status-codes": "^1.4.0", 22 | "module-alias": "^2.2.2", 23 | "mysql2": "^2.1.0", 24 | "sequelize": "^6.35.1", 25 | "underscore": "^1.13.2", 26 | "winston": "^3.2.1" 27 | }, 28 | "devDependencies": { 29 | "@types/command-line-args": "^5.0.0", 30 | "@types/cookie-parser": "^1.4.2", 31 | "@types/express": "^4.17.6", 32 | "@types/find": "^0.2.1", 33 | "@types/glob": "^7.1.3", 34 | "@types/helmet": "0.0.47", 35 | "@types/jasmine": "^3.5.10", 36 | "@types/jsonfile": "^6.0.0", 37 | "@types/morgan": "^1.9.0", 38 | "@types/node": "^20.0.0", 39 | "@types/sequelize": "^4.28.9", 40 | "@types/supertest": "^2.0.9", 41 | "find": "^0.3.0", 42 | "fs-extra": "^9.0.0", 43 | "jasmine": "^3.5.0", 44 | "jsonfile": "^6.0.1", 45 | "nodemon": "^2.0.4", 46 | "supertest": "^4.0.2", 47 | "ts-node": "^8.10.2", 48 | "tsconfig-paths": "^3.9.0", 49 | "tslint": "^6.1.2", 50 | "typescript": "^4.0.3", 51 | "underscore.string": "^3.3.5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /scripts/create-databases.js: -------------------------------------------------------------------------------- 1 | const { exec } = require("child_process"); 2 | const databases = require('../src/infrastructure/db/databases.json'); 3 | 4 | const createDatabases = async () => { 5 | const schemas = Object.keys(databases); 6 | 7 | for (const schema of schemas) { 8 | await exec(`docker exec dbmysql mysql -u ${databases[schema].username} -p${databases[schema].password} -e "create database if not exists ${schema};"`); 9 | } 10 | } 11 | createDatabases(); -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | DIR="$( pwd )" 5 | 6 | SRC_DIR="$DIR/src" 7 | DIST_DIR="$DIR/dist" 8 | 9 | if [ -f "$DIR/.setup" ]; then 10 | echo "Project setup already completed" 11 | exit 0 12 | fi 13 | 14 | npm install --prefix $DIR 15 | npm run build --prefix $DIR 16 | npm install -g sequelize-cli@5.5.1 17 | 18 | npx sequelize db:migrate --env=store 19 | npx sequelize db:seed:all --env=store 20 | 21 | cp "$SRC_DIR/../package.json" "$DIST_DIR" 22 | 23 | echo "1" > "$DIR/.setup" 24 | -------------------------------------------------------------------------------- /src/adapters/common/criteria.ts: -------------------------------------------------------------------------------- 1 | type ExcludeKey = 2 | { [K in keyof T]: T[K] extends U ? never : K }[keyof T] 3 | 4 | 5 | type ComparisonOps = '$equal' | 6 | '$notEqual' | 7 | '$greaterThan' | 8 | '$greaterThanEqual' | 9 | '$lessThan' | 10 | '$lessThanEqual'; 11 | 12 | type ValidationOps = '$is' | '$notIs'; 13 | 14 | type ListOp = { 15 | '$in'?: Type[], 16 | '$notIn'?: Type[], 17 | } 18 | 19 | type BetweenOp = { 20 | '$between'?: { 21 | firstValue: Type, 22 | secondValue: Type 23 | } 24 | } 25 | 26 | type LikeOp = { 27 | '$like'?: string 28 | } 29 | 30 | type ComparisonExpression = Partial>; 31 | type ValidationExpression = Partial>; 32 | 33 | type CommonExpression = 34 | ComparisonExpression | 35 | ListOp | 36 | BetweenOp | 37 | ValidationExpression; 38 | 39 | type StringExpression = CommonExpression | LikeOp; 40 | 41 | type Expression = { 42 | [Property in keyof Type]?: 43 | Type[Property] extends number | Date ? CommonExpression : 44 | Type[Property] extends string ? StringExpression : 45 | Type[Property] extends boolean ? ValidationExpression : 46 | never 47 | } 48 | 49 | export type CriteriaExpression = Pick, ExcludeKey, never>>; 50 | 51 | type CriteriaSentence = Criteria | CriteriaExpression | 'and' | 'or'; 52 | 53 | export class Criteria { 54 | private _sentence: CriteriaSentence[]; 55 | 56 | constructor(expression: CriteriaExpression) { 57 | this._sentence = [expression]; 58 | } 59 | 60 | public and(condition: Criteria | CriteriaExpression) { 61 | this._sentence.push('and'); 62 | this._sentence.push(condition); 63 | 64 | return this; 65 | } 66 | 67 | public or(condition: Criteria | CriteriaExpression) { 68 | this._sentence.push('and'); 69 | this._sentence.push(condition); 70 | 71 | return this; 72 | } 73 | 74 | public getSentence(): CriteriaSentence[] { 75 | return this._sentence; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/adapters/common/interfaces/data-mappers.ts: -------------------------------------------------------------------------------- 1 | import CustomerPersistenceData from '../models/customer-persistence-data'; 2 | import LineItemPersistenceData from '../models/line-item-persistence-data'; 3 | import OrderPersistenceData from '../models/order-persistence-data'; 4 | import ProductPersistenceData from '../models/product-persistence-data'; 5 | 6 | export interface AbstractDataMapper { 7 | findById(id: number | string): Promise; 8 | insert(model: Model): Promise; 9 | bulckInsert(models: Model[]): Promise; 10 | updateById(id: number | string, data: Partial): Promise; 11 | }; 12 | 13 | export interface ProductDataMapper extends AbstractDataMapper {} 14 | export interface CustomerDataMapper extends AbstractDataMapper {} 15 | 16 | export interface OrderDataMapper extends AbstractDataMapper { 17 | findByIdAndIncludeLineItems(id: string): Promise; 18 | } 19 | export interface LineItemDataMapper extends AbstractDataMapper { 20 | updateByIdAndOrderId(id: number, orderId: string, data: Partial): Promise; 21 | deleteByOrderIdWhereIdNotInArray(orderId: string, array: number[]): Promise; 22 | } -------------------------------------------------------------------------------- /src/adapters/common/interfaces/unit-of-work.ts: -------------------------------------------------------------------------------- 1 | export interface UnitOfWork { 2 | startTransaction(): Promise 3 | commitTransaction(): Promise; 4 | rollbackTransaction(): Promise; 5 | } -------------------------------------------------------------------------------- /src/adapters/common/models/customer-persistence-data.ts: -------------------------------------------------------------------------------- 1 | import { Address, Customer, UniqueEntityID } from "@entities"; 2 | 3 | export default interface CustomerPersistenceData { 4 | id: number; 5 | document: string; 6 | name: string; 7 | cellphone: string; 8 | email: string; 9 | birthdate: Date; 10 | address: any; 11 | } 12 | 13 | export function toDomain(customer: CustomerPersistenceData): Customer { 14 | if (!customer) { 15 | return null; 16 | } 17 | 18 | return Customer.build({ 19 | id: new UniqueEntityID(customer.id), 20 | address: Address.build(customer.address), 21 | birthdate: customer.birthdate, 22 | cellphone: customer.cellphone, 23 | document: customer.document, 24 | email: customer.email, 25 | name: customer.name 26 | }) 27 | } 28 | 29 | export function toPersistence(customer: Customer): Partial { 30 | const customerPersistenceData: Partial = {} 31 | 32 | if (customer.isNew || customer.getDirtyProps().includes('id')) { 33 | customerPersistenceData.id = +customer.id.toValue(); 34 | } 35 | 36 | if (customer.isNew || customer.getDirtyProps().includes('document')) { 37 | customerPersistenceData.document = customer.document; 38 | } 39 | 40 | if (customer.isNew || customer.getDirtyProps().includes('email')) { 41 | customerPersistenceData.email = customer.email; 42 | } 43 | 44 | if (customer.isNew || customer.getDirtyProps().includes('cellphone')) { 45 | customerPersistenceData.cellphone = customer.cellphone; 46 | } 47 | 48 | if (customer.isNew || customer.getDirtyProps().includes('name')) { 49 | customerPersistenceData.name = customer.name; 50 | } 51 | 52 | if (customer.isNew || customer.getDirtyProps().includes('birthdate')) { 53 | customerPersistenceData.birthdate = customer.birthdate; 54 | } 55 | 56 | if (customer.isNew || customer.getDirtyProps().includes('address')) { 57 | customerPersistenceData.address = customer.address.toValue() 58 | } 59 | 60 | return customerPersistenceData; 61 | } -------------------------------------------------------------------------------- /src/adapters/common/models/http-request.ts: -------------------------------------------------------------------------------- 1 | export default interface HTTPRequest { 2 | params?: Params, 3 | headers?: Headers, 4 | body?: Body, 5 | query?: Query 6 | } -------------------------------------------------------------------------------- /src/adapters/common/models/http-response.ts: -------------------------------------------------------------------------------- 1 | type HTTPHeaders = { 2 | [key: string]: string 3 | } 4 | 5 | export default interface HTTPResponse { 6 | statusCode: number, 7 | message?: string, 8 | body?: T, 9 | headers?: HTTPHeaders 10 | }; 11 | 12 | export interface HTTPResponseHandler { 13 | send(response: HTTPResponse): void 14 | } -------------------------------------------------------------------------------- /src/adapters/common/models/line-item-persistence-data.ts: -------------------------------------------------------------------------------- 1 | import { LineItem } from '@entities'; 2 | 3 | export default interface LineItemPersistenceData { 4 | id: number; 5 | order_id: string; 6 | product_id: number; 7 | quantity: number; 8 | } 9 | 10 | export function toDomain(lineItem: LineItemPersistenceData): LineItem { 11 | if(!lineItem) { 12 | return null; 13 | } 14 | 15 | return LineItem.build({ 16 | id: lineItem.id, 17 | productId: lineItem.product_id, 18 | quantity: lineItem.quantity 19 | }, false); 20 | } 21 | 22 | export function toPersistence(lineItem: LineItem, orderId: string): Partial { 23 | const lineItemPersistenceData: Partial = {}; 24 | 25 | if (lineItem.isNew) { 26 | lineItemPersistenceData.order_id = orderId; 27 | lineItemPersistenceData.id = +lineItem.id.toValue(); 28 | } 29 | 30 | if (lineItem.isNew || lineItem.getDirtyProps().includes('productId')) { 31 | lineItemPersistenceData.product_id = +lineItem.productId.toValue(); 32 | } 33 | 34 | if (lineItem.isNew || lineItem.getDirtyProps().includes('quantity')) { 35 | lineItemPersistenceData.quantity = lineItem.quantity; 36 | } 37 | 38 | return lineItemPersistenceData; 39 | } -------------------------------------------------------------------------------- /src/adapters/common/models/order-persistence-data.ts: -------------------------------------------------------------------------------- 1 | import LineItemPersistenceData, * as LineItemMapper from "./line-item-persistence-data"; 2 | import { Address, AddressProps, Invoice, Order, UniqueEntityID } from "@entities"; 3 | 4 | export default interface OrderPersistenceData { 5 | id: string; 6 | customer_id: number; 7 | invoice_number?: string; 8 | invoice_url?: string; 9 | billing_address: any; 10 | line_items?: LineItemPersistenceData[]; 11 | } 12 | 13 | export function toDomain(order: OrderPersistenceData): Order { 14 | let invoice: Invoice; 15 | 16 | if (!order) { 17 | return null; 18 | } 19 | 20 | if (order.invoice_url && order.invoice_number) { 21 | invoice = { 22 | number: order.invoice_number, 23 | url: order.invoice_url 24 | } 25 | } 26 | 27 | if (!Array.isArray(order.line_items)) { 28 | order.line_items = [order.line_items]; 29 | } 30 | 31 | return Order.build({ 32 | id: new UniqueEntityID(order.id), 33 | billingAddress: Address.build(order.billing_address as AddressProps), 34 | buyerId: new UniqueEntityID(order.customer_id), 35 | builtLineItems: order.line_items?.map((lineItem) => LineItemMapper.toDomain(lineItem)), 36 | invoice 37 | }); 38 | } 39 | 40 | export function toPersistence(order: Order): Partial { 41 | const orderPersistenceData: Partial = {}; 42 | 43 | if (order.isNew || order.getDirtyProps().includes('id')) { 44 | orderPersistenceData.id = '' + order.id.toValue(); 45 | } 46 | 47 | if (order.isNew || order.getDirtyProps().includes('buyerId')) { 48 | orderPersistenceData.customer_id = +order.buyerId.toValue(); 49 | } 50 | 51 | if (order.isNew || order.getDirtyProps().includes('invoice')) { 52 | orderPersistenceData.invoice_number = order.invoice?.number; 53 | orderPersistenceData.invoice_url = order.invoice?.url; 54 | } 55 | 56 | if (order.isNew || order.getDirtyProps().includes('billingAddress')) { 57 | orderPersistenceData.billing_address = order.billingAddress.toValue(); 58 | } 59 | 60 | return orderPersistenceData; 61 | } 62 | -------------------------------------------------------------------------------- /src/adapters/common/models/product-persistence-data.ts: -------------------------------------------------------------------------------- 1 | import { Product, UniqueEntityID } from "@entities"; 2 | 3 | export default interface ProductPersistenceData { 4 | id: number; 5 | name: string; 6 | description: string; 7 | price: number; 8 | } 9 | 10 | export function toDomain(product: ProductPersistenceData): Product { 11 | if (!product) { 12 | return null; 13 | } 14 | 15 | return Product.build({ 16 | id: new UniqueEntityID(product.id), 17 | description: product.description, 18 | name: product.name, 19 | price: product.price 20 | }); 21 | } 22 | 23 | export function toPersistence(product: Product): Partial { 24 | const persistenceData: Partial = {}; 25 | 26 | if (product.isNew || product.getDirtyProps().includes('id')) { 27 | persistenceData.id = +product.id.toValue(); 28 | } 29 | 30 | if (product.isNew || product.getDirtyProps().includes('description')) { 31 | persistenceData.description = product.description; 32 | } 33 | 34 | if (product.isNew || product.getDirtyProps().includes('name')) { 35 | persistenceData.name = product.name; 36 | } 37 | 38 | if (product.isNew || product.getDirtyProps().includes('price')) { 39 | persistenceData.price = product.price; 40 | } 41 | 42 | return persistenceData; 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/adapters/common/repositories/customer.rep.ts: -------------------------------------------------------------------------------- 1 | import { Customer, UniqueEntityID } from '@entities'; 2 | import { CustomerDataMapper } from '../interfaces/data-mappers'; 3 | import { toDomain } from '../models/customer-persistence-data'; 4 | 5 | type GConstructor = new (...args: any[]) => T; 6 | 7 | export default function MixCustomerRepository(Gateway: TBase) { 8 | 9 | return class CustomerRepository extends Gateway { 10 | 11 | private _customerDataMapper: CustomerDataMapper 12 | 13 | constructor(...args: any[]) { 14 | super(...args); 15 | this._customerDataMapper = args[0].customerDataMapper; 16 | } 17 | 18 | public async findCustomerById(customerId: UniqueEntityID): Promise { 19 | const customerPersistenceData = await this._customerDataMapper.findById(+customerId.toValue()) 20 | return toDomain(customerPersistenceData); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/adapters/common/repositories/order.rep.ts: -------------------------------------------------------------------------------- 1 | import { Order, UniqueEntityID, LineItem } from '@entities'; 2 | import { OrderDataMapper, LineItemDataMapper } from '../interfaces/data-mappers'; 3 | import OrderPersistenceData, { toDomain as toOrderEntity, toPersistence as toOrderPersistence} from '../models/order-persistence-data'; 4 | import LineItemPersistenceData, { toPersistence as toLineItemPersistence} from '../models/line-item-persistence-data'; 5 | 6 | type GConstructor = new (...args: any[]) => T; 7 | 8 | export default function MixOrderRepository(Gateway: TBase) { 9 | 10 | return class OrderRepository extends Gateway { 11 | 12 | private _orderDataMapper: OrderDataMapper; 13 | private _lineItemDataMapper: LineItemDataMapper 14 | 15 | constructor(...args: any[]) { 16 | super(...args); 17 | this._orderDataMapper = args[0].orderDataMapper; 18 | this._lineItemDataMapper = args[0].lineItemDataMapper; 19 | } 20 | 21 | public async findOrderById(orderId: UniqueEntityID): Promise { 22 | const orderPersistenceData = await this._orderDataMapper.findByIdAndIncludeLineItems(orderId.toString()); 23 | return toOrderEntity(orderPersistenceData); 24 | } 25 | 26 | private async saveLineItems(lineItems: LineItem[], orderId: string, orderIsNew: boolean) { 27 | const newLineItems: LineItemPersistenceData[] = []; 28 | 29 | for (const lineItem of lineItems) { 30 | const lineItemPersistenceData = toLineItemPersistence(lineItem, orderId); 31 | 32 | if (lineItem.isNew) { 33 | newLineItems.push(lineItemPersistenceData as LineItemPersistenceData); 34 | } 35 | 36 | else if (lineItem.getDirtyProps()?.length > 0){ 37 | await this._lineItemDataMapper.updateByIdAndOrderId(+lineItem.id.toValue(), orderId, lineItemPersistenceData); 38 | } 39 | } 40 | 41 | if (!orderIsNew) { 42 | await this._lineItemDataMapper.deleteByOrderIdWhereIdNotInArray( 43 | orderId, 44 | lineItems.map((lineItem) => +lineItem.id.toValue()) 45 | ); 46 | } 47 | 48 | await this._lineItemDataMapper.bulckInsert(newLineItems); 49 | } 50 | 51 | public async saveOrder(order: Order) { 52 | const orderPersistenceData = toOrderPersistence(order); 53 | 54 | if (order.isNew) { 55 | await this._orderDataMapper.insert(orderPersistenceData as OrderPersistenceData); 56 | } else if (order.getDirtyProps()?.length > 0){ 57 | await this._orderDataMapper.updateById(order.id.toString(), orderPersistenceData); 58 | } 59 | 60 | await this.saveLineItems(order.lineItems, order.id.toString(), order.isNew); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/adapters/common/repositories/product.rep.ts: -------------------------------------------------------------------------------- 1 | import { Product, UniqueEntityID } from '@entities'; 2 | import { ProductDataMapper } from '../interfaces/data-mappers'; 3 | import { toDomain } from '../models/product-persistence-data'; 4 | 5 | type GConstructor = new (...args: any[]) => T; 6 | 7 | export default function MixProductRepository(Gateway: TBase) { 8 | 9 | return class ProductRepository extends Gateway { 10 | 11 | private _productDataMaper: ProductDataMapper; 12 | 13 | constructor(...args: any[]) { 14 | super(...args); 15 | this._productDataMaper = args[0].productDataMapper; 16 | } 17 | 18 | public async findProductById(productId: UniqueEntityID): Promise { 19 | const productPersistenceData = await this._productDataMaper.findById(+productId.toValue()) 20 | return toDomain(productPersistenceData); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/adapters/common/search-options.ts: -------------------------------------------------------------------------------- 1 | import { Criteria } from "./criteria"; 2 | 3 | type MaintainKey = 4 | { [K in keyof T]: T[K] extends U ? K : never }[keyof T] 5 | 6 | type SearchSort = keyof Partial | MaintainKey | MaintainKey>>; 7 | type SearchOrder = 'asc' | 'desc'; 8 | 9 | export type Include = { 10 | model: string 11 | criteria?: Criteria 12 | includes?: Include[] 13 | } 14 | 15 | export interface SearchOptions { 16 | criteria: Criteria; 17 | includes?: Include[] 18 | orderBy?: SearchOrder; 19 | sortBy?: SearchSort 20 | } -------------------------------------------------------------------------------- /src/adapters/common/services/invoice.service.ts: -------------------------------------------------------------------------------- 1 | import { Invoice } from "@entities"; 2 | import { OrderData } from "@useCases/common/get-order-data"; 3 | 4 | 5 | type GConstructor = new (...args: any[]) => T; 6 | 7 | export interface InvoiceGateway { 8 | generateInvoice(orderData: OrderData): Promise 9 | } 10 | 11 | export default function MixInvoiceService(Gateway: TBase) { 12 | return class InvoiceService extends Gateway implements InvoiceGateway { 13 | private _invoiceGateway: InvoiceGateway; 14 | 15 | constructor(...args: any[]) { 16 | super(...args); 17 | this._invoiceGateway = args[0].invoiceGateway; 18 | } 19 | 20 | public async generateInvoice(orderData: OrderData): Promise { 21 | return this._invoiceGateway.generateInvoice(orderData); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/adapters/common/services/unit-of-work.service.ts: -------------------------------------------------------------------------------- 1 | import { UnitOfWork } from "../interfaces/unit-of-work"; 2 | 3 | type GConstructor = new (...args: any[]) => T; 4 | 5 | export default function MixUnitOfWorkService(Gateway: TBase) { 6 | return class UnitOfWorkService extends Gateway implements UnitOfWork { 7 | private _uow: UnitOfWork; 8 | 9 | constructor(...args: any[]) { 10 | super(...args); 11 | this._uow = args[0].unitOfWork; 12 | } 13 | 14 | public async startTransaction(): Promise { 15 | return this._uow.startTransaction(); 16 | } 17 | 18 | public async commitTransaction(): Promise { 19 | return this._uow.commitTransaction(); 20 | } 21 | 22 | public async rollbackTransaction(): Promise { 23 | return this._uow.rollbackTransaction(); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/adapters/detail-order/http-detail-order.controller.ts: -------------------------------------------------------------------------------- 1 | import { DetailOrderInteractor } from '@useCases/detail-order'; 2 | import HTTPRequest from '@adapters/common/models/http-request'; 3 | 4 | interface HTTPDetailOrderParams { 5 | id: string 6 | } 7 | 8 | type HTTPDetailOrderInput = HTTPRequest 9 | 10 | interface HttpDetailOrderControllerParams { 11 | detailOrderInteractor: DetailOrderInteractor 12 | } 13 | 14 | export default class HttpDetailOrderController { 15 | private _interactor: DetailOrderInteractor 16 | 17 | constructor(params: HttpDetailOrderControllerParams) { 18 | this._interactor = params.detailOrderInteractor; 19 | } 20 | 21 | async run(input: HTTPDetailOrderInput) { 22 | await this._interactor.run(input.params.id); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/adapters/detail-order/http-detail-order.presenter.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationError } from '@useCases/common/errors'; 2 | import { OrderData } from '@useCases/common/get-order-data'; 3 | import Presenter from '@useCases/common/presenter'; 4 | import { HTTPResponseHandler } from '@adapters/common/models/http-response'; 5 | 6 | 7 | interface HTTPDetailOrderPresenterParams{ 8 | httpResponseHandler: HTTPResponseHandler<{ 9 | data: OrderData 10 | }> 11 | } 12 | 13 | export default class HTTPDetailOrderPresenter implements Presenter{ 14 | private _responseHandler: HTTPResponseHandler<{ 15 | data: OrderData 16 | }>; 17 | 18 | constructor(params: HTTPDetailOrderPresenterParams) { 19 | this._responseHandler = params.httpResponseHandler; 20 | } 21 | 22 | public showSuccess(response: OrderData) { 23 | const view = { 24 | statusCode: 200, 25 | body: { 26 | data: response 27 | } 28 | }; 29 | 30 | return this._responseHandler.send(view); 31 | } 32 | 33 | public showError(error: Error) { 34 | if (error instanceof ApplicationError && error.code === 'order_not_found') { 35 | return this._responseHandler.send({ 36 | statusCode: 404, 37 | message: 'Order not found' 38 | }); 39 | } 40 | 41 | return this._responseHandler.send({ 42 | statusCode: 500, 43 | message: 'Unexpected server error' 44 | }); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/adapters/generate-order-invoice/generate-order-invoice.gateway.ts: -------------------------------------------------------------------------------- 1 | import MixOrderRepository from '../common/repositories/order.rep'; 2 | import MixInvoiceService from '../common/services/invoice.service'; 3 | import MixUnitOfWorkService from '../common/services/unit-of-work.service'; 4 | 5 | const GenerateOrderInvoiceGateway = MixUnitOfWorkService(MixInvoiceService(MixOrderRepository(class {}))); 6 | export default GenerateOrderInvoiceGateway; 7 | -------------------------------------------------------------------------------- /src/adapters/generate-order-invoice/http-generate-order-invoice.controller.ts: -------------------------------------------------------------------------------- 1 | import { GenerateOrderInvoiceInteractor } from '@useCases/generate-order-invoice' 2 | import HTTPRequest from '@adapters/common/models/http-request'; 3 | 4 | interface HTTPGenerateOrderInvoiceParams { 5 | order_id: string 6 | } 7 | 8 | type HTTPGenerateOrderInvoiceInput = HTTPRequest 9 | 10 | interface HTTPGenerateOrderInvoiceControllerParams { 11 | generateOrderInvoiceInteractor: GenerateOrderInvoiceInteractor 12 | } 13 | 14 | export default class HTTPGenerateOrderInvoiceController { 15 | private _interactorr: GenerateOrderInvoiceInteractor 16 | 17 | constructor(params: HTTPGenerateOrderInvoiceControllerParams) { 18 | this._interactorr = params.generateOrderInvoiceInteractor; 19 | } 20 | 21 | async run(input: HTTPGenerateOrderInvoiceInput) { 22 | const orderId = input.params.order_id; 23 | await this._interactorr.run(orderId); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/adapters/generate-order-invoice/http-generate-order-invoice.presenter.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationError } from '@useCases/common/errors'; 2 | import Presenter from '@useCases/common/presenter'; 3 | import { HTTPResponseHandler } from '@adapters/common/models/http-response'; 4 | 5 | interface HTTPGenerateOrderInvoicePresenterParams{ 6 | httpResponseHandler: HTTPResponseHandler 7 | } 8 | 9 | export default class HTTPGenerateOrderInvoicePresenter implements Presenter { 10 | private _responseHandler: HTTPResponseHandler; 11 | 12 | constructor(params: HTTPGenerateOrderInvoicePresenterParams) { 13 | this._responseHandler = params.httpResponseHandler; 14 | } 15 | 16 | public showSuccess() { 17 | return this._responseHandler.send({ 18 | statusCode: 204 19 | }); 20 | } 21 | 22 | public showError(error: Error) { 23 | 24 | if (error instanceof ApplicationError && error.code === 'order_not_found') { 25 | return this._responseHandler.send({ 26 | statusCode: 404, 27 | message: 'Order not found' 28 | }); 29 | } 30 | 31 | return this._responseHandler.send({ 32 | statusCode: 500, 33 | message: 'Unexpected server error' 34 | }); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/adapters/generate-order/generate-order.gateway.ts: -------------------------------------------------------------------------------- 1 | import MixCustomerRepositoy from '../common/repositories/customer.rep'; 2 | import MixProductRepository from '../common/repositories/product.rep'; 3 | import MixOrderRepository from '../common/repositories/order.rep'; 4 | import MixUnitOfWorkService from '../common/services/unit-of-work.service'; 5 | 6 | const GenerateOrderGateway = MixUnitOfWorkService(MixOrderRepository(MixCustomerRepositoy(MixProductRepository(class {})))); 7 | export default GenerateOrderGateway; 8 | -------------------------------------------------------------------------------- /src/adapters/generate-order/http-generate-order.controller.ts: -------------------------------------------------------------------------------- 1 | import { AddressProps } from '@entities'; 2 | import { 3 | GenerateOrderInteractor, 4 | GenerateOrderRequestDTO 5 | } from '@useCases/generate-order'; 6 | import HTTPRequest from '@adapters/common/models/http-request'; 7 | 8 | interface HTTPGenerateOrderBody { 9 | customer_id: string, 10 | items: { 11 | product_id: number, 12 | quantity: number 13 | }[], 14 | billing_address: AddressProps, 15 | use_customer_address: boolean 16 | } 17 | 18 | type HTTPGenerateOrderInput = HTTPRequest 19 | 20 | interface HTTPGenerateOrderControllerParams { 21 | generateOrderInteractor: GenerateOrderInteractor 22 | } 23 | 24 | export default class HTTPGenerateOrderController { 25 | private _interactor: GenerateOrderInteractor 26 | 27 | constructor(params: HTTPGenerateOrderControllerParams) { 28 | this._interactor = params.generateOrderInteractor; 29 | } 30 | 31 | async run(input: HTTPGenerateOrderInput) { 32 | const request: GenerateOrderRequestDTO = { 33 | customerId: input.body.customer_id, 34 | items: input.body.items.map((item) => { 35 | return { 36 | productId: item.product_id, 37 | quantity: item.quantity 38 | } 39 | }), 40 | billingAddress: input.body.billing_address, 41 | shouldConsiderCustomerAddressForBilling: input.body.use_customer_address 42 | }; 43 | 44 | await this._interactor.run(request); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/adapters/generate-order/http-generate-order.presenter.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationError } from '@useCases/common/errors'; 2 | import { OrderData } from '@useCases/common/get-order-data'; 3 | import Presenter from '@useCases/common/presenter'; 4 | import { HTTPResponseHandler } from '@adapters/common/models/http-response'; 5 | 6 | interface HTTPGenerateOrderPresenterParams{ 7 | httpResponseHandler: HTTPResponseHandler<{ 8 | data: OrderData 9 | }> 10 | } 11 | 12 | export default class HTTPGenerateOrderPresenter implements Presenter { 13 | private _responseHandler: HTTPResponseHandler<{ 14 | data: OrderData 15 | }>; 16 | 17 | constructor(params: HTTPGenerateOrderPresenterParams) { 18 | this._responseHandler = params.httpResponseHandler; 19 | } 20 | 21 | public showSuccess(response: OrderData) { 22 | return this._responseHandler.send({ 23 | statusCode: 201, 24 | body: { 25 | data: response 26 | } 27 | }); 28 | } 29 | 30 | public showError(error: Error) { 31 | const heandleApplicationError = (error: ApplicationError) => { 32 | 33 | if (error.code === 'customer_not_found') { 34 | 35 | return this._responseHandler.send({ 36 | statusCode: 403, 37 | message: 'Forbbiden' 38 | }); 39 | } 40 | 41 | if (error.code === 'missing_order_billing_address') { 42 | 43 | return this._responseHandler.send({ 44 | statusCode: 400, 45 | message: 'Missing order billing address' 46 | }); 47 | } 48 | } 49 | 50 | if (error instanceof ApplicationError) { 51 | heandleApplicationError(error); 52 | } 53 | 54 | return this._responseHandler.send({ 55 | statusCode: 500, 56 | message: 'Unexpected server error' 57 | }); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/adapters/get-order-data/get-order-data.gateway.ts: -------------------------------------------------------------------------------- 1 | import MixOrderRepository from '../common/repositories/order.rep'; 2 | import MixProductRepository from '../common/repositories/product.rep'; 3 | import MixCustomerRepository from '../common/repositories/customer.rep'; 4 | 5 | const GetOrderDataGateway = MixOrderRepository(MixProductRepository(MixCustomerRepository(class {}))); 6 | export default GetOrderDataGateway; 7 | -------------------------------------------------------------------------------- /src/entities/address.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from '@entities'; 2 | import { ValueObjectError } from './common/value-object'; 3 | 4 | export interface AddressProps { 5 | street: string; 6 | neighborhood: string; 7 | city: string; 8 | number: string; 9 | state: string; 10 | country: string; 11 | complement: string; 12 | zipcode: string; 13 | }; 14 | 15 | export class AddressError extends ValueObjectError { 16 | constructor(errors: string[]) { 17 | super('Address', errors); 18 | } 19 | } 20 | 21 | export class Address extends ValueObject{ 22 | 23 | get street (): string { 24 | return this.props.street; 25 | } 26 | 27 | get neighborhood (): string { 28 | return this.props.neighborhood; 29 | } 30 | 31 | get city(): string { 32 | return this.props.city; 33 | } 34 | 35 | get number (): string { 36 | return this.props.number; 37 | } 38 | 39 | get state(): string { 40 | return this.props.state; 41 | } 42 | 43 | get country(): string { 44 | return this.props.country; 45 | } 46 | 47 | get complement(): string { 48 | return this.props.complement; 49 | } 50 | 51 | get zipcode(): string { 52 | return this.props.zipcode; 53 | } 54 | 55 | private constructor(props: AddressProps) { 56 | super(props); 57 | } 58 | 59 | public static build(props: AddressProps): Address { 60 | /** some domain validations here **/ 61 | 62 | let errors: Array = []; 63 | 64 | if (!props.street || props.street.length < 2) { 65 | errors.push('street_too_short'); 66 | } 67 | 68 | /** put some other validations here */ 69 | 70 | if (errors.length > 0) { 71 | throw new AddressError(errors); 72 | } 73 | 74 | return new Address(props); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/entities/common/domain-error.ts: -------------------------------------------------------------------------------- 1 | export class DomainError extends Error { 2 | public readonly errors: string[]; 3 | 4 | constructor(message: string, errors: string[] | string) { 5 | super(); 6 | const constructorName = this.constructor.name; 7 | this.name = constructorName; 8 | this.message = message; 9 | this.errors = Array.isArray(errors) ? errors : [errors]; 10 | } 11 | } -------------------------------------------------------------------------------- /src/entities/common/entity.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityIDGeneratorFactory, UniqueEntityID } from '@entities'; 2 | import { DomainError } from './domain-error'; 3 | 4 | const isEntity = (obj: any): obj is Entity => { 5 | return obj instanceof Entity; 6 | }; 7 | 8 | interface Properties { 9 | id?: UniqueEntityID 10 | } 11 | 12 | export class EntityError extends DomainError { 13 | constructor(entity: string, errors: string[] | string) { 14 | super(`Failed while manipulating ${entity} entity`, errors); 15 | } 16 | } 17 | 18 | /** 19 | * 20 | * @desc Entities are object that encapsulates corporation business rules. 21 | * We determine it's equality throgh entity unique id, if it exists. 22 | */ 23 | export abstract class Entity { 24 | private _dirtyProperties: string[]; 25 | protected readonly _id: UniqueEntityID; 26 | protected props: T; 27 | public readonly isNew: boolean; 28 | 29 | constructor(props: T, isNew: boolean = true) { 30 | const handler = () => { 31 | const setPropertyDirty = (prop: string) => { 32 | if (!this.isNew) { 33 | this._dirtyProperties.push(prop); 34 | } 35 | } 36 | 37 | return { 38 | set: function(obj: any, prop: string, value: any) { 39 | obj[prop] = value; 40 | setPropertyDirty(prop); 41 | return true; 42 | } 43 | }; 44 | } 45 | 46 | if (!props.id && !isNew) { 47 | throw new Error('Dirty Entities must has an ID'); 48 | } 49 | 50 | const idGenerator = UniqueEntityIDGeneratorFactory.getInstance().getIdGeneratorFor(this); 51 | this._id = props.id ? props.id : idGenerator.nextId(); 52 | this.isNew = isNew; 53 | this._dirtyProperties = []; 54 | this.props = new Proxy(props, handler()); 55 | } 56 | 57 | get id (): UniqueEntityID { 58 | return this._id; 59 | } 60 | 61 | public getDirtyProps(): string[] { 62 | return this._dirtyProperties; 63 | } 64 | 65 | public equals (entity?: Entity) : boolean { 66 | 67 | if (!entity || !isEntity(entity)) { 68 | return false; 69 | } 70 | 71 | if (this === entity) { 72 | return true; 73 | } 74 | 75 | return this._id.equals(entity._id);; 76 | } 77 | } -------------------------------------------------------------------------------- /src/entities/common/id-generator-factory.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityIDGenerator, Entity} from '@entities'; 2 | 3 | type EntityIDFactories = { 4 | [entity: string ]: UniqueEntityIDGenerator 5 | } 6 | 7 | export class UniqueEntityIDGeneratorFactory { 8 | private static _instance: UniqueEntityIDGeneratorFactory; 9 | private _entityIdFactories: EntityIDFactories; 10 | 11 | private constructor() { } 12 | 13 | public static getInstance(): UniqueEntityIDGeneratorFactory { 14 | if (!UniqueEntityIDGeneratorFactory._instance) { 15 | UniqueEntityIDGeneratorFactory._instance = new UniqueEntityIDGeneratorFactory(); 16 | } 17 | 18 | return UniqueEntityIDGeneratorFactory._instance; 19 | } 20 | 21 | public initialize(factories: EntityIDFactories) { 22 | this._entityIdFactories = factories; 23 | } 24 | 25 | public getIdGeneratorFor(entity: Entity): UniqueEntityIDGenerator { 26 | const className = entity.constructor.name; 27 | 28 | if (!this._entityIdFactories) { 29 | throw new Error('Entity ID Factories were not initialized'); 30 | } 31 | 32 | if (this._entityIdFactories[className]) { 33 | return this._entityIdFactories[className]; 34 | } 35 | 36 | return this._entityIdFactories['default']; 37 | } 38 | } -------------------------------------------------------------------------------- /src/entities/common/id-generator.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from "@entities"; 2 | 3 | export interface UniqueEntityIDGenerator { 4 | nextId(): UniqueEntityID 5 | } -------------------------------------------------------------------------------- /src/entities/common/identifier.ts: -------------------------------------------------------------------------------- 1 | const isIdentifier = (id: any): id is Identifier => { 2 | return id instanceof Identifier; 3 | }; 4 | 5 | export class Identifier { 6 | constructor(private value: T) { 7 | this.value = value; 8 | } 9 | 10 | public equals(id?: Identifier): boolean { 11 | if (!id || !isIdentifier(id)) { 12 | return false; 13 | } 14 | 15 | return id.toValue() === this.value; 16 | } 17 | 18 | public toString(): string { 19 | return String(this.value); 20 | } 21 | 22 | public toValue(): T { 23 | return this.value; 24 | } 25 | } -------------------------------------------------------------------------------- /src/entities/common/unique-entity-id.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from '@entities'; 2 | 3 | export class UniqueEntityID extends Identifier{ 4 | constructor(id: string | number) { 5 | if (!id) { 6 | throw new Error('UniqueEntityID must have a non null value'); 7 | } 8 | 9 | super(id) 10 | } 11 | } -------------------------------------------------------------------------------- /src/entities/common/value-object.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from "./domain-error"; 2 | 3 | interface ValueObjectProps { 4 | [index: string]: any; 5 | } 6 | 7 | export class ValueObjectError extends DomainError { 8 | constructor(valueObject: string, errors: string[]) { 9 | super(`Failed while building ${valueObject} object`, errors); 10 | } 11 | } 12 | 13 | /** 14 | * @desc ValueObjects are objects that represents a simple entity whose 15 | * equality is not based on idenity but determined through their structrual property. 16 | */ 17 | 18 | export abstract class ValueObject { 19 | public props: T; 20 | 21 | constructor(props: T) { 22 | let baseProps: any = { 23 | ...props, 24 | } 25 | 26 | this.props = baseProps; 27 | } 28 | 29 | public toValue(): T { 30 | return this.props; 31 | } 32 | 33 | public equals(vo?: ValueObject): boolean { 34 | if (!vo || !vo.props) { 35 | return false; 36 | } 37 | 38 | return JSON.stringify(this.props) === JSON.stringify(vo.props); 39 | } 40 | } -------------------------------------------------------------------------------- /src/entities/customer.ts: -------------------------------------------------------------------------------- 1 | import { Entity, UniqueEntityID, Address, EntityError } from '@entities'; 2 | 3 | export interface CustomerProps { 4 | id: UniqueEntityID, 5 | document: string; 6 | name: string; 7 | cellphone: string; 8 | email: string; 9 | birthdate: Date; 10 | address: Address; 11 | }; 12 | 13 | export class CustomerError extends EntityError { 14 | constructor(errors: string[]) { 15 | super('Customer', errors); 16 | } 17 | } 18 | 19 | export class Customer extends Entity{ 20 | 21 | get document(): string { 22 | return this.props.document; 23 | } 24 | 25 | get name(): string { 26 | return this.props.name; 27 | } 28 | 29 | get cellphone(): string { 30 | return this.props.cellphone; 31 | } 32 | 33 | get email(): string { 34 | return this.props.email; 35 | } 36 | 37 | get birthdate(): Date { 38 | return this.props.birthdate; 39 | } 40 | 41 | get address(): Address { 42 | return this.props.address; 43 | } 44 | 45 | private constructor(props: CustomerProps) { 46 | super(props, !props.id); 47 | } 48 | 49 | public static build(props: CustomerProps): Customer { 50 | /** some domain validations here **/ 51 | const errors: Array = []; 52 | 53 | if (props.document.length !== 11 && props.document.length !== 14) { 54 | errors.push('invalid_document'); 55 | } 56 | 57 | /** put other validations here */ 58 | 59 | if (errors.length > 0) { 60 | throw new CustomerError(errors); 61 | } 62 | 63 | return new Customer(props); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common/entity'; 2 | export * from './common/identifier'; 3 | export * from './common/unique-entity-id'; 4 | export * from './common/value-object'; 5 | export * from './address'; 6 | export * from './customer'; 7 | export * from './order'; 8 | export * from './line-item'; 9 | export * from './product'; 10 | export * from './common/id-generator'; 11 | export * from './common/id-generator-factory'; 12 | export * from './common/domain-error'; 13 | -------------------------------------------------------------------------------- /src/entities/line-item.ts: -------------------------------------------------------------------------------- 1 | import { Entity, UniqueEntityID, EntityError } from '@entities'; 2 | 3 | export interface LineItemBasicBuildProps { 4 | productId: number, 5 | quantity: number; 6 | } 7 | 8 | export interface LineItemBuidProps extends LineItemBasicBuildProps { 9 | id: number 10 | } 11 | 12 | export interface LineItemProps { 13 | id: UniqueEntityID 14 | productId: UniqueEntityID, 15 | quantity: number; 16 | }; 17 | 18 | export class LineItemError extends EntityError { 19 | constructor(errors: string[]) { 20 | super('LineItem', errors); 21 | } 22 | } 23 | 24 | export class LineItem extends Entity{ 25 | 26 | get productId(): UniqueEntityID { 27 | return this.props.productId; 28 | } 29 | 30 | get quantity(): number { 31 | return this.props.quantity; 32 | } 33 | 34 | private constructor(props: LineItemProps, isNew: boolean) { 35 | super(props, isNew); 36 | } 37 | 38 | public static build(props: LineItemBuidProps, isNew: boolean): LineItem { 39 | /** some domain validations here **/ 40 | const errors: Array = []; 41 | 42 | if (props.quantity % 1 !== 0) { 43 | errors.push('Line Item quantity must be integer'); 44 | } 45 | 46 | if (errors.length > 0) { 47 | throw new LineItemError(errors) 48 | } 49 | 50 | return new LineItem({ 51 | id: new UniqueEntityID(props.id), 52 | productId: new UniqueEntityID(props.productId), 53 | quantity: props.quantity 54 | }, isNew); 55 | } 56 | } -------------------------------------------------------------------------------- /src/entities/order.ts: -------------------------------------------------------------------------------- 1 | import { Address, LineItem, LineItemProps, LineItemBasicBuildProps, LineItemBuidProps, EntityError } from '@entities'; 2 | import { Entity, UniqueEntityID } from '@entities'; 3 | 4 | interface OrderLineItemBuildProps extends LineItemBasicBuildProps { 5 | id?: number 6 | } 7 | 8 | export interface Invoice { 9 | number: string, 10 | url?: string 11 | } 12 | 13 | interface OrderProps { 14 | id?: UniqueEntityID; 15 | billingAddress: Address; 16 | lineItems?: Array; 17 | buyerId: UniqueEntityID; 18 | invoice?: Invoice 19 | }; 20 | 21 | interface OrderBuildProps { 22 | id?: UniqueEntityID; 23 | billingAddress: Address; 24 | builtLineItems?: Array; 25 | lineItems?: Array; 26 | buyerId: UniqueEntityID; 27 | invoice?: Invoice 28 | }; 29 | 30 | export class OrderError extends EntityError { 31 | constructor(errors: string[] | string) { 32 | super('Order', errors); 33 | } 34 | } 35 | 36 | export class Order extends Entity{ 37 | public static MAX_NUMBER_OF_LINE_ITEMS_PER_ORDER = 7; 38 | private _lastLineItemId: UniqueEntityID; 39 | 40 | get billingAddress(): Address { 41 | return this.props.billingAddress; 42 | } 43 | 44 | get invoice(): Invoice { 45 | return this.props.invoice; 46 | } 47 | 48 | get lineItems(): Array { 49 | return this.props.lineItems || []; 50 | } 51 | 52 | get buyerId(): UniqueEntityID { 53 | return this.props.buyerId; 54 | } 55 | 56 | private constructor(props: OrderProps) { 57 | super(props, !props.id); 58 | this._lastLineItemId = this.lineItems[this.lineItems.length - 1].id; 59 | } 60 | 61 | public addInvoice(invoice: Invoice) { 62 | if (this.invoice) { 63 | throw new OrderError('Order already has an invoice'); 64 | } 65 | 66 | this.props.invoice = invoice; 67 | } 68 | 69 | public addLineItem(lineItemBasicProps: LineItemBasicBuildProps) { 70 | const errors: string[] = []; 71 | 72 | if (this.lineItems.length >= Order.MAX_NUMBER_OF_LINE_ITEMS_PER_ORDER) { 73 | errors.push('Max line items reached'); 74 | } 75 | 76 | const nextLineItemID = +this._lastLineItemId.toValue() + 1; 77 | 78 | let lineItemProps: LineItemBuidProps = { 79 | id: nextLineItemID, 80 | productId: lineItemBasicProps.productId, 81 | quantity: lineItemBasicProps.quantity 82 | }; 83 | 84 | const lineItem = LineItem.build(lineItemProps, true); 85 | 86 | if (errors.length > 0) { 87 | throw new OrderError(errors); 88 | } 89 | 90 | this._lastLineItemId = lineItem.id; 91 | this.lineItems.push(lineItem); 92 | } 93 | 94 | public static build(buildProps: OrderBuildProps): Order { 95 | /** some domain validations here **/ 96 | 97 | buildProps.builtLineItems?.sort((a, b) => +a.id.toValue() - +b.id.toValue()); 98 | 99 | const props: OrderProps = { 100 | id: buildProps.id, 101 | billingAddress: buildProps.billingAddress, 102 | buyerId: buildProps.buyerId, 103 | lineItems: buildProps.builtLineItems || [], 104 | invoice: buildProps.invoice 105 | }; 106 | 107 | const errors: string[] = []; 108 | 109 | if (buildProps.lineItems?.length >= Order.MAX_NUMBER_OF_LINE_ITEMS_PER_ORDER) { 110 | errors.push('Max line items reached'); 111 | } 112 | 113 | const existentLineItemProps = buildProps.lineItems?.find((item) => !!item.id) 114 | 115 | if (!buildProps.id && !!existentLineItemProps) { 116 | errors.push('It is not possible add existent line items to a new order'); 117 | throw new OrderError(errors); 118 | } 119 | 120 | const newLineItemProps = buildProps.lineItems?.find((item) => !item.id); 121 | 122 | if (!!buildProps.id && newLineItemProps) { 123 | errors.push('It is not allowed build an existent order with a new line item'); 124 | throw new OrderError(errors); 125 | } 126 | 127 | if (!!buildProps.id) { 128 | 129 | buildProps.lineItems?.sort((a, b) => +a.id - +b.id); 130 | for (let i = 0; i < buildProps.lineItems?.length; i++) { 131 | 132 | if (!buildProps.lineItems[i].id) { 133 | return; 134 | } 135 | 136 | const lineItem = LineItem.build(buildProps.lineItems[i] as LineItemBuidProps, false); 137 | props.lineItems.push(lineItem); 138 | } 139 | } else { 140 | 141 | for (let i = 0; i < buildProps.lineItems?.length; i++) { 142 | 143 | let newLineItemProps = buildProps.lineItems[i]; 144 | const lastLineItem = props.lineItems[props.lineItems.length - 1]; 145 | newLineItemProps.id = (+lastLineItem?.id?.toValue() || 0) + 1; 146 | 147 | const newlineItem = LineItem.build(newLineItemProps as LineItemBuidProps, true); 148 | props.lineItems.push(newlineItem); 149 | } 150 | } 151 | 152 | return new Order(props); 153 | } 154 | } -------------------------------------------------------------------------------- /src/entities/product.ts: -------------------------------------------------------------------------------- 1 | import { Entity, EntityError, UniqueEntityID } from '@entities'; 2 | 3 | interface ProductProps { 4 | id?: UniqueEntityID, 5 | name: string; 6 | description: string; 7 | price: number; 8 | }; 9 | 10 | export class ProductError extends EntityError { 11 | constructor(errors: string[]) { 12 | super('Product', errors); 13 | } 14 | } 15 | 16 | export class Product extends Entity{ 17 | 18 | get name (): string { 19 | return this.props.name; 20 | } 21 | 22 | get description (): string { 23 | return this.props.description; 24 | } 25 | 26 | get price(): number { 27 | return this.props.price; 28 | } 29 | 30 | private constructor (props: ProductProps) { 31 | super(props, !props.id); 32 | } 33 | 34 | public static build(props: ProductProps): Product { 35 | /** some domain validations here **/ 36 | const errors: Array = []; 37 | 38 | if (props.price < 0) { 39 | errors.push('Product price is too low'); 40 | } 41 | 42 | if(errors.length > 0) { 43 | throw new ProductError(errors); 44 | } 45 | 46 | return new Product(props); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/http-server.ts: -------------------------------------------------------------------------------- 1 | import './load-env'; 2 | import cookieParser from 'cookie-parser'; 3 | import path from 'path'; 4 | 5 | import express, { Request, Response, NextFunction } from 'express'; 6 | import { BAD_REQUEST } from 'http-status-codes'; 7 | import 'express-async-errors'; 8 | 9 | import BaseRouter from './infrastructure/web/routes'; 10 | import { Server } from 'http'; 11 | 12 | import createScopeContainer from '@infrastructure/web/middlewares/create-scope-container.middleware'; 13 | import { AwilixContainer } from 'awilix'; 14 | 15 | export function startHttpServer(container: AwilixContainer) { 16 | const app = express(); 17 | 18 | app.use(express.json()); 19 | app.use(express.urlencoded({ extended: true })); 20 | app.use(cookieParser()); 21 | 22 | app.use(createScopeContainer(container)); 23 | 24 | app.use('/api', BaseRouter); 25 | 26 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 27 | req; next; 28 | return res.status(BAD_REQUEST).json({ 29 | error: err.message, 30 | }); 31 | }); 32 | 33 | const staticDir = path.join(__dirname, 'public'); 34 | app.use(express.static(staticDir)); 35 | 36 | const port = Number(process.env.PORT || 3000); 37 | app.listen(port, () => { 38 | console.log('Express server started on port: ' + port); 39 | }); 40 | 41 | return Server; 42 | } 43 | 44 | export const shutdownHttpServer = async (server: any) => { 45 | return new Promise((resolve, reject) => { 46 | server.shutdown(async (err: Error) => { 47 | if (err) { 48 | reject(err); 49 | } else { 50 | resolve(); 51 | } 52 | }); 53 | }); 54 | }; 55 | 56 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import UUIDUniqueEntityIDGenerator from './infrastructure/plugins/uuid-id-generator' 3 | import { loadModels, unloadModels } from './infrastructure/db/models'; 4 | import { startHttpServer, shutdownHttpServer } from './http-server'; 5 | import { loadContainer } from './infrastructure/container'; 6 | import { AwilixContainer } from 'awilix'; 7 | import { UniqueEntityIDGeneratorFactory } from '@entities'; 8 | 9 | declare global { 10 | namespace Express { 11 | export interface Request { 12 | container: AwilixContainer 13 | } 14 | } 15 | } 16 | 17 | function setupIdFactories() { 18 | const factories = { 19 | 'default': new UUIDUniqueEntityIDGenerator() 20 | }; 21 | 22 | UniqueEntityIDGeneratorFactory 23 | .getInstance() 24 | .initialize(factories); 25 | 26 | console.log('Entity ID Generators initialized'); 27 | } 28 | 29 | /** init DB models and modules */ 30 | let server: any = null; 31 | 32 | async function init() { 33 | try { 34 | await loadModels(); 35 | const container = loadContainer(); 36 | 37 | setupIdFactories(); 38 | 39 | server = startHttpServer(container); 40 | 41 | console.log('Bootstrapped'); 42 | } catch (err) { 43 | console.log('Bootstrap error', err); 44 | shutdown(1, server); 45 | } 46 | } 47 | 48 | async function shutdown(exitCode: number, server: any) { 49 | console.info('Shutting down'); 50 | 51 | const stopHttpServer = async () => { 52 | try { 53 | await shutdownHttpServer(server); 54 | console.info('HTTP server closed'); 55 | } catch (err) { 56 | console.error('HTTP server shutdown error', { err }); 57 | } 58 | }; 59 | 60 | // @ts-ignore 61 | await Promise.allSettled([ 62 | stopHttpServer() 63 | ]); 64 | 65 | try { 66 | unloadModels(); 67 | console.info('Database connections closed'); 68 | } catch (err) { 69 | console.error('Databases shutdown error', { err }); 70 | } 71 | 72 | console.info('Bye'); 73 | process.exit(exitCode); 74 | } 75 | 76 | init(); -------------------------------------------------------------------------------- /src/infrastructure/container.ts: -------------------------------------------------------------------------------- 1 | import { createContainer, asClass, AwilixContainer, InjectionMode, Lifetime } from 'awilix'; 2 | import path from 'path'; 3 | 4 | function camalize(str: string) { 5 | return str.replace( 6 | /([-_][a-z])/g, 7 | (group) => group.toUpperCase() 8 | .replace('-', '') 9 | .replace('_', '') 10 | ); 11 | } 12 | 13 | let container: AwilixContainer = null; 14 | 15 | export function loadContainer(): AwilixContainer { 16 | 17 | if (container) { 18 | throw new Error('Awilix Container already loaded'); 19 | } 20 | 21 | container = createContainer({ 22 | injectionMode: InjectionMode.PROXY 23 | }); 24 | 25 | const baseDir = path.resolve(`${__dirname} + '/..`); 26 | 27 | container.loadModules([ 28 | `${baseDir}/use-cases/**/*.interactor.*`, 29 | `${baseDir}/adapters/**/*.presenter.*`, 30 | `${baseDir}/adapters/**/*.controller.*`, 31 | `${baseDir}/adapters/**/*.gateway.*`, 32 | `${baseDir}/infrastructure/plugins/**/*.*`, 33 | `${baseDir}/infrastructure/db/data-mappers/**/*.*` 34 | ], { 35 | formatName: (name: string) => { 36 | const infraLabelsRegex = /impl|mysql|redis|express|sql|aws|kms|dynamo|http|sequelize|gerencianet/gi; 37 | 38 | let moduleName = name.replace(infraLabelsRegex, ''); 39 | 40 | if (moduleName.startsWith('-')) { 41 | moduleName = moduleName.slice(1); 42 | } 43 | 44 | moduleName = moduleName.replace('.', '-'); 45 | 46 | return camalize(moduleName).replace('-', ''); 47 | }, 48 | resolverOptions: { 49 | register: asClass, 50 | lifetime: Lifetime.SCOPED 51 | } 52 | }); 53 | 54 | return container; 55 | }; 56 | 57 | 58 | export const getContainer = () => { 59 | return container; 60 | } -------------------------------------------------------------------------------- /src/infrastructure/db/data-mappers/sequelize-customer-data-mapper.ts: -------------------------------------------------------------------------------- 1 | import { CustomerDataMapper } from "@adapters/common/interfaces/data-mappers"; 2 | import { CustomerModel } from '../models/store/customer'; 3 | import SequelizeDataMapper from "../sequelize-data-mapper"; 4 | 5 | export default class SequelizeCustomerDataMapper extends SequelizeDataMapper implements CustomerDataMapper {} -------------------------------------------------------------------------------- /src/infrastructure/db/data-mappers/sequelize-line-item-data-mapper.ts: -------------------------------------------------------------------------------- 1 | import { LineItemDataMapper } from '@adapters/common/interfaces/data-mappers'; 2 | import LineItemPersistenceData from '@adapters/common/models/line-item-persistence-data'; 3 | import { UpdateOptions } from 'sequelize/types'; 4 | import { Op } from 'sequelize'; 5 | import { LineItemModel } from '../models/store/line-item'; 6 | import SequelizeDataMapper from "../sequelize-data-mapper"; 7 | 8 | export default class SequelizeLineItemDataMapper extends SequelizeDataMapper implements LineItemDataMapper { 9 | public async updateByIdAndOrderId(id: number, orderId: string, data: Partial): Promise { 10 | return this.update({ 11 | where: { 12 | id, 13 | order_id: orderId 14 | } 15 | }, data as any as UpdateOptions); 16 | } 17 | 18 | public async deleteByOrderIdWhereIdNotInArray(orderId: string, array: number[]): Promise { 19 | return this.delete({ 20 | order_id: orderId, 21 | id: { 22 | [Op.notIn]: array 23 | } 24 | }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/infrastructure/db/data-mappers/sequelize-order-data-mapper.ts: -------------------------------------------------------------------------------- 1 | import { OrderDataMapper } from "@adapters/common/interfaces/data-mappers"; 2 | import OrderPersistenceData from "@adapters/common/models/order-persistence-data"; 3 | import { LineItemModel } from "../models/store/line-item"; 4 | import { OrderModel } from '../models/store/order'; 5 | import SequelizeDataMapper from "../sequelize-data-mapper"; 6 | 7 | export default class SequelizeOrderDataMapper extends SequelizeDataMapper implements OrderDataMapper { 8 | public async findByIdAndIncludeLineItems(id: string): Promise { 9 | const model = await this.find({ id }, [{model: LineItemModel}]); 10 | return model as unknown as OrderPersistenceData; 11 | } 12 | } -------------------------------------------------------------------------------- /src/infrastructure/db/data-mappers/sequelize-product-data-mapper.ts: -------------------------------------------------------------------------------- 1 | import { ProductDataMapper } from "@adapters/common/interfaces/data-mappers"; 2 | import { ProductModel } from '../models/store/product'; 3 | import SequelizeDataMapper from "../sequelize-data-mapper"; 4 | 5 | export default class SequelizeProductDataMapper extends SequelizeDataMapper implements ProductDataMapper {} -------------------------------------------------------------------------------- /src/infrastructure/db/migrations/store/20200612161858-create-products-table.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: (queryInterface, Sequelize) => { 3 | return queryInterface.createTable('products', { 4 | id: { 5 | type: Sequelize.DataTypes.BIGINT.UNSIGNED, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | allowNull: false 9 | }, 10 | name: { 11 | type: Sequelize.DataTypes.STRING, 12 | allowNull: false 13 | }, 14 | description: { 15 | type: Sequelize.DataTypes.STRING, 16 | allowNull: false 17 | }, 18 | price: { 19 | type: Sequelize.DataTypes.INTEGER.UNSIGNED, 20 | allowNull: false 21 | }, 22 | created_at: Sequelize.DataTypes.DATE, 23 | updated_at: Sequelize.DataTypes.DATE 24 | }) 25 | .then(() => { 26 | return queryInterface.addIndex('products', ['id']); 27 | }); 28 | }, 29 | down: (queryInterface) => { 30 | return queryInterface.dropTable('products'); 31 | } 32 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/migrations/store/20200724142904-create-customers-table.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: (queryInterface, Sequelize) => { 3 | return queryInterface.createTable('customers', { 4 | id: { 5 | type: Sequelize.DataTypes.BIGINT.UNSIGNED, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | allowNull: false 9 | }, 10 | document: { 11 | type: Sequelize.DataTypes.STRING, 12 | allowNull: false 13 | }, 14 | name: { 15 | type: Sequelize.DataTypes.STRING, 16 | allowNull: false 17 | }, 18 | cellphone: { 19 | type: Sequelize.DataTypes.BIGINT.UNSIGNED, 20 | allowNull: false 21 | }, 22 | email: { 23 | type: Sequelize.DataTypes.STRING, 24 | allowNull: false 25 | }, 26 | birthdate: { 27 | type: Sequelize.DataTypes.DATEONLY, 28 | allowNull: false 29 | }, 30 | address: { 31 | type: Sequelize.DataTypes.JSON, 32 | allowNull: false 33 | }, 34 | created_at: Sequelize.DataTypes.DATE, 35 | updated_at: Sequelize.DataTypes.DATE 36 | }) 37 | .then(() => { 38 | return queryInterface.addIndex('customers', ['id']); 39 | }); 40 | }, 41 | down: (queryInterface) => { 42 | return queryInterface.dropTable('customers'); 43 | } 44 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/migrations/store/20200724142945-create-orders-table.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: (queryInterface, Sequelize) => { 3 | return queryInterface.createTable('orders', { 4 | id: { 5 | type: Sequelize.DataTypes.UUID, 6 | unique: true, 7 | defaultValue: Sequelize.DataTypes.UUIDV4, 8 | primaryKey: true, 9 | allowNull: false 10 | }, 11 | customer_id: { 12 | type: Sequelize.DataTypes.UUID, 13 | allowNull: false 14 | }, 15 | invoice_number: { 16 | type: Sequelize.DataTypes.STRING, 17 | unique: true, 18 | allowNull: true 19 | }, 20 | invoice_url: { 21 | type: Sequelize.DataTypes.STRING, 22 | allowNull: true 23 | }, 24 | billing_address: { 25 | type: Sequelize.DataTypes.JSON, 26 | allowNull: false 27 | }, 28 | created_at: Sequelize.DataTypes.DATE, 29 | updated_at: Sequelize.DataTypes.DATE 30 | }) 31 | .then(() => { 32 | return queryInterface.addIndex('orders', ['id']); 33 | }) 34 | .then(() => { 35 | return queryInterface.addIndex('orders', ['customer_id']); 36 | }) 37 | .then(() => { 38 | return queryInterface.addIndex('orders', ['invoice_number']); 39 | }) 40 | }, 41 | down: (queryInterface) => { 42 | return queryInterface.dropTable('orders'); 43 | } 44 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/migrations/store/20200724143002-create-line_items-table.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: (queryInterface, Sequelize) => { 3 | return queryInterface.createTable('line_items', { 4 | id: { 5 | type: Sequelize.DataTypes.UUID, 6 | defaultValue: Sequelize.DataTypes.UUIDV4, 7 | primaryKey: true, 8 | allowNull: false 9 | }, 10 | order_id: { 11 | type: Sequelize.DataTypes.UUID, 12 | primaryKey: true, 13 | allowNull: false 14 | }, 15 | product_id: { 16 | type: Sequelize.DataTypes.INTEGER, 17 | allowNull: false 18 | }, 19 | quantity: { 20 | type: Sequelize.DataTypes.INTEGER, 21 | allowNull: false 22 | }, 23 | created_at: Sequelize.DataTypes.DATE, 24 | updated_at: Sequelize.DataTypes.DATE 25 | }) 26 | .then(() => { 27 | return queryInterface.addIndex('line_items', ['id']); 28 | }) 29 | .then(() => { 30 | return queryInterface.addIndex('line_items', ['order_id']); 31 | }) 32 | .then(() => { 33 | return queryInterface.addIndex('line_items', ['product_id']); 34 | }) 35 | }, 36 | down: (queryInterface) => { 37 | return queryInterface.dropTable('line_items'); 38 | } 39 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/models.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { Sequelize, Options } from 'sequelize'; 4 | import sequelize from 'sequelize'; 5 | import { CustomerModel } from './models/store/customer'; 6 | import { OrderModel } from './models/store/order'; 7 | import { ProductModel } from './models/store/product'; 8 | import { LineItemModel } from './models/store/line-item'; 9 | import relations from './relations'; 10 | 11 | export type ModelMap = { 12 | customer: typeof CustomerModel, 13 | order: typeof OrderModel, 14 | product: typeof ProductModel, 15 | line_item: typeof LineItemModel 16 | } 17 | 18 | export interface DB { 19 | sequelize: Sequelize, 20 | connections: { 21 | [k: string]: Sequelize 22 | }, 23 | models: ModelMap 24 | } 25 | 26 | let db: DB = null; 27 | 28 | interface DatabasesConfig { 29 | [key: string]: Options 30 | } 31 | 32 | export const loadModels = async (): Promise => { 33 | const databases: DatabasesConfig = JSON.parse(fs.readFileSync(`${__dirname}/databases.json`).toString()); 34 | 35 | if (db) { 36 | throw new Error('DB models already loaded'); 37 | } 38 | 39 | const dbObj: any = { 40 | sequelize: sequelize, 41 | connections: {}, 42 | models: {} 43 | }; 44 | 45 | const connPromises = Object.keys(databases) 46 | .map((key) => { 47 | const defaultDbAttributes = { 48 | logging: console.log, 49 | timezone: '+00:00', 50 | define: { 51 | underscored: true 52 | }, 53 | dialectOptions: { 54 | timezone: '+00:00' 55 | }, 56 | retry: { 57 | max: 3 58 | }, 59 | isolationLevel: sequelize.Transaction.ISOLATION_LEVELS.READ_COMMITTED, 60 | bindParam: false, 61 | }; 62 | 63 | const dbOptions = {...databases[key], ...defaultDbAttributes}; 64 | 65 | const connection = new Sequelize( 66 | dbOptions.database, 67 | dbOptions.username, 68 | dbOptions.password, 69 | dbOptions 70 | ); 71 | 72 | const folderPath = `${__dirname}/models/${key}`; 73 | 74 | fs.readdirSync(folderPath) 75 | .forEach((file) => { 76 | let stats = fs.lstatSync(path.join(folderPath, file)); 77 | if (!stats.isDirectory()) { 78 | let model = connection.import(path.join(folderPath, file)); 79 | connection.models[model.name].schema(dbOptions.database); 80 | 81 | dbObj.connections[key] = connection; 82 | 83 | let modelName = model.name; 84 | dbObj.models[modelName] = model; 85 | } 86 | }); 87 | 88 | return connection.authenticate(); 89 | }); 90 | 91 | relations(dbObj); 92 | 93 | await Promise.all(connPromises); 94 | 95 | db = dbObj; 96 | 97 | return dbObj; 98 | }; 99 | 100 | export const getModels = () => { 101 | return db; 102 | }; 103 | 104 | export const getModel = (modelName: keyof ModelMap) => { 105 | return db.models[modelName]; 106 | } 107 | 108 | export const unloadModels = async (): Promise => { 109 | if (!db) { 110 | throw new Error('DB models are not loaded'); 111 | } 112 | 113 | // @ts-ignore 114 | await Promise.allSettled( 115 | Object 116 | .keys(db.connections) 117 | .map(async (key) => { 118 | return db.connections[key].close(); 119 | }) 120 | ); 121 | 122 | db = null; 123 | }; 124 | -------------------------------------------------------------------------------- /src/infrastructure/db/models/store/customer.ts: -------------------------------------------------------------------------------- 1 | import CustomerPersistenceData from '@adapters/common/models/customer-persistence-data'; 2 | import { Sequelize, Model, DataTypes } from 'sequelize'; 3 | 4 | export class CustomerModel extends Model implements CustomerPersistenceData { 5 | public id: number; 6 | public document: string; 7 | public name: string; 8 | public cellphone: string; 9 | public email: string; 10 | public birthdate: Date; 11 | public address: JSON; 12 | } 13 | 14 | export default (sequelize: Sequelize) => { 15 | return CustomerModel.init({ 16 | id: { 17 | type: DataTypes.BIGINT.UNSIGNED, 18 | autoIncrement: true, 19 | primaryKey: true, 20 | allowNull: false 21 | }, 22 | document: { 23 | type: DataTypes.STRING, 24 | allowNull: false 25 | }, 26 | name: { 27 | type: DataTypes.STRING, 28 | allowNull: false 29 | }, 30 | cellphone: { 31 | type: DataTypes.STRING, 32 | allowNull: false 33 | }, 34 | email: { 35 | type: DataTypes.STRING, 36 | allowNull: false 37 | }, 38 | birthdate: { 39 | type: DataTypes.DATEONLY, 40 | allowNull: false 41 | }, 42 | address: { 43 | type: DataTypes.JSON, 44 | allowNull: false 45 | } 46 | }, { 47 | sequelize, 48 | tableName: 'customers', 49 | modelName: 'customer', 50 | timestamps: false 51 | }) 52 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/models/store/line-item.ts: -------------------------------------------------------------------------------- 1 | import LineItemPersistenceData from '@adapters/common/models/line-item-persistence-data'; 2 | import { Sequelize, Model, DataTypes } from 'sequelize'; 3 | 4 | export class LineItemModel extends Model implements LineItemPersistenceData { 5 | public id: number; 6 | public order_id: string; 7 | public product_id: number; 8 | public quantity: number; 9 | } 10 | 11 | export default (sequelize: Sequelize) => { 12 | return LineItemModel.init({ 13 | id: { 14 | type: DataTypes.INTEGER, 15 | primaryKey: true, 16 | allowNull: false 17 | }, 18 | order_id: { 19 | type: DataTypes.UUID, 20 | primaryKey: true, 21 | allowNull: false 22 | }, 23 | product_id: { 24 | type: DataTypes.INTEGER, 25 | allowNull: false 26 | }, 27 | quantity: { 28 | type: DataTypes.INTEGER, 29 | allowNull: false 30 | }, 31 | }, { 32 | sequelize, 33 | tableName: 'line_items', 34 | modelName: 'line_item', 35 | timestamps: false 36 | }) 37 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/models/store/order.ts: -------------------------------------------------------------------------------- 1 | import OrderPersistenceData from '@adapters/common/models/order-persistence-data'; 2 | import { Sequelize, Model, DataTypes } from 'sequelize'; 3 | import { LineItemModel } from './line-item'; 4 | 5 | export class OrderModel extends Model implements OrderPersistenceData { 6 | public id: string; 7 | public customer_id: number; 8 | public invoice_number: string; 9 | public invoice_url: string; 10 | public billing_address: JSON; 11 | public line_items?: LineItemModel[]; 12 | } 13 | 14 | export default (sequelize: Sequelize) => { 15 | return OrderModel.init({ 16 | id: { 17 | type: DataTypes.UUID, 18 | unique: true, 19 | primaryKey: true, 20 | allowNull: false 21 | }, 22 | customer_id: { 23 | type: DataTypes.BIGINT.UNSIGNED, 24 | allowNull: false 25 | }, 26 | invoice_number: { 27 | type: DataTypes.STRING, 28 | unique: true, 29 | allowNull: true 30 | }, 31 | invoice_url: { 32 | type: DataTypes.STRING, 33 | allowNull: true 34 | }, 35 | billing_address: { 36 | type: DataTypes.JSON, 37 | allowNull: false 38 | } 39 | }, { 40 | sequelize, 41 | tableName: 'orders', 42 | modelName: 'order', 43 | timestamps: false 44 | }); 45 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/models/store/product.ts: -------------------------------------------------------------------------------- 1 | import ProductPersistenceData from '@adapters/common/models/product-persistence-data'; 2 | import { Sequelize, Model, DataTypes } from 'sequelize'; 3 | 4 | export class ProductModel extends Model implements ProductPersistenceData { 5 | public id: number; 6 | public name: string; 7 | public description: string; 8 | public price: number; 9 | } 10 | 11 | export default (sequelize: Sequelize) => { 12 | return ProductModel.init({ 13 | id: { 14 | type: DataTypes.BIGINT.UNSIGNED, 15 | allowNull: false, 16 | primaryKey: true, 17 | autoIncrement: true 18 | }, 19 | name: { 20 | type: DataTypes.STRING(50), 21 | allowNull: false, 22 | }, 23 | description: { 24 | type: DataTypes.STRING, 25 | }, 26 | price: { 27 | type: DataTypes.INTEGER.UNSIGNED, 28 | allowNull: false, 29 | } 30 | }, { 31 | sequelize, 32 | tableName: 'products', 33 | modelName: 'product', 34 | timestamps: false 35 | }) 36 | }; -------------------------------------------------------------------------------- /src/infrastructure/db/relations.ts: -------------------------------------------------------------------------------- 1 | import { DB } from './models'; 2 | 3 | export default (db: DB): void => { 4 | db.models.line_item.belongsTo( 5 | db.models.order, { foreignKey: 'order_id' } 6 | ); 7 | 8 | db.models.order.hasMany( 9 | db.models.line_item, { foreignKey: 'order_id' } 10 | ); 11 | 12 | db.models.order.belongsTo( 13 | db.models.customer, { foreignKey: 'customer_id' } 14 | ); 15 | 16 | db.models.line_item.hasOne( 17 | db.models.product, { sourceKey: 'product_id', foreignKey: 'id' } 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/infrastructure/db/seeders/store/20211231191642-insert-product.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.bulkInsert('products', [{ 6 | name: 'TV', 7 | description: 'TV 4k 55 pol', 8 | price: 15000 9 | }, { 10 | name: 'PS5', 11 | description: 'Playstation 5', 12 | price: 50000 13 | }], {}); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/infrastructure/db/seeders/store/20211231192509-insert-customer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.bulkInsert('customers', [{ 6 | id: 1, 7 | document: '99999999999', 8 | name: 'John Wick', 9 | cellphone: 99999999999, 10 | email: 'jonh.wick@teste.com', 11 | birthdate: '1970-01-01', 12 | address: JSON.stringify({ 13 | street: 'Rua Algum lugas', 14 | neighborhood: 'Somwhere', 15 | city: 'Ouro Preto', 16 | number: '50', 17 | state: 'Minas Gerais', 18 | country: 'Brasil', 19 | complement: 'casa', 20 | zipcode: '35400000' 21 | }) 22 | }], {}); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/infrastructure/db/sequelize-data-mapper.ts: -------------------------------------------------------------------------------- 1 | import { FindOptions, WhereOptions, Op, CreateOptions, DestroyOptions, UpdateOptions, IncludeOptions } from 'sequelize/types'; 2 | import { ModelCtor, Model as SequelizeModel} from 'sequelize'; 3 | import { getModels } from './models'; 4 | import { SequelizeUnitOfWork } from './sequelize-unit-of-work'; 5 | import { AbstractDataMapper } from '@adapters/common/interfaces/data-mappers'; 6 | 7 | export default abstract class SequelizeDataMapper implements AbstractDataMapper{ 8 | 9 | protected _uow: SequelizeUnitOfWork; 10 | protected _model: ModelCtor; 11 | constructor(container: any) { 12 | this._uow = container.unitOfWork; 13 | 14 | const camelToUnderscore = (key: string) => { 15 | var result = key.replace( /([A-Z])/g, " $1" ).slice(1); 16 | return result.split(' ').join('_').toLowerCase(); 17 | } 18 | 19 | const re = /Sequelize(\w+)DataMapper/; 20 | const modelName = camelToUnderscore(this.constructor.name.replace(re, '$1')); 21 | 22 | this._model = ((getModels().models) as any)[modelName] as ModelCtor; 23 | } 24 | 25 | public async findById(id: number | string): Promise { 26 | return this.find({ id }) as any as Model; 27 | } 28 | 29 | public async insert(model: Model): Promise { 30 | const transaction = await this._uow.transaction; 31 | const options: CreateOptions = {}; 32 | 33 | if (transaction) { 34 | options.transaction = transaction; 35 | } 36 | 37 | await this._model.create(model, options); 38 | } 39 | 40 | public async bulckInsert(models: Model[]): Promise { 41 | const transaction = await this._uow.transaction; 42 | const options: CreateOptions = {}; 43 | 44 | if (transaction) { 45 | options.transaction = transaction; 46 | } 47 | 48 | await this._model.bulkCreate(models, options); 49 | } 50 | 51 | public async updateById(id: number | string, data: Partial) { 52 | await this.update({id}, data as any as UpdateOptions); 53 | } 54 | 55 | private async _generateFindOptions(conditions: WhereOptions, include?: IncludeOptions[]): Promise { 56 | const transaction = await this._uow.transaction; 57 | const options: FindOptions = { 58 | where: conditions, 59 | include, 60 | raw: true 61 | }; 62 | 63 | if(include) { 64 | options.nest = true; 65 | } 66 | 67 | if (transaction) { 68 | options.transaction = transaction; 69 | options.lock = transaction.LOCK.UPDATE 70 | } 71 | 72 | return options; 73 | } 74 | 75 | protected async find(conditions: WhereOptions, include?: IncludeOptions[]): Promise> { 76 | const options = await this._generateFindOptions(conditions, include); 77 | return this._model.findOne(options); 78 | } 79 | 80 | protected async findAll(conditions: WhereOptions, include?: IncludeOptions[]): Promise[]> { 81 | const options = await this._generateFindOptions(conditions, include); 82 | return this._model.findAll(options); 83 | } 84 | 85 | protected async delete(conditions: WhereOptions): Promise { 86 | const transaction = await this._uow.transaction; 87 | const options: DestroyOptions = { 88 | where: conditions 89 | }; 90 | 91 | if (transaction) { 92 | options.transaction = transaction; 93 | } 94 | 95 | await this._model.destroy(options); 96 | } 97 | 98 | protected async update(conditions: WhereOptions, data: UpdateOptions): Promise { 99 | const transaction = await this._uow.transaction; 100 | const options: UpdateOptions = { 101 | where: conditions 102 | }; 103 | 104 | if (transaction) { 105 | options.transaction = transaction; 106 | } 107 | 108 | await this._model.update(data, options); 109 | } 110 | } -------------------------------------------------------------------------------- /src/infrastructure/db/sequelize-unit-of-work.ts: -------------------------------------------------------------------------------- 1 | import { UnitOfWork } from '@adapters/common/interfaces/unit-of-work'; 2 | import { Transaction } from 'sequelize/types'; 3 | import { DB, getModels } from './models'; 4 | 5 | export class SequelizeUnitOfWork implements UnitOfWork { 6 | private _db: DB; 7 | private _transaction: Transaction; 8 | 9 | constructor() { 10 | this._db = getModels(); 11 | } 12 | 13 | public async startTransaction(): Promise { 14 | this._transaction = await this._db.connections.store 15 | .transaction({autocommit: false}); 16 | } 17 | 18 | public async commitTransaction(): Promise { 19 | await this._transaction?.commit(); 20 | this._transaction = null; 21 | } 22 | 23 | public async rollbackTransaction(): Promise { 24 | await this._transaction?.rollback(); 25 | this._transaction = null; 26 | } 27 | 28 | get transaction (): Transaction { 29 | return this._transaction; 30 | } 31 | } -------------------------------------------------------------------------------- /src/infrastructure/plugins/gerencianet/credentials.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | client_id: 'Client_Id_d329359f89a409ba4ec7297da75774e98b386029', 5 | client_secret: 'Client_Secret_e1b1f79c8ab84a9c63e56dba9af915521d57ad29', 6 | sandbox: true 7 | } 8 | -------------------------------------------------------------------------------- /src/infrastructure/plugins/gerencianet/gerencianet-invoice.gateway.ts: -------------------------------------------------------------------------------- 1 | import { InvoiceGateway } from '@adapters/common/services/invoice.service'; 2 | import { Invoice } from '@entities'; 3 | import { OrderData } from '@useCases/common/get-order-data'; 4 | 5 | const Gerencianet = require('gn-api-sdk-node'); 6 | const credentials = require('./credentials'); 7 | 8 | export default class GerencianetInvoiceGateway implements InvoiceGateway { 9 | public async generateInvoice(orderData: OrderData): Promise { 10 | const options = { 11 | client_id: credentials.client_id, 12 | client_secret: credentials.client_secret, 13 | sandbox: credentials.sandbox 14 | } 15 | 16 | const items = orderData.lineItems.map((item) => { 17 | return { 18 | name: item.product.name, 19 | value: item.product.price, 20 | amount: item.quantity 21 | }; 22 | }); 23 | 24 | const body = { 25 | payment: { 26 | banking_billet: { 27 | expire_at: '2024-10-30', 28 | customer: { 29 | name: orderData.buyer.name, 30 | email: orderData.buyer.email, 31 | cpf: orderData.buyer.document, 32 | birth: orderData.buyer.birthdate, 33 | phone_number: `${orderData.buyer.cellphone}` 34 | } 35 | } 36 | }, 37 | items: items 38 | } 39 | 40 | var gerencianet = new Gerencianet(options); 41 | 42 | return gerencianet 43 | .oneStep([], body) 44 | .then(function (data: any) { 45 | data = data.data; 46 | 47 | return { 48 | number: data.charge_id, 49 | url: data.link 50 | } 51 | }) 52 | .catch((err: any) => { 53 | console.log(err); 54 | throw new Error('fail_to_generate_invoice'); 55 | }); 56 | } 57 | } -------------------------------------------------------------------------------- /src/infrastructure/plugins/uuid-id-generator.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto'; 2 | import { UniqueEntityID, UniqueEntityIDGenerator } from '@entities'; 3 | 4 | export default class UUIDUniqueEntityIDGenerator implements UniqueEntityIDGenerator { 5 | constructor() { } 6 | 7 | public nextId(): UniqueEntityID { 8 | return new UniqueEntityID(randomUUID()); 9 | } 10 | } -------------------------------------------------------------------------------- /src/infrastructure/web/execute-rule.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | export default function executeRule(rule: string) { 4 | return async (request: Request, res: Response, next: NextFunction) => { 5 | const container = request.container; 6 | 7 | const controller: any = container.resolve(`${rule}Controller`); 8 | 9 | try { 10 | return await controller.run(request, res, next); 11 | } catch (err: any) { 12 | console.error(err.message, { err: err }); 13 | 14 | return res.status(500).json({ 15 | name: 'unexpected_failure', 16 | description: 'Unexpected server error' 17 | }); 18 | } 19 | }; 20 | } -------------------------------------------------------------------------------- /src/infrastructure/web/express-response-handler.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import HTTPResponse, { HTTPResponseHandler } from '@adapters/common/models/http-response'; 3 | 4 | export default class ExpressResponseHandler implements HTTPResponseHandler { 5 | private _response: Response; 6 | 7 | constructor(response: Response) { 8 | this._response = response; 9 | } 10 | 11 | public send(data: HTTPResponse): any { 12 | if(data.headers) { 13 | Object.keys(data.headers).forEach((key: string) => { 14 | this._response.setHeader(key, data.headers[key]) 15 | }); 16 | } 17 | 18 | this._response.status(data.statusCode); 19 | 20 | if (data.body) { 21 | return this._response.json(data.body); 22 | } 23 | 24 | return this._response.send(data.message); 25 | } 26 | } -------------------------------------------------------------------------------- /src/infrastructure/web/middlewares/create-scope-container.middleware.ts: -------------------------------------------------------------------------------- 1 | import { SequelizeUnitOfWork } from '@infrastructure/db/sequelize-unit-of-work'; 2 | import { AwilixContainer, asValue } from 'awilix'; 3 | import { Request, Response, NextFunction } from 'express'; 4 | import ExpressResponseHandler from '../express-response-handler'; 5 | 6 | 7 | export default (container: AwilixContainer) => { 8 | return (req: Request, res: Response, next: NextFunction) => { 9 | const scope = container.createScope(); 10 | 11 | scope.register({ 12 | httpResponseHandler: asValue(new ExpressResponseHandler(res)), 13 | unitOfWork: asValue(new SequelizeUnitOfWork()) 14 | }); 15 | 16 | req.container = scope; 17 | 18 | next(); 19 | }; 20 | }; 21 | 22 | -------------------------------------------------------------------------------- /src/infrastructure/web/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import OrderRouter from './order'; 3 | 4 | const router = Router(); 5 | 6 | router.use('/orders', OrderRouter); 7 | 8 | export default router; 9 | -------------------------------------------------------------------------------- /src/infrastructure/web/routes/order.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import executeRule from '../execute-rule'; 3 | 4 | const router = Router(); 5 | 6 | router.route('/:id') 7 | .get(executeRule('detailOrder')); 8 | 9 | router.route('/') 10 | .post(executeRule('generateOrder')); 11 | 12 | router.route('/:order_id/invoice') 13 | .patch(executeRule('generateOrderInvoice')); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /src/load-env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import commandLineArgs from 'command-line-args'; 3 | 4 | // Setup command line options 5 | const options = commandLineArgs([ 6 | { 7 | name: 'env', 8 | alias: 'e', 9 | defaultValue: 'production', 10 | type: String, 11 | }, 12 | ]); 13 | 14 | // Set the env file 15 | const result2 = dotenv.config({ 16 | path: `./env/${options.env}.env`, 17 | }); 18 | 19 | if (result2.error) { 20 | throw result2.error; 21 | } 22 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | ../package.json -------------------------------------------------------------------------------- /src/use-cases/common/errors.ts: -------------------------------------------------------------------------------- 1 | export class ApplicationError extends Error { 2 | public readonly code: string; 3 | 4 | constructor(code: string, message?: string) { 5 | super(message || 'Aplication Error'); 6 | this.code = code; 7 | } 8 | } -------------------------------------------------------------------------------- /src/use-cases/common/get-order-data/get-order-data.dtos.ts: -------------------------------------------------------------------------------- 1 | import { AddressProps } from '@entities'; 2 | 3 | interface ProductDTO { 4 | name: string, 5 | description: string, 6 | price: number 7 | }; 8 | 9 | interface CustomerDTO { 10 | document: string; 11 | name: string; 12 | cellphone: string; 13 | email: string; 14 | birthdate: string; 15 | } 16 | 17 | interface LineItemDTO { 18 | product: ProductDTO, 19 | quantity: number 20 | }; 21 | 22 | export default interface OrderData { 23 | id: string, 24 | billingAddress: AddressProps, 25 | lineItems: Array 26 | buyer: CustomerDTO 27 | }; -------------------------------------------------------------------------------- /src/use-cases/common/get-order-data/get-order-data.gateway.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID, Order, Customer, Product } from '@entities'; 2 | 3 | export default interface GetOrderDataGateway { 4 | findOrderById(orderId: UniqueEntityID): Promise; 5 | findCustomerById(customerId: UniqueEntityID): Promise; 6 | findProductById(productID: UniqueEntityID): Promise; 7 | }; -------------------------------------------------------------------------------- /src/use-cases/common/get-order-data/get-order-data.interactor.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID, Product, LineItem, Customer, Order } from '@entities'; 2 | import OrderData from './get-order-data.dtos'; 3 | import GetOrderDataGateway from './get-order-data.gateway'; 4 | import { ApplicationError } from '@useCases/common/errors'; 5 | 6 | interface GetOrderDataInteractorParams { 7 | getOrderDataGateway: GetOrderDataGateway 8 | } 9 | export default class GetOrderDataInteractor { 10 | private _gateway: GetOrderDataGateway; 11 | 12 | constructor(params: GetOrderDataInteractorParams) { 13 | this._gateway = params.getOrderDataGateway; 14 | } 15 | 16 | private _mapProduct(product: Product) { 17 | return { 18 | name: product.name, 19 | description: product.description, 20 | price: product.price 21 | }; 22 | } 23 | 24 | private async _mapLineItem(lineItems: LineItem[]) { 25 | const itemsDTO = []; 26 | for (const lineItem of lineItems) { 27 | 28 | const product = await this._gateway 29 | .findProductById(lineItem.productId); 30 | 31 | const item = { 32 | product: this._mapProduct(product), 33 | quantity: lineItem.quantity 34 | }; 35 | 36 | itemsDTO.push(item); 37 | } 38 | 39 | return itemsDTO; 40 | } 41 | 42 | private _mapCustomer(customer: Customer) { 43 | return { 44 | name: customer.name, 45 | document: customer.document, 46 | email: customer.email, 47 | cellphone: customer.cellphone, 48 | address: customer.address.toValue(), 49 | birthdate: customer.birthdate.toString() 50 | } 51 | } 52 | 53 | public async execute(orderRef: string | Order): Promise { 54 | let order: Order; 55 | 56 | if (orderRef instanceof Order) { 57 | order = orderRef; 58 | } 59 | 60 | if (!order && typeof orderRef === 'string') { 61 | order = await this._gateway 62 | .findOrderById(new UniqueEntityID(orderRef)); 63 | } 64 | 65 | if (!order) { 66 | throw new ApplicationError('order_not_found'); 67 | } 68 | 69 | const buyer = await this._gateway 70 | .findCustomerById(order.buyerId); 71 | 72 | return { 73 | id: order.id.toString(), 74 | billingAddress: order.billingAddress.toValue(), 75 | lineItems: await this._mapLineItem(order.lineItems), 76 | buyer: this._mapCustomer(buyer) 77 | }; 78 | } 79 | } 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/use-cases/common/get-order-data/index.ts: -------------------------------------------------------------------------------- 1 | import OrderData from './get-order-data.dtos'; 2 | import GetOrderDataGateway from './get-order-data.gateway'; 3 | import GetOrderDataInteractor from './get-order-data.interactor'; 4 | 5 | export { 6 | OrderData, 7 | GetOrderDataGateway, 8 | GetOrderDataInteractor 9 | } -------------------------------------------------------------------------------- /src/use-cases/common/interactor.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from '@entities'; 2 | import { ApplicationError } from './errors'; 3 | import Presenter from './presenter'; 4 | 5 | export default abstract class Interactor { 6 | 7 | private _presenter: Presenter; 8 | 9 | protected abstract execute(input: InputModel): Promise 10 | 11 | constructor(presenter: any) { 12 | this._presenter = presenter; 13 | } 14 | 15 | public async run(input: InputModel) { 16 | 17 | try { 18 | 19 | const response = await this.execute(input); 20 | this._presenter.showSuccess(response); 21 | } catch (err) { 22 | console.log(err); 23 | 24 | if (err instanceof ApplicationError || err instanceof DomainError) { 25 | return this._presenter.showError(err); 26 | } 27 | 28 | return this._presenter.showError( 29 | new ApplicationError('unexpected_failure') 30 | ); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/use-cases/common/presenter.ts: -------------------------------------------------------------------------------- 1 | export default interface Presenter { 2 | showSuccess(response: ResponseModel): void; 3 | showError(error: Error): void; 4 | } 5 | -------------------------------------------------------------------------------- /src/use-cases/detail-order/detail-order.interactor.ts: -------------------------------------------------------------------------------- 1 | import { GetOrderDataInteractor, OrderData } from '@useCases/common/get-order-data'; 2 | import Interactor from '@useCases/common/interactor'; 3 | import Presenter from '@useCases/common/presenter'; 4 | 5 | interface DetailOrderInteractorParams { 6 | getOrderDataInteractor: GetOrderDataInteractor, 7 | detailOrderPresenter: Presenter 8 | } 9 | 10 | export default class DetailOrderInteractor extends Interactor { 11 | private _getOrderDataInteractor: GetOrderDataInteractor; 12 | 13 | constructor(params: DetailOrderInteractorParams) { 14 | super(params.detailOrderPresenter); 15 | this._getOrderDataInteractor = params.getOrderDataInteractor; 16 | } 17 | 18 | protected async execute(orderId: string) { 19 | 20 | return await this._getOrderDataInteractor 21 | .execute(orderId); 22 | } 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/use-cases/detail-order/index.ts: -------------------------------------------------------------------------------- 1 | import DetailOrderInteractor from './detail-order.interactor'; 2 | 3 | export { DetailOrderInteractor }; -------------------------------------------------------------------------------- /src/use-cases/generate-order-invoice/generate-order-invoice.gateway.ts: -------------------------------------------------------------------------------- 1 | import { OrderData } from '@useCases/common/get-order-data' 2 | import { Invoice, Order, UniqueEntityID } from '@entities'; 3 | 4 | export default interface GenerateOrderInvoiceGateway { 5 | startTransaction(): Promise; 6 | commitTransaction(): Promise; 7 | rollbackTransaction(): Promise; 8 | findOrderById(orderId: UniqueEntityID): Promise; 9 | generateInvoice(orderData: OrderData): Promise; 10 | saveOrder(order: Order): Promise; 11 | } -------------------------------------------------------------------------------- /src/use-cases/generate-order-invoice/generate-order-invoice.interactor.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from '@entities'; 2 | import GenerateOrderInvoiceGateway from './generate-order-invoice.gateway'; 3 | import Interactor from '@useCases/common/interactor'; 4 | import Presenter from '@useCases/common/presenter'; 5 | import { GetOrderDataInteractor } from '@useCases/common/get-order-data'; 6 | 7 | interface GenerateOrderInvoiceInteractorParams { 8 | getOrderDataInteractor: GetOrderDataInteractor, 9 | generateOrderInvoiceGateway: GenerateOrderInvoiceGateway, 10 | generateOrderInvoicePresenter: Presenter 11 | } 12 | 13 | export default class GenerateOrderInvoiceInteractor extends Interactor { 14 | private _getOrderDataInteractor: GetOrderDataInteractor; 15 | private _gateway: GenerateOrderInvoiceGateway; 16 | 17 | constructor(params: GenerateOrderInvoiceInteractorParams) { 18 | super(params.generateOrderInvoicePresenter); 19 | this._getOrderDataInteractor = params.getOrderDataInteractor; 20 | this._gateway = params.generateOrderInvoiceGateway; 21 | } 22 | 23 | protected async execute(orderId: string) { 24 | try { 25 | await this._gateway.startTransaction(); 26 | 27 | const order = await this._gateway 28 | .findOrderById(new UniqueEntityID(orderId)); 29 | 30 | const orderData = await this._getOrderDataInteractor 31 | .execute(order); 32 | 33 | const invoice = await this._gateway 34 | .generateInvoice(orderData); 35 | 36 | order.addInvoice(invoice); 37 | 38 | await this._gateway.saveOrder(order); 39 | await this._gateway.commitTransaction(); 40 | } catch(err) { 41 | await this._gateway.rollbackTransaction(); 42 | throw err; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/use-cases/generate-order-invoice/index.ts: -------------------------------------------------------------------------------- 1 | import GenerateOrderInvoiceInteractor from './generate-order-invoice.interactor'; 2 | import GenerateOrderGateway from './generate-order-invoice.gateway'; 3 | 4 | export { 5 | GenerateOrderInvoiceInteractor, 6 | GenerateOrderGateway 7 | }; -------------------------------------------------------------------------------- /src/use-cases/generate-order/generate-order.dtos.ts: -------------------------------------------------------------------------------- 1 | import { AddressProps, LineItemBasicBuildProps } from '@entities'; 2 | 3 | export default interface GenerateOrderRequestDTO { 4 | items: Array, 5 | customerId: string, 6 | billingAddress?: AddressProps, 7 | shouldConsiderCustomerAddressForBilling?: boolean 8 | }; 9 | -------------------------------------------------------------------------------- /src/use-cases/generate-order/generate-order.gateway.ts: -------------------------------------------------------------------------------- 1 | import { Customer, UniqueEntityID, Product, Order } from '@entities'; 2 | 3 | export default interface GenerateOrderGateway { 4 | startTransaction(): Promise; 5 | commitTransaction(): Promise; 6 | rollbackTransaction(): Promise; 7 | saveOrder(order: Order): Promise; 8 | findCustomerById(id: UniqueEntityID): Promise; 9 | findProductById(id: UniqueEntityID): Promise; 10 | }; -------------------------------------------------------------------------------- /src/use-cases/generate-order/generate-order.interactor.ts: -------------------------------------------------------------------------------- 1 | import { Order, Address, UniqueEntityID } from '@entities'; 2 | import GenerateOrderGateway from './generate-order.gateway'; 3 | import GenerateOrderRequestDTO from './generate-order.dtos'; 4 | import Interactor from '@useCases/common/interactor'; 5 | import Presenter from '@useCases/common/presenter'; 6 | import { ApplicationError } from '@useCases/common/errors'; 7 | import { GetOrderDataInteractor, OrderData } from '@useCases/common/get-order-data'; 8 | 9 | interface GenerateOrderInteractorParams { 10 | generateOrderGateway: GenerateOrderGateway, 11 | generateOrderPresenter: Presenter, 12 | getOrderDataInteractor: GetOrderDataInteractor 13 | } 14 | 15 | export default class GenerateOrderInteractor extends Interactor{ 16 | private _gateway: GenerateOrderGateway; 17 | private _getOrderDataInteractor: GetOrderDataInteractor; 18 | 19 | constructor(params: GenerateOrderInteractorParams) { 20 | super(params.generateOrderPresenter) 21 | this._gateway = params.generateOrderGateway; 22 | this._getOrderDataInteractor = params.getOrderDataInteractor; 23 | } 24 | 25 | protected async execute(data: GenerateOrderRequestDTO) { 26 | let billingAddress: Address | undefined; 27 | 28 | if(!!data.billingAddress) { 29 | billingAddress = Address.build(data.billingAddress); 30 | } 31 | 32 | const customer = await this._gateway 33 | .findCustomerById(new UniqueEntityID(data.customerId)); 34 | 35 | if (!customer) { 36 | throw new ApplicationError('customer_not_found'); 37 | } 38 | 39 | if (data.shouldConsiderCustomerAddressForBilling) { 40 | billingAddress = customer.address; 41 | } 42 | 43 | if (!billingAddress) { 44 | throw new ApplicationError('missing_order_billing_address'); 45 | } 46 | 47 | const order = Order.build({ 48 | billingAddress: billingAddress, 49 | lineItems: data.items, 50 | buyerId: customer.id 51 | }); 52 | 53 | try { 54 | await this._gateway.startTransaction(); 55 | await this._gateway.saveOrder(order); 56 | await this._gateway.commitTransaction(); 57 | } catch(err) { 58 | await this._gateway.rollbackTransaction(); 59 | throw err; 60 | } 61 | 62 | return await this._getOrderDataInteractor 63 | .execute(order); 64 | } 65 | } -------------------------------------------------------------------------------- /src/use-cases/generate-order/index.ts: -------------------------------------------------------------------------------- 1 | import GenerateOrderInteractor from './generate-order.interactor'; 2 | import GenerateOrderRequestDTO from './generate-order.dtos'; 3 | import GenerateOrderGateway from './generate-order.gateway'; 4 | 5 | export { 6 | GenerateOrderInteractor, 7 | GenerateOrderRequestDTO, 8 | GenerateOrderGateway 9 | }; -------------------------------------------------------------------------------- /src/use-cases/index.ts: -------------------------------------------------------------------------------- 1 | export * as GetOrderData from './common/get-order-data/index'; 2 | export * as DetailOrder from './detail-order/index'; 3 | export * as GenerateOrder from './generate-order/index'; 4 | export * as GenerateOrderInvoice from './generate-order-invoice/index'; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "declaration": false, 5 | "strict": true, 6 | "strictNullChecks": false, 7 | "target": "ES2019", 8 | "module": "commonjs", 9 | "resolveJsonModule": true, 10 | "allowJs": false, 11 | "checkJs": false, 12 | "outDir": "dist", 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "rootDir": "./src", 16 | "baseUrl": "./src", 17 | "paths": { 18 | "@infrastructure/*": [ 19 | "infrastructure/*" 20 | ], 21 | "@adapters/*": [ 22 | "adapters/*" 23 | ], 24 | "@entities": [ 25 | "entities/index" 26 | ], 27 | "@useCases/*": [ 28 | "use-cases/*" 29 | ] 30 | }, 31 | "moduleResolution": "node" 32 | }, 33 | "compileOnSave": true, 34 | "include": [ 35 | "src/**/*.ts", 36 | "src/**/*.js", 37 | "src/**/*.json" 38 | ], 39 | "exclude": [ 40 | "src/infrastructure/db/migrations/**/*", 41 | "src/infrastructure/db/seeders/**/*" 42 | ] 43 | } -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false 5 | }, 6 | "exclude": [ 7 | "spec", 8 | "src/**/*.mock.ts", 9 | "src/public/" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "member-ordering": false, 5 | "object-literal-sort-keys": false, 6 | "ordered-imports": false, 7 | "quotemark": [true, "single"] 8 | }, 9 | "linterOptions": { 10 | "exclude": [ 11 | "./node_modules/**" 12 | ] 13 | } 14 | } 15 | --------------------------------------------------------------------------------