├── .prettierrc ├── nest-cli.json ├── .env ├── tsconfig.build.json ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20220917155735_init │ │ └── migration.sql └── schema.prisma ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── src ├── pattern.ts ├── main.ts ├── prisma │ └── prisma.service.ts ├── app.module.ts ├── app.controller.spec.ts ├── app.controller.ts ├── app.service.ts ├── models.ts └── pay.controller.ts ├── tsconfig.json ├── .babelrc ├── serverless.yaml ├── .gitignore ├── .eslintrc.js ├── dockerfile ├── nginx ├── nginx.conf └── default.conf ├── docker-compose.yaml ├── README.md ├── webpack.config.js └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | SLS_DEBUG=* 2 | DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/pattern.ts: -------------------------------------------------------------------------------- 1 | export async function retry(time: number, f: () => Promise): Promise { 2 | if (time === 1) { 3 | return f(); 4 | } else { 5 | try { 6 | const res = await f(); 7 | return res; 8 | } catch (_) { 9 | return retry(time - 1, f); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./src" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "16" 8 | } 9 | } 10 | ], 11 | ["@babel/preset-typescript"] 12 | ], 13 | "plugins": [ 14 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 15 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 16 | "babel-plugin-parameter-decorator" 17 | ] 18 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import * as process from 'process'; 4 | import { ValidationPipe } from '@nestjs/common'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | app.useGlobalPipes(new ValidationPipe()); 9 | await app.listen(3000); 10 | process.on('SIGINT', () => { 11 | app.close(); 12 | }); 13 | } 14 | bootstrap(); 15 | -------------------------------------------------------------------------------- /src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | class PrismaService 6 | extends PrismaClient 7 | implements OnModuleInit, OnModuleDestroy 8 | { 9 | async onModuleInit() { 10 | await this.$connect(); 11 | } 12 | 13 | async onModuleDestroy() { 14 | await this.$disconnect(); 15 | } 16 | } 17 | 18 | export { PrismaService }; 19 | -------------------------------------------------------------------------------- /serverless.yaml: -------------------------------------------------------------------------------- 1 | useDotenv: true 2 | service: ticketing 3 | package: 4 | individually: true 5 | plugins: 6 | - serverless-plugin-typescript 7 | - serverless-offline 8 | 9 | provider: 10 | name: aws 11 | runtime: nodejs14.x 12 | region: ap-east-1 13 | lambdaHashingVersion: 20201221 14 | 15 | functions: 16 | main: # The name of the lambda function 17 | handler: src/lambda.handler 18 | events: 19 | - http: 20 | method: any 21 | path: /{any+} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | .build 37 | .serverless 38 | .webpack 39 | .webpackCache -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, OnModuleDestroy } from '@nestjs/common'; 2 | import { PayController } from 'pay.controller'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | import { PrismaService } from './prisma/prisma.service'; 6 | 7 | @Module({ 8 | imports: [], 9 | controllers: [AppController, PayController], 10 | providers: [PrismaService, AppService], 11 | }) 12 | class AppModule implements OnModuleDestroy { 13 | onModuleDestroy() { 14 | console.log('Safe terminating...'); 15 | } 16 | } 17 | 18 | export { AppModule }; 19 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine AS build 2 | 3 | WORKDIR /opt/app/ 4 | 5 | ADD ./package*.json package.json 6 | RUN npm install 7 | 8 | 9 | ADD ./prisma prisma 10 | RUN npx prisma generate 11 | 12 | ADD ./tsconfig.json tsconfig.json 13 | ADD ./tsconfig.build.json tsconfig.build.json 14 | ADD ./nest-cli.json nest-cli.json 15 | ADD ./src src 16 | 17 | RUN npm run build 18 | 19 | # RUN ls -al & sleep 30 20 | 21 | FROM node:alpine 22 | 23 | ENV NODE_ENV=production 24 | 25 | WORKDIR /opt/app/ 26 | 27 | ADD ./package*.json package.json 28 | RUN npm install --production 29 | 30 | COPY --from=build /opt/app/dist dist 31 | COPY --from=build /opt/app/node_modules/.prisma/client node_modules/.prisma/client 32 | 33 | EXPOSE 3000 34 | 35 | CMD ["node", "dist/main.js"] -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | error_log /var/log/nginx/error.log notice; 4 | pid /var/run/nginx.pid; 5 | 6 | 7 | events { 8 | worker_connections 4096; 9 | } 10 | 11 | 12 | http { 13 | include /etc/nginx/mime.types; 14 | default_type application/octet-stream; 15 | 16 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 17 | '$status $body_bytes_sent "$http_referer" ' 18 | '"$http_user_agent" "$http_x_forwarded_for"'; 19 | 20 | access_log /var/log/nginx/access.log main; 21 | 22 | sendfile on; 23 | #tcp_nopush on; 24 | 25 | keepalive_timeout 65; 26 | 27 | #gzip on; 28 | 29 | include /etc/nginx/conf.d/*.conf; 30 | } -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | server: 4 | deploy: 5 | replicas: 4 6 | build: 7 | context: ./ 8 | dockerfile: ./dockerfile 9 | environment: 10 | PORT: 3000 11 | NODE_ENV: production 12 | DATABASE_URL: postgresql://postgres:postgres@postgres/postgres?schema=public 13 | restart: always 14 | depends_on: 15 | - postgres 16 | nginx: 17 | image: nginx:alpine 18 | ports: 19 | - "8000:80" 20 | volumes: 21 | - ./static:/srv/www/static 22 | - ./nginx/default.conf:/etc/nginx/conf.d/default.conf 23 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf 24 | depends_on: 25 | - server 26 | postgres: 27 | image: postgres:alpine 28 | environment: 29 | POSTGRES_PASSWORD: postgres 30 | ports: 31 | - "5432:5432" -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { randomUUID } from 'crypto'; 3 | import { range } from 'ramda'; 4 | import { AppService } from './app.service'; 5 | import { PrismaService } from './prisma/prisma.service'; 6 | 7 | @Controller() 8 | class AppController { 9 | constructor( 10 | private readonly appService: AppService, 11 | private readonly prisma: PrismaService, 12 | ) {} 13 | 14 | @Get() 15 | async getHello(): Promise { 16 | return this.appService.getHello(); 17 | } 18 | 19 | @Get('/api/fake-data-fill') 20 | async fill(): Promise { 21 | const products = range(1, 100).map(() => { 22 | return { 23 | id: randomUUID(), 24 | quantity: 500, 25 | }; 26 | }); 27 | await this.prisma.product.createMany({ 28 | data: products, 29 | }); 30 | return 'ok'; 31 | } 32 | } 33 | 34 | export { AppController }; 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # How to run 2 | ## Prerequisites 3 | docker and docker-compose are required. 4 | ``` 5 | $ docker-compose up 6 | ``` 7 | ## DDL 8 | Run this to initialize the database. 9 | ``` 10 | $ npx prisma migrate deploy 11 | ``` 12 | ## Generate fake data 13 | ``` 14 | $ curl localhost:8000/api/fake-data-fill 15 | ``` 16 | ## Run 17 | You need to change `items[].productId` to the real UUID we generated in the previous command. 18 | The `orderId` UUID in URL path is randomly generated by the client to ensure idempotent. 19 | ``` 20 | $ curl --location --request PUT 'localhost:8000/api/purchase/9836e6dc-27ed-442f-9e49-b84fe5f8f7ce' \ 21 | --header 'Content-Type: application/json' \ 22 | --data-raw '{ 23 | "creditCardInfo": { 24 | "number": "376237924510443", 25 | "expireDate": "08/25", 26 | "CVC": "666" 27 | }, 28 | "items": [{ 29 | "productId": "1f32f40e-83a7-4b03-9448-7ad04c0445bc", 30 | "quantity": 10000 31 | }] 32 | }' 33 | ``` -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | previewFeatures = ["interactiveTransactions"] 12 | } 13 | 14 | model Product { 15 | id String @id @db.Uuid 16 | quantity Int 17 | orders OrderItem[] 18 | } 19 | 20 | model Order { 21 | id String @id @default(uuid()) @db.Uuid 22 | items OrderItem[] 23 | cardNo String 24 | state OrderState 25 | } 26 | 27 | model OrderItem { 28 | order Order @relation(fields: [orderId], references: [id]) 29 | orderId String @db.Uuid 30 | product Product @relation(fields: [productId], references: [id]) 31 | productId String @db.Uuid 32 | quantity Int 33 | @@id([orderId, productId]) 34 | } 35 | 36 | enum OrderState { 37 | FREE 38 | ORDER_CANCELLING 39 | LOCKED_WAIT_CONFIRM 40 | PAYING 41 | PAYED 42 | } -------------------------------------------------------------------------------- /nginx/default.conf: -------------------------------------------------------------------------------- 1 | 2 | server { 3 | proxy_http_version 1.1; # this is essential for chunked responses to work 4 | 5 | listen 80; ## listen for ipv4; this line is default and implied 6 | listen [::]:80 default ipv6only=on; ## listen for ipv6 7 | client_max_body_size 4G; 8 | server_name frontend; 9 | 10 | gzip on; 11 | gzip_disable "msie6"; 12 | gzip_vary on; 13 | gzip_proxied any; 14 | gzip_comp_level 6; 15 | gzip_buffers 16 8k; 16 | gzip_http_version 1.1; 17 | gzip_types application/javascript text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 18 | 19 | keepalive_timeout 5; 20 | 21 | location /static/ { 22 | alias /static/; 23 | } 24 | 25 | location / { 26 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 27 | proxy_set_header Host $http_host; 28 | 29 | # UNCOMMENT LINE BELOW IF THIS IS BEHIND A SSL PROXY 30 | #proxy_set_header X-Forwarded-Proto https; 31 | 32 | proxy_redirect off; 33 | proxy_pass http://server:3000; 34 | } 35 | } -------------------------------------------------------------------------------- /prisma/migrations/20220917155735_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "OrderState" AS ENUM ('FREE', 'ORDER_CANCELLING', 'LOCKED_WAIT_CONFIRM', 'PAYING', 'PAYED'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Product" ( 6 | "id" UUID NOT NULL, 7 | "quantity" INTEGER NOT NULL, 8 | 9 | CONSTRAINT "Product_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateTable 13 | CREATE TABLE "Order" ( 14 | "id" UUID NOT NULL, 15 | "cardNo" TEXT NOT NULL, 16 | "state" "OrderState" NOT NULL, 17 | 18 | CONSTRAINT "Order_pkey" PRIMARY KEY ("id") 19 | ); 20 | 21 | -- CreateTable 22 | CREATE TABLE "OrderItem" ( 23 | "orderId" UUID NOT NULL, 24 | "productId" UUID NOT NULL, 25 | "quantity" INTEGER NOT NULL, 26 | 27 | CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("orderId","productId") 28 | ); 29 | 30 | -- AddForeignKey 31 | ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 32 | 33 | -- AddForeignKey 34 | ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 35 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | class AppService { 5 | async getHello(): Promise { 6 | return 'Hello World!'; 7 | } 8 | 9 | async bookTicket(): Promise { 10 | const duration = 250 + Math.random() * (3000 - 250); 11 | await delay(duration); 12 | if (Math.random() > 0.2) { 13 | return; 14 | } else { 15 | throw new Error('Airline: ticket book failed'); 16 | } 17 | } 18 | 19 | async cancelTicket(): Promise { 20 | const duration = 250 + Math.random() * (3000 - 250); 21 | await delay(duration); 22 | if (Math.random() > 0.2) { 23 | return; 24 | } else { 25 | throw new Error('Airline: ticket cancel failed'); 26 | } 27 | } 28 | 29 | async pay() { 30 | const duration = 250 + Math.random() * (3000 - 250); 31 | await delay(duration); 32 | if (Math.random() > 0.1) { 33 | return; 34 | } else { 35 | throw new Error('Pay: failed'); 36 | } 37 | } 38 | } 39 | 40 | function delay(duration: number): Promise { 41 | return new Promise((resolve) => setTimeout(resolve, duration)); 42 | } 43 | 44 | export { AppService }; 45 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require('path'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | const slsw = require('serverless-webpack'); 5 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 6 | 7 | const isLocal = slsw.lib.webpack.isLocal; 8 | 9 | module.exports = { 10 | mode: isLocal ? 'development' : 'production', 11 | entry: slsw.lib.entries, 12 | externals: [nodeExternals()], 13 | devtool: 'source-map', 14 | resolve: { 15 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], 16 | }, 17 | output: { 18 | libraryTarget: 'commonjs2', 19 | path: path.join(__dirname, '.webpack'), 20 | filename: '[name].js', 21 | }, 22 | target: 'node', 23 | module: { 24 | rules: [ 25 | { 26 | // Include ts, tsx, js, and jsx files. 27 | test: /\.(ts|js)x?$/, 28 | exclude: /node_modules/, 29 | use: [ 30 | { 31 | loader: 'cache-loader', 32 | options: { 33 | cacheDirectory: path.resolve('.webpackCache'), 34 | }, 35 | }, 36 | 'babel-loader', 37 | ], 38 | }, 39 | ], 40 | }, 41 | plugins: [new ForkTsCheckerWebpackPlugin()], 42 | }; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stardust-ticket", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "MIT", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^9.0.11", 25 | "@nestjs/core": "^9.0.11", 26 | "@nestjs/platform-express": "^9.0.11", 27 | "@nestjs/schematics": "^9.0.3", 28 | "@prisma/client": "^4.3.1", 29 | "class-transformer": "^0.5.1", 30 | "class-validator": "^0.13.2", 31 | "ramda": "^0.28.0", 32 | "reflect-metadata": "^0.1.13", 33 | "rxjs": "^7.5.6" 34 | }, 35 | "devDependencies": { 36 | "@nestjs/cli": "^9.1.3", 37 | "@nestjs/testing": "^9.0.11", 38 | "@types/aws-lambda": "^8.10.77", 39 | "@types/express": "^4.17.11", 40 | "@types/jest": "^26.0.22", 41 | "@types/node": "^14.14.36", 42 | "@types/ramda": "^0.27.40", 43 | "@types/supertest": "^2.0.10", 44 | "@typescript-eslint/eslint-plugin": "^4.19.0", 45 | "@typescript-eslint/parser": "^4.19.0", 46 | "eslint": "^7.22.0", 47 | "eslint-config-prettier": "^8.1.0", 48 | "eslint-plugin-prettier": "^3.3.1", 49 | "jest": "^26.6.3", 50 | "plugin": "^0.0.15", 51 | "prettier": "^2.2.1", 52 | "prisma": "^4.3.1", 53 | "rimraf": "^3.0.2", 54 | "supertest": "^6.1.3", 55 | "ts-jest": "^26.5.4", 56 | "ts-node": "^10.9.1", 57 | "tsconfig-paths": "^3.9.0", 58 | "typescript": "^4.8.3" 59 | }, 60 | "jest": { 61 | "moduleFileExtensions": [ 62 | "js", 63 | "json", 64 | "ts" 65 | ], 66 | "rootDir": "src", 67 | "testRegex": ".*\\.spec\\.ts$", 68 | "transform": { 69 | "^.+\\.(t|j)s$": "ts-jest" 70 | }, 71 | "collectCoverageFrom": [ 72 | "**/*.(t|j)s" 73 | ], 74 | "coverageDirectory": "../coverage", 75 | "testEnvironment": "node" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | import { OrderState } from '@prisma/client'; 2 | import { 3 | IsCreditCard, 4 | Matches, 5 | IsUUID, 6 | IsInt, 7 | IsPositive, 8 | ValidateNested, 9 | IsDefined, 10 | ArrayMinSize, 11 | } from 'class-validator'; 12 | import { Type } from 'class-transformer'; 13 | 14 | export class PurchaseParam { 15 | @IsUUID() 16 | @IsDefined() 17 | orderId: string; 18 | } 19 | 20 | export class OrderItem { 21 | @IsUUID() 22 | @IsDefined() 23 | productId: string; 24 | @IsInt() 25 | @IsPositive() 26 | @IsDefined() 27 | quantity: number; 28 | } 29 | 30 | export class CreditCardInfo { 31 | @IsCreditCard() 32 | @IsDefined() 33 | number: string; 34 | @Matches(/(0[0-9])|(1[0-2])[/][0-9]{2}/g) 35 | @IsDefined() 36 | expireDate: string; 37 | @Matches(/[0-9]{3}/g) 38 | @IsDefined() 39 | CVC: string; 40 | } 41 | 42 | export class PurchasePayload { 43 | @ValidateNested() 44 | @IsDefined() 45 | @Type(() => CreditCardInfo) 46 | creditCardInfo: CreditCardInfo; 47 | 48 | @ValidateNested() 49 | @ArrayMinSize(1) 50 | @Type(() => OrderItem) 51 | items: OrderItem[]; 52 | } 53 | 54 | export type CompleteOrderSuccess = { 55 | tag: 'success'; 56 | }; 57 | 58 | export type CompleteOrderDenied = { 59 | tag: 'denied'; 60 | message: string; 61 | }; 62 | 63 | export type CompleteOrderError = { 64 | tag: 'error'; 65 | message: string; 66 | }; 67 | 68 | export type CompleteOrderReply = 69 | | CompleteOrderSuccess 70 | | CompleteOrderDenied 71 | | CompleteOrderError; 72 | 73 | export type FreeTicket = { 74 | id: string; 75 | flightId: string; 76 | price: number; 77 | orderState: typeof OrderState.FREE; 78 | }; 79 | 80 | export type LockedTicket = { 81 | id: string; 82 | flightId: string; 83 | travelerId: string; 84 | price: number; 85 | orderState: typeof OrderState.LOCKED_WAIT_CONFIRM; 86 | }; 87 | 88 | export type PayingTicket = { 89 | id: string; 90 | flightId: string; 91 | travelerId: string; 92 | price: number; 93 | orderState: typeof OrderState.PAYING; 94 | }; 95 | 96 | export type PayedTicket = { 97 | id: string; 98 | flightId: string; 99 | travelerId: string; 100 | price: number; 101 | orderState: typeof OrderState.PAYED; 102 | }; 103 | 104 | export type TicketType = FreeTicket | LockedTicket | PayingTicket | PayedTicket; 105 | 106 | export type CreateTicketSuccess = { 107 | ticketId: string; 108 | }; 109 | 110 | export type CreateTicketError = { 111 | message: string; 112 | }; 113 | 114 | export type CreateTicketReply = CreateTicketSuccess | CreateTicketError; 115 | 116 | export type CreateOrderRawQueryResult = { 117 | id: string; 118 | }; 119 | -------------------------------------------------------------------------------- /src/pay.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Param, Put, Res } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { OrderState, Prisma } from '@prisma/client'; 4 | import { PrismaService } from 'prisma/prisma.service'; 5 | import { PurchaseParam, PurchasePayload, CompleteOrderReply } from 'models'; 6 | import { sortBy } from 'ramda'; 7 | 8 | class OutOfStockError extends Error {} 9 | 10 | @Controller() 11 | export class PayController { 12 | constructor(private readonly prisma: PrismaService) {} 13 | 14 | @Put('/api/purchase/:orderId') 15 | async purchase( 16 | @Res({ passthrough: true }) res: Response, 17 | @Param() param: PurchaseParam, 18 | @Body() payload: PurchasePayload, 19 | ): Promise { 20 | // NOTE: Sorting items can prevent deadlocks in transactions. 21 | const items = sortBy((x) => x.productId, payload.items); 22 | 23 | try { 24 | // credit card charge 25 | await this.prisma.$transaction(async (prisma) => { 26 | await prisma.order.create({ 27 | data: { 28 | id: param.orderId, 29 | cardNo: payload.creditCardInfo.number, 30 | items: { 31 | createMany: { 32 | data: items, 33 | }, 34 | }, 35 | state: OrderState.PAYED, 36 | }, 37 | }); 38 | 39 | const promises = items.map(async (item) => { 40 | return prisma.product.updateMany({ 41 | data: { 42 | quantity: { decrement: item.quantity }, 43 | }, 44 | where: { 45 | id: item.productId, 46 | quantity: { 47 | gt: item.quantity, 48 | }, 49 | }, 50 | }); 51 | }); 52 | 53 | const withholding = await Promise.all(promises); 54 | 55 | const failed = withholding 56 | .map((res, index) => [res.count, index]) 57 | .filter(([count]) => count == 0) 58 | .map(([, index]) => items[index]); 59 | 60 | if (failed.length > 0) { 61 | const msg = items 62 | .map( 63 | (item) => 64 | `product(${item.productId}) not found or stock is less than ${item.quantity}`, 65 | ) 66 | .join('\n'); 67 | throw new OutOfStockError(msg); 68 | } 69 | }); 70 | return { tag: 'success' }; 71 | } catch (e) { 72 | if (e instanceof Prisma.PrismaClientKnownRequestError) { 73 | if (e.code === 'P2002') { 74 | res.status(400); 75 | return { tag: 'denied', message: 'Order conflict' }; 76 | } 77 | } else if (e instanceof OutOfStockError) { 78 | res.status(400); 79 | return { tag: 'denied', message: e.message }; 80 | } 81 | res.status(500); 82 | return { tag: 'error', message: e.toString() }; 83 | } 84 | } 85 | } 86 | --------------------------------------------------------------------------------