├── .DS_Store ├── .github └── workflows │ ├── deploy-auth.yml │ ├── deploy-client.yaml │ ├── deploy-expiration.yml │ ├── deploy-manifests.yml │ ├── deploy-orders.yml │ ├── deploy-payments.yml │ ├── deploy-tickets.yml │ ├── tests-auth.yaml │ ├── tests-orders.yaml │ ├── tests-payments.yaml │ └── tests-tickets.yaml ├── .gitignore ├── README.md ├── auth ├── .dockerignore ├── Dockerfile ├── package-lock.json ├── package.json ├── src │ ├── app.ts │ ├── index.ts │ ├── models │ │ └── user.ts │ ├── routes │ │ ├── __test__ │ │ │ ├── current-user.test.ts │ │ │ ├── signin.test.ts │ │ │ ├── signout.test.ts │ │ │ └── signup.test.ts │ │ ├── current-user.ts │ │ ├── signin.ts │ │ ├── signout.ts │ │ └── signup.ts │ ├── services │ │ └── password.ts │ └── test │ │ └── setup.ts └── tsconfig.json ├── client ├── .dockerignore ├── Dockerfile ├── api │ └── buildClient.js ├── components │ ├── header.js │ └── warning.js ├── hooks │ └── use-request.js ├── next.config.js ├── package-lock.json ├── package.json └── pages │ ├── _app.js │ ├── auth │ ├── signin.js │ ├── signout.js │ └── signup.js │ ├── index.js │ ├── orders │ ├── [orderId].js │ └── index.js │ └── tickets │ ├── [ticketId].js │ └── new.js ├── expiration ├── .dockerignore ├── Dockerfile ├── package-lock.json ├── package.json ├── src │ ├── __mocks__ │ │ └── nats-wrapper.ts │ ├── events │ │ ├── listeners │ │ │ ├── order-created-listener.ts │ │ │ └── queue-group-name.ts │ │ └── publishers │ │ │ └── expiration-complete-publisher.ts │ ├── index.ts │ ├── nats-wrapper.ts │ └── queues │ │ └── expiration-queue.ts └── tsconfig.json ├── infra ├── k8s-dev │ ├── client-depl.yaml │ └── ingress-srv.yaml ├── k8s-prod │ ├── client-depl.yaml │ └── ingress-srv.yaml └── k8s │ ├── auth-depl.yaml │ ├── auth-mongo-depl.yaml │ ├── expiration-depl.yaml │ ├── expiration-redis-depl.yaml │ ├── nats-depl.yaml │ ├── orders-depl.yaml │ ├── orders-mongo-depl.yaml │ ├── payments-depl.yaml │ ├── payments-mongo-depl.yaml │ ├── tickets-depl.yaml │ └── tickets-mongo-depl.yaml ├── orders ├── .dockerignore ├── Dockerfile ├── package-lock.json ├── package.json ├── src │ ├── __mocks__ │ │ └── nats-wrapper.ts │ ├── app.ts │ ├── events │ │ ├── listeners │ │ │ ├── __test__ │ │ │ │ ├── expiration-complete-listener.test.ts │ │ │ │ ├── ticket-created-listener.test.ts │ │ │ │ └── ticket-updated-listener.test.ts │ │ │ ├── expiration-complete-listener.ts │ │ │ ├── payment-created-listener.ts │ │ │ ├── queue-group-name.ts │ │ │ ├── ticket-created-listener.ts │ │ │ └── ticket-updated-listener.ts │ │ └── publishers │ │ │ ├── order-cancelled-publisher.ts │ │ │ └── order-created-pubisher.ts │ ├── index.ts │ ├── models │ │ ├── order.ts │ │ └── ticket.ts │ ├── nats-wrapper.ts │ ├── routes │ │ ├── __test__ │ │ │ ├── delete.test.ts │ │ │ ├── index.test.ts │ │ │ ├── new.test.ts │ │ │ └── show.test.ts │ │ ├── delete.ts │ │ ├── index.ts │ │ ├── new.ts │ │ └── show.ts │ └── test │ │ └── setup.ts └── tsconfig.json ├── payments ├── .dockerignore ├── Dockerfile ├── package-lock.json ├── package.json ├── src │ ├── __mocks__ │ │ ├── nats-wrapper.ts │ │ └── stripe.ts │ ├── app.ts │ ├── events │ │ ├── listeners │ │ │ ├── __test__ │ │ │ │ ├── order-cancelled-listener.test.ts │ │ │ │ └── order-created-listener.test.ts │ │ │ ├── order-cancelled-listener.ts │ │ │ ├── order-created-listener.ts │ │ │ └── queue-group-name.ts │ │ └── publishers │ │ │ └── payment-created-publisher.ts │ ├── index.ts │ ├── models │ │ ├── order.ts │ │ └── payment.ts │ ├── nats-wrapper.ts │ ├── routes │ │ ├── __test__ │ │ │ └── new.test.ts │ │ └── new.ts │ ├── stripe.ts │ └── test │ │ └── setup.ts └── tsconfig.json ├── skaffold.yaml └── tickets ├── .dockerignore ├── Dockerfile ├── package-lock.json ├── package.json ├── src ├── __mocks__ │ └── nats-wrapper.ts ├── app.ts ├── events │ ├── listeners │ │ ├── __test__ │ │ │ ├── order-cancelled-listener.test.ts │ │ │ └── order-created-listener.test.ts │ │ ├── order-cancelled-listener.ts │ │ ├── order-created-listener.ts │ │ └── queue-group-name.ts │ └── publishers │ │ ├── ticket-created-publisher.ts │ │ └── ticket-updated-publisher.ts ├── index.ts ├── models │ ├── __test__ │ │ └── ticket.test.ts │ └── ticket.ts ├── nats-wrapper.ts ├── routes │ ├── __test__ │ │ ├── index.test.ts │ │ ├── new.test.ts │ │ ├── show.test.ts │ │ └── update.test.ts │ ├── index.ts │ ├── new.ts │ ├── show.ts │ └── update.ts └── test │ └── setup.ts └── tsconfig.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weilyuwang/ticketing/504eb95066458fa60f7a39b68b3242a39d8b5f72/.DS_Store -------------------------------------------------------------------------------- /.github/workflows/deploy-auth.yml: -------------------------------------------------------------------------------- 1 | name: deploy-auth 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - 'auth/**' 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd auth && docker build -t weilyuwang/ticketing-auth . 14 | - run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 15 | env: 16 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 18 | - run: docker push weilyuwang/ticketing-auth 19 | - uses: digitalocean/action-doctl@v2 20 | with: 21 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 22 | - run: doctl kubernetes cluster kubeconfig save ticketing 23 | - run: kubectl rollout restart deployment auth-depl 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/deploy-client.yaml: -------------------------------------------------------------------------------- 1 | name: deploy-client 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "client/**" 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd client && docker build -t weilyuwang/ticketing-client . 14 | - run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 15 | env: 16 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 18 | - run: docker push weilyuwang/ticketing-client 19 | - uses: digitalocean/action-doctl@v2 20 | with: 21 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 22 | - run: doctl kubernetes cluster kubeconfig save ticketing 23 | - run: kubectl rollout restart deployment client-depl 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy-expiration.yml: -------------------------------------------------------------------------------- 1 | name: deploy-expiration 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "expiration/**" 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd expiration && docker build -t weilyuwang/ticketing-expiration . 14 | - run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 15 | env: 16 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 18 | - run: docker push weilyuwang/ticketing-expiration 19 | - uses: digitalocean/action-doctl@v2 20 | with: 21 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 22 | - run: doctl kubernetes cluster kubeconfig save ticketing 23 | - run: kubectl rollout restart deployment expiration-depl 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy-manifests.yml: -------------------------------------------------------------------------------- 1 | name: deploy-manifests 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - 'infra/**' 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: digitalocean/action-doctl@v2 14 | with: 15 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 16 | - run: doctl kubernetes cluster kubeconfig save ticketing 17 | - run: kubectl apply -f infra/k8s && kubectl apply -f infra/k8s-prod 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/deploy-orders.yml: -------------------------------------------------------------------------------- 1 | name: deploy-orders 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "orders/**" 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd orders && docker build -t weilyuwang/ticketing-orders . 14 | - run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 15 | env: 16 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 18 | - run: docker push weilyuwang/ticketing-orders 19 | - uses: digitalocean/action-doctl@v2 20 | with: 21 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 22 | - run: doctl kubernetes cluster kubeconfig save ticketing 23 | - run: kubectl rollout restart deployment orders-depl 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy-payments.yml: -------------------------------------------------------------------------------- 1 | name: deploy-payments 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "payments/**" 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd payments && docker build -t weilyuwang/ticketing-payments . 14 | - run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 15 | env: 16 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 18 | - run: docker push weilyuwang/ticketing-payments 19 | - uses: digitalocean/action-doctl@v2 20 | with: 21 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 22 | - run: doctl kubernetes cluster kubeconfig save ticketing 23 | - run: kubectl rollout restart deployment payments-depl 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy-tickets.yml: -------------------------------------------------------------------------------- 1 | name: deploy-tickets 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - "tickets/**" 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd tickets && docker build -t weilyuwang/ticketing-tickets . 14 | - run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 15 | env: 16 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 18 | - run: docker push weilyuwang/ticketing-tickets 19 | - uses: digitalocean/action-doctl@v2 20 | with: 21 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 22 | - run: doctl kubernetes cluster kubeconfig save ticketing 23 | - run: kubectl rollout restart deployment tickets-depl 24 | -------------------------------------------------------------------------------- /.github/workflows/tests-auth.yaml: -------------------------------------------------------------------------------- 1 | name: tests-auth 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "auth/**" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd auth && npm install && npm run test:ci 14 | -------------------------------------------------------------------------------- /.github/workflows/tests-orders.yaml: -------------------------------------------------------------------------------- 1 | name: tests-orders 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "orders/**" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd orders && npm install && npm run test:ci 14 | -------------------------------------------------------------------------------- /.github/workflows/tests-payments.yaml: -------------------------------------------------------------------------------- 1 | name: tests-payments 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "payments/**" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd payments && npm install && npm run test:ci 14 | -------------------------------------------------------------------------------- /.github/workflows/tests-tickets.yaml: -------------------------------------------------------------------------------- 1 | name: tests-tickets 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "tickets/**" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cd tickets && npm install && npm run test:ci 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ticketing App - Microservices & Event Driven Architecture 2 | 3 | ## Local Dev 4 | 5 | #### Set up ingress-nginx controller 6 | 7 | - This is required for both local dev K8s cluster and DigitalOcean K8s cluster 8 | 9 | Check `ingress-nginx` documentation: https://kubernetes.github.io/ingress-nginx/ 10 | 11 | --- 12 | 13 | #### Create secret tokens in k8s cluster 14 | 15 | - This is required for both local dev k8s and DigitalOcean k8s 16 | 17 | ``` 18 | e.g. To create a secret token in k8s cluster that is used for stripe payment service: 19 | 20 | kubectl create secret generic stripe-secret --from-literal STRIPE_KEY=[YOU_STRIPE_SECRET_KEY] 21 | 22 | And inside payment service k8s deployment config file (payments-depl.yaml): 23 | 24 | - name: STRIPE_KEY 25 | valueFrom: 26 | secretKeyRef: 27 | name: stripe-secret 28 | key: STRIPE_KEY 29 | 30 | ``` 31 | 32 | --- 33 | 34 | #### Set up mock host name (local dev) 35 | 36 | - To redirect requests coming to: ticketing.dev => localhost 37 | - only for local development purposes 38 | 39 | - MacOS/Linux: 40 | modify `/etc/hosts` file to include `127.0.0.1 ticketing.dev` 41 | 42 | - Windows: 43 | modify `C:\Windows\System32\Drivers\etc\hosts` file to include `127.0.0.1 ticketing.dev` 44 | 45 | - To skip the unskippable HTTPS warning in Chrome: 46 | try type **thisisunsafe** 47 | 48 | --- 49 | 50 | #### Skaffold (local dev) 51 | 52 | Install Skaffold Dev Tool: `brew install skaffold` 53 | 54 | From root project directory: run `skaffold dev` 55 | 56 | --- 57 | 58 | #### Authentication Strategies 59 | 60 | - Want no backend session storage when using Microservices architecture - so stick with `JWT`. 61 | 62 | - Want to use Server-Side Rendering and access user's auth information when HTML first gets rendered - so `store and transmit JWT within Cookies`, i.e. use cookies as a transport mechanism. 63 | 64 | - Want to be able to revoke a user - so use `short-lived JWT` (e.g. expired in 15 minutes) with `refresh` mechanism. 65 | 66 | 67 | ## CI/CD: Github Actions 68 | 69 | 70 | ## Deployment 71 | 72 | **Digital Ocean Kubernetes Cluster** 73 | 74 | app live at www.weilyuticketing.shop 75 | ***Update: the kubernetes cluster in digital ocean has been stopped*** 76 | 77 | 78 | ## Frontend Client 79 | 80 | #### Built with NextJS 81 | 82 | `Minimalistic ReactJS framework for rendering React app on the server. https://nextjs.org/` 83 | #### Routes 84 | 85 | - / 86 | `Show list of all tickets` 87 | 88 | - /auth/signin 89 | `Show sign in form` 90 | 91 | - /auth/signup 92 | `Show sign up form` 93 | 94 | - /auth/signout 95 | `Sign out` 96 | 97 | - /tickets/new 98 | `Form to create a new ticket` 99 | 100 | - /tickets/:ticketId 101 | `Details about a specific ticket` 102 | 103 | - /tickets/:orderId 104 | `Show info about an order and payment button` 105 | 106 | 107 | 108 | 109 | ## Common NPM Module 110 | 111 | All the commonly used classes, interfaces and middlewares, etc. are extracted into a published NPM Module. 112 | 113 | - `@wwticketing/common`: https://www.npmjs.com/package/@wwticketing/common 114 | 115 | Contains commonly used Middlewares and Error Classes for ticketing microservices 116 | 117 | Source codes: https://github.com/weilyuwang/ticketing-common-lib 118 | 119 | 120 | 121 | ## Backend API 122 | 123 | #### Microservices + Event-Driven Architecture 124 | 125 | - NATS Streaming Server 126 | 127 | #### Optimistic Concurrency Control 128 | 129 | - Leverage `mongoose-update-if-current` npm module to update mongodb documents' version. 130 | 131 | 132 | ## auth service 133 | 134 | - GET /api/users/currentUser 135 | `Get current user's information` 136 | > 137 | - POST /api/users/signup 138 | { "email": "test@gmail.com", "password": "123456" } 139 | `User Sign up` 140 | > 141 | - POST /api/users/signin 142 | { "email": "test@gmail.com", "password": "123456" } 143 | `User sign in` 144 | > 145 | - POST /api/users/signout 146 | {} 147 | `User sign out` 148 | 149 | 150 | ### tickets service 151 | 152 | - GET /api/tickets 153 | `Retrieve all tickets` 154 | > 155 | - GET /api/tickets/:id 156 | `Retrieve ticket with specific ID` 157 | > 158 | - POST /api/tickets 159 | { title: string, price: string } 160 | `Create a ticket` 161 | > 162 | - PUT /api/tickets/:id 163 | { title: string, price: string } 164 | `Update a ticket` 165 | 166 | 167 | ### orders service 168 | 169 | - GET /api/orders 170 | `Retrieve all active orders for the given user making the request` 171 | > 172 | - GET /api/orders/:id 173 | `Get details about a specific order` 174 | > 175 | - POST /api/orders 176 | { ticketId: string } 177 | `Create an order to purchase the specified ticket` 178 | > 179 | - DELETE /api/orders/:id 180 | `Cancel the order` 181 | 182 | 183 | ### expiration service 184 | 185 | - BullJS 186 | Use `Bull.js` to manage job queues, with job delay option 187 | 188 | - Redis 189 | Use `Redis` to store list of jobs 190 | 191 | 192 | ### payments service 193 | 194 | - StripeJS 195 | For handling payments 196 | 197 | - POST /api/payments 198 | { token: string, orderId: string} 199 | `Create new charge/payment` 200 | -------------------------------------------------------------------------------- /auth/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /auth/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # only install prod dependencies 6 | RUN npm install --only=prod 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth", 3 | "version": "1.0.0", 4 | "description": "Auth Service", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev src/index.ts --poll", 8 | "test": "jest --watchAll --no-cache", 9 | "test:ci": "jest" 10 | }, 11 | "jest": { 12 | "preset": "ts-jest", 13 | "testEnvironment": "node", 14 | "setupFilesAfterEnv": [ 15 | "./src/test/setup.ts" 16 | ] 17 | }, 18 | "keywords": [], 19 | "author": "Weilyu Wang", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@types/cookie-session": "2.0.39", 23 | "@types/express": "^4.17.6", 24 | "@types/jsonwebtoken": "^8.5.0", 25 | "@types/mongoose": "^5.7.23", 26 | "@wwticketing/common": "^1.0.5", 27 | "cookie-session": "^1.4.0", 28 | "express": "^4.17.1", 29 | "express-async-errors": "^3.1.1", 30 | "express-validator": "^6.5.0", 31 | "jsonwebtoken": "^8.5.1", 32 | "mongoose": "^5.9.18", 33 | "ts-node-dev": "^1.0.0-pre.44", 34 | "typescript": "^3.9.5" 35 | }, 36 | "devDependencies": { 37 | "@types/jest": "^26.0.0", 38 | "@types/supertest": "^2.0.9", 39 | "jest": "^26.0.1", 40 | "mongodb-memory-server": "^6.6.1", 41 | "supertest": "^4.0.2", 42 | "ts-jest": "^26.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /auth/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import "express-async-errors"; 3 | import { json } from "body-parser"; 4 | import cookieSession from "cookie-session"; 5 | import { currentUserRouter } from "./routes/current-user"; 6 | import { signinRouter } from "./routes/signin"; 7 | import { signupRouter } from "./routes/signup"; 8 | import { signoutRouter } from "./routes/signout"; 9 | import { errorHandlerMiddleware, NotFoundError } from "@wwticketing/common"; 10 | 11 | const app = express(); 12 | app.set("trust proxy", true); // trust ingress & nginx proxy 13 | app.use(json()); 14 | app.use( 15 | cookieSession({ 16 | signed: false, // disable encryption on the cookie - JWT is already secured 17 | // secure: process.env.NODE_ENV !== "test", // *** HTTPS connection only ***, but exception made for testing 18 | secure: false, 19 | }) 20 | ); 21 | 22 | // express routes 23 | app.use(currentUserRouter); 24 | app.use(signinRouter); 25 | app.use(signoutRouter); 26 | app.use(signupRouter); 27 | 28 | // use express-async-errors lib behind the scene to handle async errors 29 | app.all("*", async (req, res) => { 30 | throw new NotFoundError(); 31 | }); 32 | 33 | app.use(errorHandlerMiddleware); 34 | 35 | export { app }; 36 | -------------------------------------------------------------------------------- /auth/src/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { app } from "./app"; 3 | 4 | const start = async () => { 5 | console.log("Auth Service starting up......"); 6 | // First check if JWT_KEY is defined 7 | if (!process.env.JWT_KEY) { 8 | throw new Error("JWT_KEY must be defined"); 9 | } 10 | 11 | if (!process.env.MONGO_URI) { 12 | throw new Error("MONGO_URI must be defined"); 13 | } 14 | 15 | try { 16 | await mongoose.connect(process.env.MONGO_URI, { 17 | useNewUrlParser: true, 18 | useUnifiedTopology: true, 19 | useCreateIndex: true, 20 | }); 21 | console.log("Connected to MongoDB"); 22 | } catch (err) { 23 | console.log(err); 24 | } 25 | 26 | app.listen(3000, () => { 27 | console.log("Auth Service listening on port 3000"); 28 | }); 29 | }; 30 | 31 | start(); 32 | -------------------------------------------------------------------------------- /auth/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { Password } from "../services/password"; 3 | 4 | // An interface that describes the properties 5 | // that are required to create a new User 6 | interface UserAttrs { 7 | email: string; 8 | password: string; 9 | } 10 | 11 | // An interface that describes the properties 12 | // that a User Model has 13 | interface UserModel extends mongoose.Model { 14 | // build static method returns a User Docuemnt of type UserDoc (an instance of User Model) 15 | build(attrs: UserAttrs): UserDoc; 16 | } 17 | 18 | // An interface that describes the properties 19 | // that a User Document has 20 | // - Instances of Models are documents. 21 | interface UserDoc extends mongoose.Document { 22 | email: string; 23 | password: string; 24 | } 25 | 26 | /* 27 | * 1. Create a User Schema 28 | * Everything in Mongoose starts with a Schema. Each schema maps to a MongoDB collection 29 | * and defines the shape of the documents within that collection. 30 | */ 31 | const userSchema = new mongoose.Schema( 32 | { 33 | email: { 34 | type: String, 35 | required: true, 36 | }, 37 | password: { 38 | type: String, 39 | required: true, 40 | }, 41 | }, 42 | { 43 | toJSON: { 44 | transform(doc, ret) { 45 | ret.id = ret._id; 46 | delete ret._id; 47 | delete ret.password; // remove property of an object (javascript built in method) 48 | delete ret.__v; 49 | }, 50 | }, 51 | } 52 | ); 53 | 54 | // hash the password using pre-save hook 55 | userSchema.pre("save", async function (done) { 56 | if (this.isModified("password")) { 57 | const hashed = await Password.toHash(this.get("password")); 58 | this.set("password", hashed); 59 | } 60 | done(); 61 | }); 62 | 63 | /** 64 | * Statics 65 | * You can also add static functions to your model. 66 | * to do so: Add a function property to schema.statics 67 | */ 68 | userSchema.statics.build = (attrs: UserAttrs) => { 69 | return new User(attrs); 70 | }; 71 | 72 | /* 73 | * 2. Create a User Model 74 | * To use our schema definition, we need to convert our userSchema into a UserModel we can work with. 75 | * To do so, we pass it into mongoose.model(modelName, schema): 76 | */ 77 | const User: UserModel = mongoose.model("User", userSchema); 78 | 79 | // const testUesr = User.build({ 80 | // email: "email", 81 | // password: "password", 82 | // }); 83 | 84 | export { User }; 85 | -------------------------------------------------------------------------------- /auth/src/routes/__test__/current-user.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | 4 | it("responds with details with the current user", async () => { 5 | const cookie = await global.signup_and_get_cookie(); 6 | 7 | const response = await request(app) 8 | .get("/api/users/currentuser") 9 | .set("Cookie", cookie) 10 | .send() 11 | .expect(200); 12 | 13 | expect(response.body.currentUser.email).toEqual("test@test.com"); 14 | }); 15 | 16 | it("responds with null if not authenticated", async () => { 17 | const response = await request(app) 18 | .get("/api/users/currentuser") 19 | .send() 20 | .expect(200); 21 | 22 | expect(response.body.currentUser).toEqual(null); 23 | }); 24 | -------------------------------------------------------------------------------- /auth/src/routes/__test__/signin.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | 4 | it("fails when a email that does no exist is supplied", async () => { 5 | await request(app) 6 | .post("/api/users/signin") 7 | .send({ 8 | email: "test@test.com", 9 | password: "password", 10 | }) 11 | .expect(400); 12 | }); 13 | 14 | it("fails when an incorrect password is supplied", async () => { 15 | // first signup to register a user 16 | await request(app) 17 | .post("/api/users/signup") 18 | .send({ 19 | email: "test@test.com", 20 | password: "password", 21 | }) 22 | .expect(201); 23 | 24 | // then signin with an incorrect password 25 | await request(app) 26 | .post("/api/users/signin") 27 | .send({ 28 | email: "test@test.com", 29 | password: "incorrect_password", 30 | }) 31 | .expect(400); 32 | }); 33 | 34 | it("responds with a cookie when given valid credentials", async () => { 35 | // first signup to register a user 36 | await request(app) 37 | .post("/api/users/signup") 38 | .send({ 39 | email: "test@test.com", 40 | password: "password", 41 | }) 42 | .expect(201); 43 | 44 | // then signin with an incorrect password 45 | const response = await request(app) 46 | .post("/api/users/signin") 47 | .send({ 48 | email: "test@test.com", 49 | password: "password", 50 | }) 51 | .expect(200); 52 | 53 | expect(response.get("Set-Cookie")).toBeDefined(); 54 | }); 55 | -------------------------------------------------------------------------------- /auth/src/routes/__test__/signout.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | 4 | it("clears the cookie after signing out", async () => { 5 | await request(app) 6 | .post("/api/users/signup") 7 | .send({ 8 | email: "test@test.com", 9 | password: "password", 10 | }) 11 | .expect(201); 12 | 13 | const response = await request(app) 14 | .post("/api/users/signout") 15 | .send({}) 16 | .expect(200); 17 | 18 | expect(response.get("Set-Cookie")).toBeDefined(); 19 | }); 20 | -------------------------------------------------------------------------------- /auth/src/routes/__test__/signup.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | 4 | it("returns a 201 on successful signup", async () => { 5 | return request(app) 6 | .post("/api/users/signup") 7 | .send({ 8 | email: "test@test.com", 9 | password: "test_password", 10 | }) 11 | .expect(201); 12 | }); 13 | 14 | it("returns a 400 with an invalid email", async () => { 15 | return request(app) 16 | .post("/api/users/signup") 17 | .send({ 18 | email: "invalidemail.com", 19 | password: "test_password", 20 | }) 21 | .expect(400); 22 | }); 23 | 24 | it("returns a 400 with an invalid password", async () => { 25 | return request(app) 26 | .post("/api/users/signup") 27 | .send({ 28 | email: "invalidemail.com", 29 | password: "1", 30 | }) 31 | .expect(400); 32 | }); 33 | 34 | it("returns a 400 with missing email and password", async () => { 35 | await request(app) 36 | .post("/api/users/signup") 37 | .send({ email: "validemail@gmail.com" }) 38 | .expect(400); 39 | 40 | await request(app) 41 | .post("/api/users/signup") 42 | .send({ password: "validpassword" }) 43 | .expect(400); 44 | 45 | await request(app).post("/api/users/signup").send({}).expect(400); 46 | }); 47 | 48 | it("disallows duplicate emails", async () => { 49 | await request(app) 50 | .post("/api/users/signup") 51 | .send({ 52 | email: "valid@gmail.com", 53 | password: "validpass", 54 | }) 55 | .expect(201); 56 | 57 | await request(app) 58 | .post("/api/users/signup") 59 | .send({ 60 | email: "valid@gmail.com", 61 | password: "validpass", 62 | }) 63 | .expect(400); 64 | }); 65 | 66 | it("sets a cookie after successful signup", async () => { 67 | const response = await request(app) 68 | .post("/api/users/signup") 69 | .send({ 70 | email: "valid@gmail.com", 71 | password: "validpass", 72 | }) 73 | .expect(201); 74 | 75 | expect(response.get("Set-Cookie")).toBeDefined(); // test set-cookie header is defined 76 | }); 77 | -------------------------------------------------------------------------------- /auth/src/routes/current-user.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { currentUserMiddleware } from "@wwticketing/common"; 3 | 4 | const router = express.Router(); 5 | 6 | router.get("/api/users/currentuser", currentUserMiddleware, (req, res) => { 7 | res.send({ currentUser: req.currentUser || null }); 8 | // send { currentUser: user_info_payload } or send { currentUser: null } 9 | }); 10 | 11 | export { router as currentUserRouter }; 12 | -------------------------------------------------------------------------------- /auth/src/routes/signin.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { body } from "express-validator"; 3 | import { 4 | BadRequestError, 5 | validateRequestMiddleware, 6 | } from "@wwticketing/common"; 7 | import jwt from "jsonwebtoken"; 8 | import { User } from "../models/user"; 9 | import { Password } from "../services/password"; 10 | 11 | const router = express.Router(); 12 | 13 | router.post( 14 | "/api/users/signin", 15 | [ 16 | body("email").isEmail().withMessage("Email must be valid"), 17 | body("password") 18 | .trim() 19 | .notEmpty() 20 | .withMessage("You must supply a password"), 21 | ], 22 | validateRequestMiddleware, 23 | async (req: Request, res: Response) => { 24 | // extract email and password info from request body 25 | const { email, password } = req.body; 26 | 27 | // first check if the email has been registered 28 | const existingUser = await User.findOne({ email }); 29 | if (!existingUser) { 30 | throw new BadRequestError("Invalid credentials"); 31 | } 32 | 33 | // then compare passwords 34 | const passwordsMatch = await Password.compare( 35 | existingUser.password, 36 | password 37 | ); 38 | if (!passwordsMatch) { 39 | throw new BadRequestError("Invalid credentials"); 40 | } 41 | 42 | // if credentials are correctly matched 43 | 44 | // Generate a new JWT to user 45 | const userJwt = jwt.sign( 46 | { 47 | id: existingUser.id, 48 | email: existingUser.email, 49 | }, 50 | process.env.JWT_KEY! 51 | ); 52 | 53 | // Store the JWT on session object 54 | req.session = { 55 | jwt: userJwt, 56 | }; 57 | 58 | res.status(200).send(existingUser); 59 | } 60 | ); 61 | 62 | export { router as signinRouter }; 63 | -------------------------------------------------------------------------------- /auth/src/routes/signout.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | const router = express.Router(); 4 | 5 | router.post("/api/users/signout", (req, res) => { 6 | // signout === clear session 7 | req.session = null; 8 | 9 | // send back an empty response 10 | res.send({}); 11 | }); 12 | 13 | export { router as signoutRouter }; 14 | -------------------------------------------------------------------------------- /auth/src/routes/signup.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { User } from "../models/user"; 3 | import { body } from "express-validator"; 4 | import jwt from "jsonwebtoken"; 5 | import { 6 | BadRequestError, 7 | validateRequestMiddleware, 8 | } from "@wwticketing/common"; 9 | 10 | const router = express.Router(); 11 | 12 | router.post( 13 | "/api/users/signup", 14 | [ 15 | body("email").isEmail().withMessage("Email must be valid"), 16 | body("password") 17 | .trim() 18 | .isLength({ min: 4, max: 20 }) 19 | .withMessage("Password must be between 4 and 20 characters"), 20 | ], 21 | validateRequestMiddleware, 22 | async (req: Request, res: Response) => { 23 | // extract email and password info from request body 24 | const { email, password } = req.body; 25 | 26 | // first check if the email has been registered 27 | const existingUser = await User.findOne({ email }); 28 | if (existingUser) { 29 | throw new BadRequestError("Email already in use"); 30 | } 31 | 32 | // Create new user and persist it into MongoDB 33 | const user = User.build({ email, password }); 34 | await user.save(); 35 | 36 | // Generate JWT 37 | const userJwt = jwt.sign( 38 | { 39 | id: user.id, 40 | email: user.email, 41 | }, 42 | process.env.JWT_KEY! 43 | ); 44 | 45 | // Store it on session object 46 | req.session = { 47 | jwt: userJwt, 48 | }; 49 | 50 | res.status(201).send(user); 51 | } 52 | ); 53 | 54 | export { router as signupRouter }; 55 | -------------------------------------------------------------------------------- /auth/src/services/password.ts: -------------------------------------------------------------------------------- 1 | import { scrypt, randomBytes } from "crypto"; 2 | import { promisify } from "util"; 3 | 4 | // by promisify the scrypt, we can then use async-await syntax 5 | const scryptAsync = promisify(scrypt); 6 | 7 | export class Password { 8 | static async toHash(password: string) { 9 | const salt = randomBytes(8).toString("hex"); 10 | const buf = (await scryptAsync(password, salt, 64)) as Buffer; 11 | 12 | return `${buf.toString("hex")}.${salt}`; 13 | } 14 | 15 | static async compare(storedPassword: string, suppliedPassword: string) { 16 | const [hashedPassword, salt] = storedPassword.split("."); 17 | const buf = (await scryptAsync(suppliedPassword, salt, 64)) as Buffer; 18 | 19 | return buf.toString("hex") === hashedPassword; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /auth/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from "mongodb-memory-server"; 2 | import mongoose from "mongoose"; 3 | import { app } from "../app"; 4 | import request from "supertest"; 5 | 6 | declare global { 7 | namespace NodeJS { 8 | interface Global { 9 | signup_and_get_cookie(): Promise; 10 | } 11 | } 12 | } 13 | 14 | // declare in-memory mock mongo server 15 | let mongo: any; 16 | 17 | beforeAll(async () => { 18 | process.env.JWT_KEY = "test_jwt_key"; 19 | mongo = new MongoMemoryServer(); 20 | const mongoUri = await mongo.getUri(); 21 | 22 | await mongoose.connect(mongoUri, { 23 | useNewUrlParser: true, 24 | useUnifiedTopology: true, 25 | }); 26 | }); 27 | 28 | beforeEach(async () => { 29 | // get all existing collections in mongodb 30 | const collections = await mongoose.connection.db.collections(); 31 | 32 | for (let collection of collections) { 33 | await collection.deleteMany({}); 34 | } 35 | }); 36 | 37 | afterAll(async () => { 38 | await mongo.stop(); 39 | await mongoose.connection.close(); 40 | }); 41 | 42 | // global function only for test environment 43 | // signup and return the cookie from the response 44 | global.signup_and_get_cookie = async () => { 45 | const email = "test@test.com"; 46 | const password = "password"; 47 | 48 | const response = await request(app) 49 | .post("/api/users/signup") 50 | .send({ email, password }) 51 | .expect(201); 52 | 53 | const cookie = response.get("Set-Cookie"); 54 | 55 | return cookie; 56 | }; 57 | -------------------------------------------------------------------------------- /auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | RUN npm install 6 | COPY . . 7 | 8 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /client/api/buildClient.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | // a pre-configured axios client 4 | // receive a context object that has a key 'req', which contains the request-related properties 5 | export default ({ req }) => { 6 | // first check what environment we are in: browser or server 7 | if (typeof window === "undefined") { 8 | // we are on the server 9 | return axios.create({ 10 | baseURL: process.env.BASE_URL, 11 | headers: req.headers, 12 | }); 13 | } else { 14 | // We must be on the browser 15 | // Browsers gonna take care of the headers for us 16 | return axios.create({ 17 | baseURL: "/", 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /client/components/header.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default ({ currentUser }) => { 4 | // links = [false, false, { label: "Sign Out", href: "/auth/signout" }] 5 | // or = [{ label: "Sign Up", href: "/auth/signup" }, { label: "Sign In", href: "/auth/signin" }, false] 6 | 7 | const links = [ 8 | !currentUser && { label: "Sign Up", href: "/auth/signup" }, 9 | !currentUser && { label: "Sign In", href: "/auth/signin" }, 10 | currentUser && { label: "Sell Tickets", href: "/tickets/new" }, 11 | currentUser && { label: "My Orders", href: "/orders" }, 12 | currentUser && { label: "Sign Out", href: "/auth/signout" }, 13 | ] 14 | .filter((linkConfig) => linkConfig) 15 | .map(({ label, href }) => { 16 | return ( 17 |
  • 18 | 19 | {label} 20 | 21 |
  • 22 | ); 23 | }); 24 | 25 | console.log(process.env.ENVIRONMENT, process.env.BASE_URL); 26 | 27 | return ( 28 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /client/components/warning.js: -------------------------------------------------------------------------------- 1 | const Warning = () => { 2 | return ( 3 |
    12 | *Please use the following test credit card for payments* 13 |
    14 | 4242 4242 4242 4242 - Exp: 12/20 - CVV: 123 15 |
    16 | ); 17 | }; 18 | 19 | export default Warning; 20 | -------------------------------------------------------------------------------- /client/hooks/use-request.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useState } from "react"; 3 | 4 | export default ({ url, method, body, onSuccess }) => { 5 | const [errors, setErrors] = useState(null); 6 | 7 | const doRequest = async (props = {}) => { 8 | try { 9 | setErrors(null); 10 | const response = await axios[method](url, { ...body, ...props }); 11 | 12 | if (onSuccess) { 13 | onSuccess(response.data); 14 | } 15 | return response.data; 16 | } catch (err) { 17 | console.log(err); 18 | setErrors( 19 |
    20 |
      21 | {err.response.data.errors.map((error) => ( 22 |
    • {error.message}
    • 23 | ))} 24 |
    25 |
    26 | ); 27 | } 28 | }; 29 | 30 | return { doRequest, errors }; 31 | }; 32 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpackDevMiddleware: (config) => { 3 | config.watchOptions.poll = 300; 4 | return config; 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "next" 8 | }, 9 | "keywords": [], 10 | "author": "Weilyu Wang", 11 | "license": "ISC", 12 | "dependencies": { 13 | "axios": "^0.19.2", 14 | "bootstrap": "^4.5.0", 15 | "next": "^9.4.4", 16 | "react": "^16.13.1", 17 | "react-dom": "^16.13.1", 18 | "react-stripe-checkout": "^2.6.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/pages/_app.js: -------------------------------------------------------------------------------- 1 | import "bootstrap/dist/css/bootstrap.css"; 2 | import buildClient from "../api/buildClient"; 3 | import Header from "../components/header"; 4 | 5 | const AppComponent = ({ Component, pageProps, currentUser }) => { 6 | return ( 7 |
    8 |
    9 |
    10 | 11 |
    12 |
    13 | ); 14 | }; 15 | 16 | AppComponent.getInitialProps = async (appContext) => { 17 | // appContext = { Compoennt: { getInitialProps: ___ }, ctx: {req, res}, ... } 18 | 19 | const client = buildClient(appContext.ctx); 20 | const { data } = await client.get("/api/users/currentuser"); 21 | 22 | let pageProps = {}; 23 | 24 | // if child component has getInitialProps func defined, 25 | // invoke the child component's getInitialProps func from here 26 | if (appContext.Component.getInitialProps) { 27 | pageProps = await appContext.Component.getInitialProps( 28 | appContext.ctx, 29 | client, 30 | data.currentUser 31 | ); 32 | } 33 | 34 | return { 35 | pageProps, 36 | currentUser: data.currentUser, 37 | }; 38 | }; 39 | 40 | export default AppComponent; 41 | -------------------------------------------------------------------------------- /client/pages/auth/signin.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import useRequest from "../../hooks/use-request"; 3 | import Router from "next/router"; 4 | 5 | export default () => { 6 | const [email, setEmail] = useState(""); 7 | const [password, setPassword] = useState(""); 8 | const { doRequest, errors } = useRequest({ 9 | url: "/api/users/signin", 10 | method: "post", 11 | body: { 12 | email, 13 | password, 14 | }, 15 | onSuccess: () => Router.push("/"), 16 | }); 17 | 18 | const onSubmit = async (event) => { 19 | event.preventDefault(); 20 | 21 | await doRequest(); 22 | }; 23 | 24 | return ( 25 |
    26 |

    Sign In

    27 |
    28 | 29 | setEmail(e.target.value)} 32 | className="form-control" 33 | /> 34 |
    35 | 36 |
    37 | 38 | setPassword(e.target.value)} 43 | /> 44 |
    45 | {errors} 46 | 47 | 48 |
    49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /client/pages/auth/signout.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import useRequest from "../../hooks/use-request"; 3 | import Router from "next/router"; 4 | 5 | export default () => { 6 | const { doRequest } = useRequest({ 7 | url: "/api/users/signout", 8 | method: "post", 9 | body: {}, 10 | onSuccess: () => Router.push("/"), 11 | }); 12 | 13 | useEffect(() => { 14 | doRequest(); 15 | }, []); 16 | 17 | return
    Signing you out...
    ; 18 | }; 19 | -------------------------------------------------------------------------------- /client/pages/auth/signup.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import useRequest from "../../hooks/use-request"; 3 | import Router from "next/router"; 4 | 5 | export default () => { 6 | const [email, setEmail] = useState(""); 7 | const [password, setPassword] = useState(""); 8 | const { doRequest, errors } = useRequest({ 9 | url: "/api/users/signup", 10 | method: "post", 11 | body: { 12 | email, 13 | password, 14 | }, 15 | onSuccess: () => Router.push("/"), 16 | }); 17 | 18 | const onSubmit = async (event) => { 19 | event.preventDefault(); 20 | 21 | await doRequest(); 22 | }; 23 | 24 | return ( 25 |
    26 |

    Sign Up

    27 |
    28 | 29 | setEmail(e.target.value)} 32 | className="form-control" 33 | /> 34 |
    35 | 36 |
    37 | 38 | setPassword(e.target.value)} 43 | /> 44 |
    45 | {errors} 46 | 47 | 48 |
    49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /client/pages/index.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | const LandingPage = ({ currentUser, tickets }) => { 4 | const ticketList = tickets.map((ticket) => { 5 | return ( 6 | 7 | {ticket.title} 8 | {ticket.price} 9 | 10 | 11 | View 12 | 13 | 14 | 15 | ); 16 | }); 17 | 18 | return ( 19 |
    20 |

    Tickets

    21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {ticketList} 30 |
    TitlePriceLink
    31 |
    32 | ); 33 | }; 34 | 35 | // executed during the SSR process 36 | LandingPage.getInitialProps = async (context, client, currentUser) => { 37 | const { data } = await client.get("/api/tickets"); 38 | 39 | return { tickets: data }; 40 | }; 41 | 42 | export default LandingPage; 43 | -------------------------------------------------------------------------------- /client/pages/orders/[orderId].js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import StripeCheckout from "react-stripe-checkout"; 3 | import Warning from "../../components/warning"; 4 | import useRequest from "../../hooks/use-request"; 5 | import Router from "next/router"; 6 | 7 | const STRIPE_PUBLIC_KEY = 8 | "pk_test_51GzmrmEfLuseb67nBpvjxHmep8tGxj8DNiLHC48E8481QYlshdSXYNiDDpK60SdYDySNZ6tzn1vM5k3xwdXjOnqo0067GjfmxI"; 9 | 10 | const OrderShow = ({ currentUser, order }) => { 11 | const [timeLeft, setTimeLeft] = useState(0); 12 | 13 | const { doRequest, errors } = useRequest({ 14 | url: "/api/payments", 15 | method: "post", 16 | body: { 17 | orderId: order.id, 18 | }, 19 | onSuccess: () => Router.push("/orders"), 20 | }); 21 | 22 | useEffect(() => { 23 | const findTimeLeft = () => { 24 | const msLeft = new Date(order.expiresAt) - new Date(); 25 | setTimeLeft(Math.round(msLeft / 1000)); 26 | }; 27 | 28 | // setInterval has 1 second delay, so we need to manually invoke findTimeLeft func immediately 29 | // to show how many seconds left instantly on the page 30 | findTimeLeft(); 31 | 32 | // by default, setInterval is gonna run forever until we stop it 33 | const timerId = setInterval(findTimeLeft, 1000); 34 | 35 | // return clean-up func which stops the interval 36 | return () => { 37 | clearInterval(timerId); 38 | }; 39 | }, []); 40 | 41 | if (timeLeft < 0) { 42 | return
    Order Expired
    ; 43 | } 44 | 45 | return ( 46 |
    47 |

    Time left to pay: {timeLeft} seconds

    48 | 49 | doRequest({ token: token.id })} 51 | stripeKey={STRIPE_PUBLIC_KEY} 52 | amount={order.ticket.price * 100} 53 | email={currentUser.email} 54 | // closed={() => Router.push("/orders")} 55 | /> 56 | {errors} 57 |
    58 | ); 59 | }; 60 | 61 | OrderShow.getInitialProps = async (context, client) => { 62 | const { orderId } = context.query; 63 | const { data } = await client.get(`/api/orders/${orderId}`); 64 | 65 | return { order: data }; 66 | }; 67 | 68 | export default OrderShow; 69 | -------------------------------------------------------------------------------- /client/pages/orders/index.js: -------------------------------------------------------------------------------- 1 | const OrderIndex = ({ orders }) => { 2 | return ( 3 |
      4 | {orders.map((order) => { 5 | return ( 6 |
    • 7 | {order.ticket.title} - {order.status} 8 |
    • 9 | ); 10 | })} 11 |
    12 | ); 13 | }; 14 | 15 | OrderIndex.getInitialProps = async (context, client) => { 16 | const { data } = await client.get("/api/orders"); 17 | 18 | return { orders: data }; 19 | }; 20 | 21 | export default OrderIndex; 22 | -------------------------------------------------------------------------------- /client/pages/tickets/[ticketId].js: -------------------------------------------------------------------------------- 1 | import useRequest from "../../hooks/use-request"; 2 | import Router from "next/router"; 3 | 4 | const TicketShow = ({ ticket }) => { 5 | const { doRequest, errors } = useRequest({ 6 | url: "/api/orders", 7 | method: "post", 8 | body: { 9 | ticketId: ticket.id, 10 | }, 11 | onSuccess: (order) => 12 | Router.push("/orders/[orderId]", `/orders/${order.id}`), 13 | }); 14 | 15 | return ( 16 |
    17 |

    {ticket.title}

    18 |

    {ticket.price}

    19 | {errors} 20 | 23 |
    24 | ); 25 | }; 26 | 27 | TicketShow.getInitialProps = async (context, client, currentUser) => { 28 | const { ticketId } = context.query; // url param 29 | const { data } = await client.get(`/api/tickets/${ticketId}`); 30 | 31 | return { ticket: data }; 32 | }; 33 | 34 | export default TicketShow; 35 | -------------------------------------------------------------------------------- /client/pages/tickets/new.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import useRequest from "../../hooks/use-request"; 3 | import Router from "next/router"; 4 | 5 | const NewTicket = () => { 6 | const [price, setPrice] = useState(""); 7 | const [title, setTitle] = useState(""); 8 | const { doRequest, errors } = useRequest({ 9 | url: "/api/tickets", 10 | method: "post", 11 | body: { 12 | title, 13 | price, 14 | }, 15 | onSuccess: () => Router.push("/"), 16 | }); 17 | 18 | const onBlur = () => { 19 | const value = parseFloat(price); 20 | if (isNaN(value)) { 21 | return; 22 | } 23 | setPrice(value.toFixed(2)); // returns a string representing a number in fixed-point notation. 24 | }; 25 | 26 | const onSubmit = (event) => { 27 | event.preventDefault(); 28 | 29 | // make the request to POST /api/tickets 30 | doRequest(); 31 | }; 32 | 33 | return ( 34 |
    35 |

    Create a Ticket

    36 |
    37 |
    38 | 39 | setTitle(e.target.value)} 42 | className="form-control" 43 | /> 44 |
    45 |
    46 | 47 | setPrice(e.target.value)} 51 | className="form-control" 52 | /> 53 |
    54 | {errors} 55 | 56 |
    57 |
    58 | ); 59 | }; 60 | 61 | export default NewTicket; 62 | -------------------------------------------------------------------------------- /expiration/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /expiration/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # only install prod dependencies 6 | RUN npm install --only=prod 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /expiration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expiration", 3 | "version": "1.0.0", 4 | "description": "Expiration Service", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev src/index.ts --poll", 8 | "test": "jest --watchAll --no-cache" 9 | }, 10 | "jest": { 11 | "preset": "ts-jest", 12 | "testEnvironment": "node", 13 | "setupFilesAfterEnv": [ 14 | "./src/test/setup.ts" 15 | ] 16 | }, 17 | "keywords": [], 18 | "author": "Weilyu Wang", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@types/bull": "^3.14.0", 22 | "@wwticketing/common": "^1.0.12", 23 | "bull": "^3.14.0", 24 | "node-nats-streaming": "^0.3.2", 25 | "ts-node-dev": "^1.0.0-pre.44", 26 | "typescript": "^3.9.5" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^26.0.0", 30 | "jest": "^26.0.1", 31 | "ts-jest": "^26.1.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /expiration/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | // mock NATS client (Stan) 3 | client: { 4 | publish: jest 5 | .fn() 6 | .mockImplementation( 7 | (subject: string, data: string, callback: () => void) => { 8 | callback(); 9 | } 10 | ), 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /expiration/src/events/listeners/order-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCreatedEvent, Subjects } from '@wwticketing/common' 2 | import { Message } from 'node-nats-streaming'; 3 | import { queueGroupName } from './queue-group-name' 4 | import { expirationQueue } from '../../queues/expiration-queue' 5 | 6 | export class OrderCreatedListener extends Listener { 7 | 8 | readonly subject: Subjects.OrderCreated = Subjects.OrderCreated; 9 | readonly queueGroupName: string = queueGroupName; 10 | 11 | async onMessage(data: OrderCreatedEvent['data'], msg: Message) { 12 | 13 | const delay = new Date(data.expiresAt).getTime() - new Date().getTime() // get time difference in milliseconds 14 | console.log('Waiting this many miliseconds to process the job:', delay) 15 | 16 | await expirationQueue.add({ 17 | orderId: data.id 18 | }, { 19 | delay: delay, // unit: millisecond 20 | }) 21 | 22 | msg.ack() 23 | } 24 | } -------------------------------------------------------------------------------- /expiration/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGroupName = 'expiration-service' -------------------------------------------------------------------------------- /expiration/src/events/publishers/expiration-complete-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Subjects, Publisher, ExpirationCompleteEvent } from '@wwticketing/common' 2 | 3 | export class ExpirationCompletePublisher extends Publisher { 4 | readonly subject = Subjects.ExpirationComplete; 5 | } -------------------------------------------------------------------------------- /expiration/src/index.ts: -------------------------------------------------------------------------------- 1 | import { natsWrapper } from "./nats-wrapper"; 2 | import { OrderCreatedListener } from './events/listeners/order-created-listener' 3 | 4 | const start = async () => { 5 | 6 | if (!process.env.NATS_URL) { 7 | throw new Error("NATS_URL must be defined"); 8 | } 9 | if (!process.env.NATS_CLUSTER_ID) { 10 | throw new Error("NATS_CLUSTER_ID must be defined"); 11 | } 12 | if (!process.env.NATS_CLIENT_ID) { 13 | throw new Error("NATS_CLIENT_ID must be defined"); 14 | } 15 | 16 | try { 17 | await natsWrapper.connect( 18 | process.env.NATS_CLUSTER_ID, 19 | process.env.NATS_CLIENT_ID, 20 | process.env.NATS_URL 21 | ); 22 | 23 | // Gracefully shut down NATS connection 24 | natsWrapper.client.on("close", () => { 25 | console.log("NATS connection closed!"); 26 | process.exit(); 27 | }); 28 | process.on("SIGINT", () => natsWrapper.client.close()); 29 | process.on("SIGTERM", () => natsWrapper.client.close()); 30 | 31 | // start listen to OrderCreatedEvent 32 | new OrderCreatedListener(natsWrapper.client).listen() 33 | 34 | } catch (err) { 35 | console.log(err); 36 | } 37 | 38 | }; 39 | 40 | start(); 41 | -------------------------------------------------------------------------------- /expiration/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from "node-nats-streaming"; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | get client() { 7 | if (!this._client) { 8 | throw new Error("Cannot access NATS client before connecting"); 9 | } 10 | 11 | return this._client; 12 | } 13 | 14 | connect(clusterId: string, clientId: string, url: string) { 15 | this._client = nats.connect(clusterId, clientId, { url }); 16 | 17 | return new Promise((resolve, reject) => { 18 | this.client.on("connect", () => { 19 | console.log("Connected to NATS"); 20 | resolve(); 21 | }); 22 | this.client.on("error", (err) => { 23 | reject(err); 24 | }); 25 | }); 26 | } 27 | } 28 | 29 | export const natsWrapper = new NatsWrapper(); 30 | -------------------------------------------------------------------------------- /expiration/src/queues/expiration-queue.ts: -------------------------------------------------------------------------------- 1 | import Queue from 'bull' 2 | import { ExpirationCompletePublisher } from '../events/publishers/expiration-complete-publisher' 3 | import { natsWrapper } from '../nats-wrapper' 4 | 5 | 6 | interface Payload { 7 | orderId: string; 8 | } 9 | 10 | const expirationQueue = new Queue('order:expiration', { 11 | redis: { 12 | host: process.env.REDIS_HOST 13 | } 14 | }) 15 | 16 | expirationQueue.process(async (job: Queue.Job) => { 17 | // publish an ExpirationComplete Event 18 | new ExpirationCompletePublisher(natsWrapper.client).publish({ 19 | orderId: job.data.orderId 20 | }) 21 | }) 22 | 23 | export { expirationQueue } -------------------------------------------------------------------------------- /expiration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /infra/k8s-dev/client-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: client-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: client 10 | template: 11 | metadata: 12 | labels: 13 | app: client 14 | spec: 15 | containers: 16 | - name: client 17 | image: weilyuwang/ticketing-client 18 | env: 19 | - name: BASE_URL 20 | value: "http://ingress-nginx-controller.ingress-nginx.svc.cluster.local" 21 | - name: ENVIRONMENT 22 | value: Dev 23 | --- 24 | apiVersion: v1 25 | kind: Service 26 | metadata: 27 | name: client-srv 28 | spec: 29 | selector: 30 | app: client 31 | ports: 32 | - name: client 33 | protocol: TCP 34 | port: 3000 35 | targetPort: 3000 36 | -------------------------------------------------------------------------------- /infra/k8s-dev/ingress-srv.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: ingress-service 5 | annotations: 6 | kubernetes.io/ingress.class: nginx 7 | nginx.ingress.kubernetes.io/use-regex: "true" 8 | spec: 9 | rules: 10 | - host: ticketing.dev 11 | http: 12 | paths: 13 | - path: /api/payments/?(.*) 14 | backend: 15 | serviceName: payments-srv 16 | servicePort: 3000 17 | - path: /api/users/?(.*) 18 | backend: 19 | serviceName: auth-srv 20 | servicePort: 3000 21 | - path: /api/tickets/?(.*) 22 | backend: 23 | serviceName: tickets-srv 24 | servicePort: 3000 25 | - path: /api/orders/?(.*) 26 | backend: 27 | serviceName: orders-srv 28 | servicePort: 3000 29 | - path: /?(.*) 30 | backend: 31 | serviceName: client-srv 32 | servicePort: 3000 33 | -------------------------------------------------------------------------------- /infra/k8s-prod/client-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: client-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: client 10 | template: 11 | metadata: 12 | labels: 13 | app: client 14 | spec: 15 | containers: 16 | - name: client 17 | image: weilyuwang/ticketing-client 18 | env: 19 | - name: BASE_URL 20 | value: "http://www.weilyuticketing.shop" 21 | - name: ENVIRONMENT 22 | value: Prod 23 | --- 24 | apiVersion: v1 25 | kind: Service 26 | metadata: 27 | name: client-srv 28 | spec: 29 | selector: 30 | app: client 31 | ports: 32 | - name: client 33 | protocol: TCP 34 | port: 3000 35 | targetPort: 3000 36 | -------------------------------------------------------------------------------- /infra/k8s-prod/ingress-srv.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: ingress-service 5 | annotations: 6 | kubernetes.io/ingress.class: nginx 7 | nginx.ingress.kubernetes.io/use-regex: "true" 8 | spec: 9 | rules: 10 | - host: www.weilyuticketing.shop 11 | http: 12 | paths: 13 | - path: /api/payments/?(.*) 14 | backend: 15 | serviceName: payments-srv 16 | servicePort: 3000 17 | - path: /api/users/?(.*) 18 | backend: 19 | serviceName: auth-srv 20 | servicePort: 3000 21 | - path: /api/tickets/?(.*) 22 | backend: 23 | serviceName: tickets-srv 24 | servicePort: 3000 25 | - path: /api/orders/?(.*) 26 | backend: 27 | serviceName: orders-srv 28 | servicePort: 3000 29 | - path: /?(.*) 30 | backend: 31 | serviceName: client-srv 32 | servicePort: 3000 33 | 34 | --- 35 | apiVersion: v1 36 | kind: Service 37 | metadata: 38 | annotations: 39 | service.beta.kubernetes.io/do-loadbalancer-enable-proxy-protocol: "true" 40 | service.beta.kubernetes.io/do-loadbalancer-hostname: "www.weilyuticketing.shop" 41 | labels: 42 | helm.sh/chart: ingress-nginx-2.0.3 43 | app.kubernetes.io/name: ingress-nginx 44 | app.kubernetes.io/instance: ingress-nginx 45 | app.kubernetes.io/version: 0.32.0 46 | app.kubernetes.io/managed-by: Helm 47 | app.kubernetes.io/component: controller 48 | name: ingress-nginx-controller 49 | namespace: ingress-nginx 50 | spec: 51 | type: LoadBalancer 52 | externalTrafficPolicy: Local 53 | ports: 54 | - name: http 55 | port: 80 56 | protocol: TCP 57 | targetPort: http 58 | - name: https 59 | port: 443 60 | protocol: TCP 61 | targetPort: https 62 | selector: 63 | app.kubernetes.io/name: ingress-nginx 64 | app.kubernetes.io/instance: ingress-nginx 65 | app.kubernetes.io/component: controller 66 | -------------------------------------------------------------------------------- /infra/k8s/auth-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: auth-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: auth 10 | template: 11 | metadata: 12 | labels: 13 | app: auth 14 | spec: 15 | containers: 16 | - name: auth 17 | image: weilyuwang/ticketing-auth 18 | env: 19 | - name: MONGO_URI 20 | value: "mongodb://auth-mongo-srv:27017/auth" 21 | - name: JWT_KEY 22 | valueFrom: 23 | secretKeyRef: 24 | name: jwt-secret 25 | key: JWT_KEY 26 | --- 27 | apiVersion: v1 28 | kind: Service 29 | metadata: 30 | name: auth-srv 31 | spec: 32 | selector: 33 | app: auth 34 | ports: 35 | - name: auth 36 | protocol: TCP 37 | port: 3000 38 | targetPort: 3000 39 | -------------------------------------------------------------------------------- /infra/k8s/auth-mongo-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: auth-mongo-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: auth-mongo 10 | template: 11 | metadata: 12 | labels: 13 | app: auth-mongo 14 | spec: 15 | containers: 16 | - name: auth-mongo 17 | image: mongo ## pull mongo image directly from Docker Hub 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: auth-mongo-srv # domain name of auth-mongo cluster ip 23 | spec: 24 | selector: 25 | app: auth-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 # standard port for MongoDB 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /infra/k8s/expiration-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: expiration-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: expiration 10 | template: 11 | metadata: 12 | labels: 13 | app: expiration 14 | spec: 15 | containers: 16 | - name: expiration 17 | image: weilyuwang/ticketing-expiration 18 | env: 19 | - name: NATS_CLIENT_ID 20 | valueFrom: 21 | fieldRef: 22 | fieldPath: metadata.name 23 | - name: NATS_URL 24 | value: "http://nats-srv:4222" 25 | - name: NATS_CLUSTER_ID 26 | value: ticketing 27 | - name: REDIS_HOST 28 | value: expiration-redis-srv 29 | -------------------------------------------------------------------------------- /infra/k8s/expiration-redis-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: expiration-redis-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: expiration-redis 10 | template: 11 | metadata: 12 | labels: 13 | app: expiration-redis 14 | spec: 15 | containers: 16 | - name: expiration-redis 17 | image: redis ## pull redis image directly from Docker Hub 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: expiration-redis-srv # domain name of expiration-redis cluster ip 23 | spec: 24 | selector: 25 | app: expiration-redis 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 6379 # standard port for redis 30 | targetPort: 6379 31 | -------------------------------------------------------------------------------- /infra/k8s/nats-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: nats-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: nats 10 | template: 11 | metadata: 12 | labels: 13 | app: nats 14 | spec: 15 | containers: 16 | - name: nats 17 | image: nats-streaming:0.17.0 18 | args: 19 | [ 20 | "-p", 21 | "4222", 22 | "-m", 23 | "8222", 24 | "-hbi", 25 | "5s", 26 | "-hbt", 27 | "5s", 28 | "-hbf", 29 | "2", 30 | "-SD", 31 | "-cid", 32 | "ticketing", 33 | ] 34 | --- 35 | apiVersion: v1 36 | kind: Service 37 | metadata: 38 | name: nats-srv 39 | spec: 40 | selector: 41 | app: nats 42 | ports: 43 | - name: client 44 | protocol: TCP 45 | port: 4222 46 | targetPort: 4222 47 | - name: monitoring 48 | protocol: TCP 49 | port: 8222 50 | targetPort: 8222 51 | -------------------------------------------------------------------------------- /infra/k8s/orders-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: orders-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: orders 10 | template: 11 | metadata: 12 | labels: 13 | app: orders 14 | spec: 15 | containers: 16 | - name: orders 17 | image: weilyuwang/ticketing-orders 18 | env: 19 | - name: NATS_CLIENT_ID 20 | valueFrom: 21 | fieldRef: 22 | fieldPath: metadata.name 23 | - name: NATS_URL 24 | value: "http://nats-srv:4222" 25 | - name: NATS_CLUSTER_ID 26 | value: ticketing 27 | - name: MONGO_URI 28 | value: "mongodb://orders-mongo-srv:27017/orders" 29 | - name: JWT_KEY 30 | valueFrom: 31 | secretKeyRef: 32 | name: jwt-secret 33 | key: JWT_KEY 34 | --- 35 | # ClusterIP Service 36 | apiVersion: v1 37 | kind: Service 38 | metadata: 39 | name: orders-srv 40 | spec: 41 | selector: 42 | app: orders 43 | ports: 44 | - name: orders 45 | protocol: TCP 46 | port: 3000 47 | targetPort: 3000 48 | -------------------------------------------------------------------------------- /infra/k8s/orders-mongo-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: orders-mongo-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: orders-mongo 10 | template: 11 | metadata: 12 | labels: 13 | app: orders-mongo 14 | spec: 15 | containers: 16 | - name: orders-mongo 17 | image: mongo ## pull mongo image directly from Docker Hub 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: orders-mongo-srv # domain name of orders-mongo cluster ip 23 | spec: 24 | selector: 25 | app: orders-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 # standard port for MongoDB 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /infra/k8s/payments-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: payments-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: payments 10 | template: 11 | metadata: 12 | labels: 13 | app: payments 14 | spec: 15 | containers: 16 | - name: payments 17 | image: weilyuwang/ticketing-payments 18 | env: 19 | - name: NATS_CLIENT_ID 20 | valueFrom: 21 | fieldRef: 22 | fieldPath: metadata.name 23 | - name: NATS_URL 24 | value: "http://nats-srv:4222" 25 | - name: NATS_CLUSTER_ID 26 | value: ticketing 27 | - name: MONGO_URI 28 | value: "mongodb://payments-mongo-srv:27017/payments" 29 | - name: JWT_KEY 30 | valueFrom: 31 | secretKeyRef: 32 | name: jwt-secret 33 | key: JWT_KEY 34 | - name: STRIPE_KEY 35 | valueFrom: 36 | secretKeyRef: 37 | name: stripe-secret 38 | key: STRIPE_KEY 39 | --- 40 | # ClusterIP Service 41 | apiVersion: v1 42 | kind: Service 43 | metadata: 44 | name: payments-srv 45 | spec: 46 | selector: 47 | app: payments 48 | ports: 49 | - name: payments 50 | protocol: TCP 51 | port: 3000 52 | targetPort: 3000 53 | -------------------------------------------------------------------------------- /infra/k8s/payments-mongo-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: payments-mongo-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: payments-mongo 10 | template: 11 | metadata: 12 | labels: 13 | app: payments-mongo 14 | spec: 15 | containers: 16 | - name: payments-mongo 17 | image: mongo ## pull mongo image directly from Docker Hub 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: payments-mongo-srv # domain name of payments-mongo cluster ip 23 | spec: 24 | selector: 25 | app: payments-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 # standard port for MongoDB 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /infra/k8s/tickets-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tickets-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: tickets 10 | template: 11 | metadata: 12 | labels: 13 | app: tickets 14 | spec: 15 | containers: 16 | - name: tickets 17 | image: weilyuwang/ticketing-tickets 18 | env: 19 | - name: NATS_CLIENT_ID 20 | valueFrom: 21 | fieldRef: 22 | fieldPath: metadata.name 23 | - name: NATS_URL 24 | value: "http://nats-srv:4222" 25 | - name: NATS_CLUSTER_ID 26 | value: ticketing 27 | - name: MONGO_URI 28 | value: "mongodb://tickets-mongo-srv:27017/tickets" 29 | - name: JWT_KEY 30 | valueFrom: 31 | secretKeyRef: 32 | name: jwt-secret 33 | key: JWT_KEY 34 | --- 35 | # ClusterIP Service 36 | apiVersion: v1 37 | kind: Service 38 | metadata: 39 | name: tickets-srv 40 | spec: 41 | selector: 42 | app: tickets 43 | ports: 44 | - name: tickets 45 | protocol: TCP 46 | port: 3000 47 | targetPort: 3000 48 | -------------------------------------------------------------------------------- /infra/k8s/tickets-mongo-depl.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tickets-mongo-depl 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: tickets-mongo 10 | template: 11 | metadata: 12 | labels: 13 | app: tickets-mongo 14 | spec: 15 | containers: 16 | - name: tickets-mongo 17 | image: mongo ## pull mongo image directly from Docker Hub 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: tickets-mongo-srv # domain name of tickets-mongo cluster ip 23 | spec: 24 | selector: 25 | app: tickets-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 # standard port for MongoDB 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /orders/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /orders/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # only install prod dependencies 6 | RUN npm install --only=prod 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /orders/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orders", 3 | "version": "1.0.0", 4 | "description": "Orders Service", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev src/index.ts --poll", 8 | "test": "jest --watchAll --no-cache", 9 | "test:ci": "jest" 10 | }, 11 | "jest": { 12 | "preset": "ts-jest", 13 | "testEnvironment": "node", 14 | "setupFilesAfterEnv": [ 15 | "./src/test/setup.ts" 16 | ] 17 | }, 18 | "keywords": [], 19 | "author": "Weilyu Wang", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@types/cookie-session": "2.0.39", 23 | "@types/express": "^4.17.6", 24 | "@types/jsonwebtoken": "^8.5.0", 25 | "@types/mongoose": "^5.7.23", 26 | "@wwticketing/common": "^1.0.13", 27 | "cookie-session": "^1.4.0", 28 | "express": "^4.17.1", 29 | "express-async-errors": "^3.1.1", 30 | "express-validator": "^6.5.0", 31 | "jsonwebtoken": "^8.5.1", 32 | "mongoose": "^5.9.18", 33 | "mongoose-update-if-current": "^1.4.0", 34 | "node-nats-streaming": "^0.3.2", 35 | "ts-node-dev": "^1.0.0-pre.44", 36 | "typescript": "^3.9.5" 37 | }, 38 | "devDependencies": { 39 | "@types/jest": "^26.0.0", 40 | "@types/supertest": "^2.0.9", 41 | "jest": "^26.0.1", 42 | "mongodb-memory-server": "^6.6.1", 43 | "supertest": "^4.0.2", 44 | "ts-jest": "^26.1.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /orders/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | // mock NATS client (Stan) 3 | client: { 4 | publish: jest 5 | .fn() 6 | .mockImplementation( 7 | (subject: string, data: string, callback: () => void) => { 8 | callback(); 9 | } 10 | ), 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /orders/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import "express-async-errors"; 3 | import { json } from "body-parser"; 4 | import cookieSession from "cookie-session"; 5 | import { 6 | errorHandlerMiddleware, 7 | NotFoundError, 8 | currentUserMiddleware, 9 | } from "@wwticketing/common"; 10 | 11 | import { deleteOrderRouter } from "./routes/delete"; 12 | import { indexOrderRouter } from "./routes/index"; 13 | import { newOrderRouter } from "./routes/new"; 14 | import { showOrderRouter } from "./routes/show"; 15 | 16 | const app = express(); 17 | app.set("trust proxy", true); // trust ingress & nginx proxy 18 | app.use(json()); 19 | 20 | // use cookieSession to add session property to req object 21 | app.use( 22 | cookieSession({ 23 | signed: false, // disable encryption on the cookie - JWT is already secured 24 | // secure: process.env.NODE_ENV !== "test", // *** HTTPS connection only ***, but exception made for testing 25 | secure: false, 26 | }) 27 | ); 28 | 29 | // middleware to add currentUser to req 30 | app.use(currentUserMiddleware); 31 | 32 | // express routes 33 | app.use(deleteOrderRouter); 34 | app.use(indexOrderRouter); 35 | app.use(newOrderRouter); 36 | app.use(showOrderRouter); 37 | 38 | // use express-async-errors lib behind the scene to handle async errors 39 | app.all("*", async (req, res) => { 40 | throw new NotFoundError(); 41 | }); 42 | 43 | // middleware to handle all kinds of errors 44 | app.use(errorHandlerMiddleware); 45 | 46 | export { app }; 47 | -------------------------------------------------------------------------------- /orders/src/events/listeners/__test__/expiration-complete-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { natsWrapper } from '../../../nats-wrapper' 2 | import { ExpirationCompleteListener } from '../expiration-complete-listener' 3 | import { Order } from '../../../models/order' 4 | import { Ticket } from '../../../models/ticket' 5 | import mongoose from 'mongoose' 6 | import { OrderStatus, ExpirationCompleteEvent } from '@wwticketing/common' 7 | import { Message } from 'node-nats-streaming' 8 | 9 | const setup = async () => { 10 | const listener = new ExpirationCompleteListener(natsWrapper.client) 11 | 12 | const ticket = Ticket.build({ 13 | id: mongoose.Types.ObjectId().toHexString(), 14 | title: 'concert', 15 | price: 100 16 | }) 17 | await ticket.save() 18 | 19 | const order = Order.build({ 20 | userId: 'someuser', 21 | status: OrderStatus.Created, 22 | expiresAt: new Date(), 23 | ticket: ticket 24 | }) 25 | await order.save() 26 | 27 | const data: ExpirationCompleteEvent['data'] = { 28 | orderId: order.id, 29 | } 30 | 31 | // @ts-ignore 32 | const msg: Message = { 33 | ack: jest.fn() 34 | } 35 | 36 | return { listener, order, ticket, data, msg } 37 | 38 | } 39 | 40 | 41 | it('udpates the order status to cancelled', async () => { 42 | const { listener, order, data, msg } = await setup() 43 | await listener.onMessage(data, msg) 44 | 45 | const updatedOrder = await Order.findById(order.id) 46 | 47 | expect(updatedOrder!.status).toEqual(OrderStatus.Cancelled) 48 | 49 | }) 50 | 51 | 52 | it('emits an OrderCancelled event', async () => { 53 | const { listener, order, data, msg } = await setup() 54 | await listener.onMessage(data, msg) 55 | 56 | // assert that the publish() func gets called 57 | expect(natsWrapper.client.publish).toHaveBeenCalled() 58 | 59 | // Grab the event data that got published 60 | // tell typescript to calm down as this is just a mock func 61 | const eventData = JSON.parse( 62 | (natsWrapper.client.publish as jest.Mock).mock.calls[0][1] 63 | ) 64 | 65 | // assert that correct event data gets sent off 66 | expect(eventData.id).toEqual(order.id) 67 | 68 | }) 69 | 70 | 71 | it('acks the message', async () => { 72 | const { listener, data, msg } = await setup() 73 | await listener.onMessage(data, msg) 74 | 75 | expect(msg.ack).toHaveBeenCalled() 76 | 77 | }) -------------------------------------------------------------------------------- /orders/src/events/listeners/__test__/ticket-created-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { TicketCreatedListener } from '../ticket-created-listener' 2 | import { Ticket } from '../../../models/ticket' 3 | import { natsWrapper } from '../../../nats-wrapper' 4 | import { TicketCreatedEvent } from '@wwticketing/common' 5 | import mongoose from 'mongoose' 6 | import { Message } from 'node-nats-streaming' 7 | 8 | const setup = async () => { 9 | // create an instance of the listener 10 | const listener = new TicketCreatedListener(natsWrapper.client) 11 | 12 | // create a fake data event 13 | const data: TicketCreatedEvent['data'] = { 14 | version: 0, 15 | id: mongoose.Types.ObjectId().toHexString(), 16 | title: 'concert', 17 | price: 100, 18 | userId: mongoose.Types.ObjectId().toHexString() 19 | } 20 | 21 | // create a fake message object 22 | // @ts-ignore 23 | const msg: Message = { 24 | ack: jest.fn() 25 | } 26 | 27 | return { listener, data, msg } 28 | } 29 | 30 | it('creates and saves a ticket', async () => { 31 | const { listener, data, msg } = await setup() 32 | 33 | // call the onMessage function with the data object + message object 34 | await listener.onMessage(data, msg) 35 | 36 | // write assertons to make sure a ticket was created 37 | const ticket = await Ticket.findById(data.id) 38 | expect(ticket).toBeDefined() 39 | expect(ticket!.title).toEqual(data.title) 40 | expect(ticket!.price).toEqual(data.price) 41 | 42 | }) 43 | 44 | 45 | it('acks the message', async () => { 46 | const { listener, data, msg } = await setup() 47 | 48 | // call the onMessage function with the data object + message object 49 | await listener.onMessage(data, msg) 50 | 51 | // write assertions to make sure ack function is called 52 | expect(msg.ack).toHaveBeenCalled() 53 | }) 54 | 55 | -------------------------------------------------------------------------------- /orders/src/events/listeners/__test__/ticket-updated-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { TicketCreatedListener } from '../ticket-created-listener' 2 | import { Ticket } from '../../../models/ticket' 3 | import { natsWrapper } from '../../../nats-wrapper' 4 | import { TicketUpdatedEvent } from '@wwticketing/common' 5 | import mongoose from 'mongoose' 6 | import { Message } from 'node-nats-streaming' 7 | import { TicketUpdatedListener } from '../ticket-updated-listener' 8 | 9 | 10 | const setup = async () => { 11 | // Create a listener 12 | const listener = new TicketUpdatedListener(natsWrapper.client) 13 | 14 | // Create and save a ticket 15 | const ticket = Ticket.build({ 16 | id: mongoose.Types.ObjectId().toHexString(), 17 | title: 'concert', 18 | price: 100 19 | }) 20 | await ticket.save() 21 | 22 | // Create a fake TicketUpdatedEvent data 23 | const data: TicketUpdatedEvent['data'] = { 24 | version: ticket.version + 1, 25 | id: ticket.id, 26 | title: 'new concert', 27 | price: 999, 28 | userId: mongoose.Types.ObjectId().toHexString() 29 | } 30 | 31 | // Create a fake msg 32 | // @ts-ignore 33 | const msg: Message = { 34 | ack: jest.fn() 35 | } 36 | 37 | // return all of this stuff 38 | return { listener, data, ticket, msg } 39 | } 40 | 41 | 42 | it('finds, updates, and saves a ticket', async () => { 43 | const { listener, data, ticket, msg } = await setup() 44 | 45 | await listener.onMessage(data, msg) 46 | 47 | const updatedTicket = await Ticket.findById(ticket.id) 48 | 49 | expect(updatedTicket!.title).toEqual(data.title) 50 | expect(updatedTicket!.price).toEqual(data.price) 51 | expect(updatedTicket!.version).toEqual(data.version) 52 | 53 | }) 54 | 55 | 56 | it('acks the message', async () => { 57 | const { listener, data, msg } = await setup() 58 | await listener.onMessage(data, msg) 59 | 60 | expect(msg.ack).toHaveBeenCalled() 61 | }) 62 | 63 | 64 | it('does not call ack if the event has a skipped version number', async () => { 65 | const { listener, data, msg } = await setup() 66 | data.version = data.version + 1 67 | 68 | try { 69 | await listener.onMessage(data, msg) 70 | } catch (err) { } 71 | 72 | expect(msg.ack).not.toHaveBeenCalled() 73 | 74 | }) -------------------------------------------------------------------------------- /orders/src/events/listeners/expiration-complete-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, ExpirationCompleteEvent, Subjects, OrderStatus } from '@wwticketing/common' 2 | import { Message } from 'node-nats-streaming'; 3 | import { queueGroupName } from './queue-group-name' 4 | import { Order } from '../../models/order' 5 | import { OrderCancelledPublisher } from '../publishers/order-cancelled-publisher' 6 | 7 | export class ExpirationCompleteListener extends Listener { 8 | 9 | readonly subject = Subjects.ExpirationComplete; 10 | readonly queueGroupName: string = queueGroupName; 11 | 12 | async onMessage(data: ExpirationCompleteEvent['data'], msg: Message) { 13 | // find the order and also populate the embedded ticket object 14 | const order = await Order.findById(data.orderId).populate('ticket') 15 | 16 | if (!order) { 17 | throw new Error('Order not found') 18 | } 19 | 20 | if (order.status === OrderStatus.Complete) { 21 | // if the order is already been paid for and completed 22 | // - don't cancel it, instead do nothing and just return 23 | return msg.ack() 24 | } 25 | 26 | // update order status and save 27 | order.set({ 28 | status: OrderStatus.Cancelled, 29 | }) 30 | await order.save() 31 | 32 | // publish OrderCancelled Event 33 | await new OrderCancelledPublisher(this.client).publish({ 34 | id: order.id, 35 | version: order.version, 36 | ticket: { 37 | id: order.ticket.id, 38 | } 39 | }) 40 | 41 | msg.ack() 42 | 43 | } 44 | } -------------------------------------------------------------------------------- /orders/src/events/listeners/payment-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Subjects, 3 | Listener, 4 | PaymentCreatedEvent, 5 | OrderStatus, 6 | } from "@wwticketing/common"; 7 | import { Message } from "node-nats-streaming"; 8 | import { queueGroupName } from "./queue-group-name"; 9 | import { Order } from "../../models/order"; 10 | 11 | export class PaymentCreatedListener extends Listener { 12 | readonly subject = Subjects.PaymentCreated; 13 | readonly queueGroupName: string = queueGroupName; 14 | 15 | async onMessage(data: PaymentCreatedEvent["data"], msg: Message) { 16 | const order = await Order.findById(data.orderId); 17 | 18 | if (!order) { 19 | throw new Error("Order not found"); 20 | } 21 | 22 | order.set({ 23 | status: OrderStatus.Complete, 24 | }); 25 | await order.save(); 26 | 27 | msg.ack(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /orders/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGroupName = "orders-service"; 2 | -------------------------------------------------------------------------------- /orders/src/events/listeners/ticket-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "node-nats-streaming"; 2 | import { Subjects, Listener, TicketCreatedEvent } from "@wwticketing/common"; 3 | import { Ticket } from "../../models/ticket"; 4 | import { queueGroupName } from "./queue-group-name"; 5 | 6 | export class TicketCreatedListener extends Listener { 7 | readonly subject: Subjects.TicketCreated = Subjects.TicketCreated; 8 | readonly queueGroupName: string = queueGroupName; 9 | 10 | async onMessage(data: TicketCreatedEvent['data'], msg: Message) { 11 | const { id, title, price } = data; 12 | const ticket = Ticket.build({ 13 | id, 14 | title, 15 | price, 16 | }); 17 | await ticket.save(); 18 | 19 | // acknowledge that we have received the event/message 20 | msg.ack(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /orders/src/events/listeners/ticket-updated-listener.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "node-nats-streaming"; 2 | import { Subjects, Listener, TicketUpdatedEvent } from "@wwticketing/common"; 3 | import { Ticket } from "../../models/ticket"; 4 | import { queueGroupName } from "./queue-group-name"; 5 | 6 | export class TicketUpdatedListener extends Listener { 7 | readonly subject: Subjects.TicketUpdated = Subjects.TicketUpdated; 8 | readonly queueGroupName: string = queueGroupName; 9 | 10 | async onMessage(data: TicketUpdatedEvent['data'], msg: Message) { 11 | 12 | const ticket = await Ticket.findByEvent(data) 13 | if (!ticket) { 14 | throw new Error("TIcket not found"); 15 | } 16 | 17 | ticket.set({ title: data.title, price: data.price }); 18 | await ticket.save(); 19 | 20 | msg.ack(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /orders/src/events/publishers/order-cancelled-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Publisher, OrderCancelledEvent, Subjects } from "@wwticketing/common"; 2 | 3 | export class OrderCancelledPublisher extends Publisher { 4 | readonly subject = Subjects.OrderCancelled; 5 | } 6 | -------------------------------------------------------------------------------- /orders/src/events/publishers/order-created-pubisher.ts: -------------------------------------------------------------------------------- 1 | import { Publisher, OrderCreatedEvent, Subjects } from "@wwticketing/common"; 2 | 3 | export class OrderCreatedPublisher extends Publisher { 4 | readonly subject = Subjects.OrderCreated; 5 | } 6 | -------------------------------------------------------------------------------- /orders/src/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { app } from "./app"; 3 | import { natsWrapper } from "./nats-wrapper"; 4 | import { TicketCreatedListener } from "./events/listeners/ticket-created-listener"; 5 | import { TicketUpdatedListener } from "./events/listeners/ticket-updated-listener"; 6 | import { ExpirationCompleteListener } from "./events/listeners/expiration-complete-listener"; 7 | import { PaymentCreatedListener } from "./events/listeners/payment-created-listener"; 8 | 9 | const start = async () => { 10 | console.log("Starting..."); 11 | 12 | // First check if JWT_KEY is defined 13 | if (!process.env.JWT_KEY) { 14 | throw new Error("JWT_KEY must be defined"); 15 | } 16 | if (!process.env.MONGO_URI) { 17 | throw new Error("MONGO_URI must be defined"); 18 | } 19 | if (!process.env.NATS_URL) { 20 | throw new Error("NATS_URL must be defined"); 21 | } 22 | if (!process.env.NATS_CLUSTER_ID) { 23 | throw new Error("NATS_CLUSTER_ID must be defined"); 24 | } 25 | if (!process.env.NATS_CLIENT_ID) { 26 | throw new Error("NATS_CLIENT_ID must be defined"); 27 | } 28 | 29 | try { 30 | await natsWrapper.connect( 31 | process.env.NATS_CLUSTER_ID, 32 | process.env.NATS_CLIENT_ID, 33 | process.env.NATS_URL 34 | ); 35 | 36 | // Gracefully shut down NATS connection 37 | natsWrapper.client.on("close", () => { 38 | console.log("NATS connection closed!"); 39 | process.exit(); 40 | }); 41 | process.on("SIGINT", () => natsWrapper.client.close()); 42 | process.on("SIGTERM", () => natsWrapper.client.close()); 43 | 44 | new TicketCreatedListener(natsWrapper.client).listen(); 45 | new TicketUpdatedListener(natsWrapper.client).listen(); 46 | new ExpirationCompleteListener(natsWrapper.client).listen(); 47 | new PaymentCreatedListener(natsWrapper.client).listen(); 48 | 49 | await mongoose.connect(process.env.MONGO_URI, { 50 | useNewUrlParser: true, 51 | useUnifiedTopology: true, 52 | useCreateIndex: true, 53 | }); 54 | console.log("Connected to MongoDB"); 55 | } catch (err) { 56 | console.log(err); 57 | } 58 | 59 | app.listen(3000, () => { 60 | console.log("Orders Service listening on port 3000"); 61 | }); 62 | }; 63 | 64 | start(); 65 | -------------------------------------------------------------------------------- /orders/src/models/order.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { OrderStatus } from "@wwticketing/common"; 3 | import { TicketDoc } from "./ticket"; 4 | import { updateIfCurrentPlugin } from 'mongoose-update-if-current' 5 | 6 | 7 | export { OrderStatus }; 8 | 9 | interface OrderAttrs { 10 | userId: string; 11 | status: OrderStatus; 12 | expiresAt: Date; 13 | ticket: TicketDoc; // reference to ticket 14 | } 15 | 16 | interface OrderDoc extends mongoose.Document { 17 | userId: string; 18 | status: OrderStatus; 19 | expiresAt: Date; 20 | ticket: TicketDoc; 21 | version: number; 22 | } 23 | 24 | interface OrderModel extends mongoose.Model { 25 | build(attrs: OrderAttrs): OrderDoc; 26 | } 27 | 28 | const orderSchema = new mongoose.Schema( 29 | { 30 | userId: { 31 | type: String, 32 | required: true, 33 | }, 34 | status: { 35 | type: String, 36 | required: true, 37 | enum: Object.values(OrderStatus), 38 | default: OrderStatus.Created, 39 | }, 40 | expiresAt: { 41 | type: mongoose.Schema.Types.Date, 42 | }, 43 | ticket: { 44 | type: mongoose.Schema.Types.ObjectId, 45 | ref: "Ticket", 46 | }, 47 | }, 48 | { 49 | toJSON: { 50 | transform(doc, ret) { 51 | ret.id = ret._id; 52 | delete ret._id; 53 | }, 54 | }, 55 | } 56 | ); 57 | 58 | orderSchema.set('versionKey', 'version') 59 | orderSchema.plugin(updateIfCurrentPlugin) 60 | 61 | orderSchema.statics.build = (attrs: OrderAttrs) => { 62 | return new Order(attrs); 63 | }; 64 | 65 | const Order = mongoose.model("Order", orderSchema); 66 | 67 | export { Order }; 68 | -------------------------------------------------------------------------------- /orders/src/models/ticket.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { Order, OrderStatus } from "./order"; 3 | import { updateIfCurrentPlugin } from "mongoose-update-if-current"; 4 | 5 | interface TicketAttrs { 6 | id: string; 7 | title: string; 8 | price: number; 9 | } 10 | 11 | export interface TicketDoc extends mongoose.Document { 12 | title: string; 13 | price: number; 14 | version: number; 15 | isReserved(): Promise; 16 | } 17 | // want to call ticket.isReserved() 18 | 19 | interface TicketModel extends mongoose.Model { 20 | build(attrs: TicketAttrs): TicketDoc; 21 | findByEvent(event: { id: string, version: number }): Promise; 22 | } 23 | // want to call Ticket.build({}) 24 | // want to call Ticket.findByEvent() 25 | 26 | const ticketSchema = new mongoose.Schema( 27 | { 28 | title: { 29 | type: String, 30 | required: true, 31 | }, 32 | price: { 33 | type: Number, 34 | required: true, 35 | min: 0, 36 | }, 37 | }, 38 | { 39 | toJSON: { 40 | transform(doc, ret) { 41 | ret.id = ret._id; 42 | delete ret._id; 43 | }, 44 | }, 45 | } 46 | ); 47 | 48 | ticketSchema.set("versionKey", "version"); 49 | ticketSchema.plugin(updateIfCurrentPlugin); 50 | 51 | ticketSchema.statics.findByEvent = (event: { id: string, version: number }) => { 52 | return Ticket.findOne({ 53 | _id: event.id, 54 | version: event.version - 1, 55 | }); 56 | } 57 | 58 | // Add a static method to Ticket Model 59 | ticketSchema.statics.build = (attrs: TicketAttrs) => { 60 | return new Ticket({ 61 | _id: attrs.id, 62 | title: attrs.title, 63 | price: attrs.price, 64 | }); 65 | }; 66 | 67 | // Add a method directly to a document instance 68 | // in order to use this - need to define this as a standard function (not arrow func syntax) 69 | // Logics: 70 | // Run query to look at all orders. Find an order where the ticket 71 | // is the ticket we just found *and* the order status is not cancelled 72 | // If we find an order from that means the ticket *is* reserved 73 | ticketSchema.methods.isReserved = async function () { 74 | // this === the ticket document that we just called 'isReserved' on 75 | const existingOrder = await Order.findOne({ 76 | ticket: this, 77 | status: { 78 | $in: [ 79 | OrderStatus.Created, 80 | OrderStatus.AwaitingPayment, 81 | OrderStatus.Complete, 82 | ], 83 | }, 84 | }); 85 | 86 | // if (existingOrder === null) : isReserved = false 87 | // else if exitingOrder is found : isReserved = true 88 | return !!existingOrder; 89 | }; 90 | 91 | const Ticket = mongoose.model("Ticket", ticketSchema); 92 | 93 | export { Ticket }; 94 | -------------------------------------------------------------------------------- /orders/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from "node-nats-streaming"; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | get client() { 7 | if (!this._client) { 8 | throw new Error("Cannot access NATS client before connecting"); 9 | } 10 | 11 | return this._client; 12 | } 13 | 14 | connect(clusterId: string, clientId: string, url: string) { 15 | this._client = nats.connect(clusterId, clientId, { url }); 16 | 17 | return new Promise((resolve, reject) => { 18 | this.client.on("connect", () => { 19 | console.log("Connected to NATS"); 20 | resolve(); 21 | }); 22 | this.client.on("error", (err) => { 23 | reject(err); 24 | }); 25 | }); 26 | } 27 | } 28 | 29 | // Singleton NATS Client: export an instance instead of a class 30 | export const natsWrapper = new NatsWrapper(); 31 | -------------------------------------------------------------------------------- /orders/src/routes/__test__/delete.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | import { Ticket } from "../../models/ticket"; 4 | import { Order, OrderStatus } from "../../models/order"; 5 | import { natsWrapper } from "../../nats-wrapper"; // mock natsWrapper is instead imported by jest 6 | import mongoose from 'mongoose' 7 | 8 | 9 | it("marks an order as cancelled", async () => { 10 | // Create a ticket 11 | const ticket = Ticket.build({ 12 | id: mongoose.Types.ObjectId().toHexString(), 13 | title: "concert", 14 | price: 20, 15 | }); 16 | await ticket.save(); 17 | 18 | // Create a mock user 19 | const user = global.signin_and_get_cookie(); 20 | 21 | // Make a request to create an order 22 | const { body: order } = await request(app) 23 | .post("/api/orders") 24 | .set("Cookie", user) 25 | .send({ ticketId: ticket.id }) 26 | .expect(201); 27 | 28 | expect(order.status).not.toEqual(OrderStatus.Cancelled); 29 | 30 | // Make a request to cancel the order 31 | await request(app) 32 | .delete(`/api/orders/${order.id}`) 33 | .set("Cookie", user) 34 | .send() 35 | .expect(204); 36 | 37 | // Make sure the order is actually cancelled 38 | const updatedOrder = await Order.findById(order.id); 39 | expect(updatedOrder?.status).toEqual(OrderStatus.Cancelled); 40 | }); 41 | 42 | it("emits an order cancelled event", async () => { 43 | // Create a ticket 44 | const ticket = Ticket.build({ 45 | id: mongoose.Types.ObjectId().toHexString(), 46 | title: "concert", 47 | price: 20, 48 | }); 49 | await ticket.save(); 50 | 51 | // Create a mock user 52 | const user = global.signin_and_get_cookie(); 53 | 54 | // Make a request to create an order 55 | const { body: order } = await request(app) 56 | .post("/api/orders") 57 | .set("Cookie", user) 58 | .send({ ticketId: ticket.id }) 59 | .expect(201); 60 | 61 | // Make a request to cancel the order 62 | await request(app) 63 | .delete(`/api/orders/${order.id}`) 64 | .set("Cookie", user) 65 | .send() 66 | .expect(204); 67 | 68 | // make sure we have called publish() on nats client to publish the event 69 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 70 | }); 71 | -------------------------------------------------------------------------------- /orders/src/routes/__test__/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | import { Ticket } from "../../models/ticket"; 4 | import mongoose from 'mongoose' 5 | 6 | 7 | const buildTicket = async () => { 8 | const ticket = Ticket.build({ 9 | id: mongoose.Types.ObjectId().toHexString(), 10 | title: "concert", 11 | price: 20, 12 | }); 13 | await ticket.save(); 14 | return ticket; 15 | }; 16 | 17 | it("fetches orders for a particular user", async () => { 18 | // Create three tickets 19 | const ticketOne = await buildTicket(); 20 | const ticketTwo = await buildTicket(); 21 | const ticketThree = await buildTicket(); 22 | 23 | // Create User #1 and User #2 24 | const userOne = global.signin_and_get_cookie(); 25 | const userTwo = global.signin_and_get_cookie(); 26 | 27 | // Create one order as User #1 28 | await request(app) 29 | .post("/api/orders") 30 | .set("Cookie", userOne) 31 | .send({ ticketId: ticketOne.id }) 32 | .expect(201); 33 | 34 | // Create two orders as User #2 35 | const { body: orderOne } = await request(app) 36 | .post("/api/orders") 37 | .set("Cookie", userTwo) 38 | .send({ ticketId: ticketTwo.id }) 39 | .expect(201); 40 | 41 | const { body: orderTwo } = await request(app) 42 | .post("/api/orders") 43 | .set("Cookie", userTwo) 44 | .send({ ticketId: ticketThree.id }) 45 | .expect(201); 46 | 47 | // Make request to get orders for User #2 48 | const response = await request(app) 49 | .get("/api/orders") 50 | .set("Cookie", userTwo) 51 | .expect(200); 52 | 53 | // Make sure we only got the orders for User #2 54 | expect(response.body.length).toEqual(2); 55 | expect(response.body[0].id).toEqual(orderOne.id); 56 | expect(response.body[1].id).toEqual(orderTwo.id); 57 | }); 58 | -------------------------------------------------------------------------------- /orders/src/routes/__test__/new.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | import mongoose from "mongoose"; 4 | import { Order, OrderStatus } from "../../models/order"; 5 | import { Ticket } from "../../models/ticket"; 6 | import { natsWrapper } from "../../nats-wrapper"; // mock natsWrapper is instead imported by jest 7 | 8 | it("returns an error if the ticket does not exist", async () => { 9 | const ticketId = mongoose.Types.ObjectId(); 10 | 11 | await request(app) 12 | .post("/api/orders") 13 | .set("Cookie", global.signin_and_get_cookie()) 14 | .send({ 15 | ticketId: ticketId, 16 | }) 17 | .expect(404); // NotFoundError:404 18 | }); 19 | 20 | it("returns an error if the ticket is already reserved", async () => { 21 | // setup: create a ticket 22 | const ticket = Ticket.build({ 23 | id: mongoose.Types.ObjectId().toHexString(), 24 | title: "concert", 25 | price: 20, 26 | }); 27 | await ticket.save(); 28 | 29 | // setup: create an order thats already reserved (status != cancelled) 30 | const order = Order.build({ 31 | ticket: ticket, 32 | userId: "someuserid", 33 | status: OrderStatus.Created, 34 | expiresAt: new Date(), 35 | }); 36 | await order.save(); 37 | 38 | // make request 39 | await request(app) 40 | .post("/api/orders") 41 | .set("Cookie", global.signin_and_get_cookie()) 42 | .send({ 43 | ticketId: ticket.id, 44 | }) 45 | .expect(400); // BadRequestError:400 46 | }); 47 | 48 | it("reserves a ticket", async () => { 49 | // setup: create a ticket 50 | const ticket = Ticket.build({ 51 | id: mongoose.Types.ObjectId().toHexString(), 52 | title: "concert", 53 | price: 20, 54 | }); 55 | await ticket.save(); 56 | 57 | // make request 58 | const response = await request(app) 59 | .post("/api/orders") 60 | .set("Cookie", global.signin_and_get_cookie()) 61 | .send({ 62 | ticketId: ticket.id, 63 | }) 64 | .expect(201); 65 | 66 | expect(response.body.ticket.id).toEqual(ticket.id); 67 | 68 | const orders = await Order.find({}); 69 | expect(orders.length).toEqual(1); 70 | expect(orders[0].ticket.toString()).toEqual(ticket.id); 71 | }); 72 | 73 | it("emits an order created event", async () => { 74 | // setup: create a ticket 75 | const ticket = Ticket.build({ 76 | id: mongoose.Types.ObjectId().toHexString(), 77 | title: "concert", 78 | price: 20, 79 | }); 80 | await ticket.save(); 81 | 82 | // make request to create an order 83 | await request(app) 84 | .post("/api/orders") 85 | .set("Cookie", global.signin_and_get_cookie()) 86 | .send({ 87 | ticketId: ticket.id, 88 | }) 89 | .expect(201); 90 | 91 | // make sure we have called publish() on nats client to publish the event 92 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 93 | }); 94 | -------------------------------------------------------------------------------- /orders/src/routes/__test__/show.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | import { Ticket } from "../../models/ticket"; 4 | import mongoose from 'mongoose' 5 | 6 | it("fetches the order", async () => { 7 | // Create a ticket 8 | const ticket = Ticket.build({ 9 | id: mongoose.Types.ObjectId().toHexString(), 10 | title: "concert", 11 | price: 20, 12 | }); 13 | 14 | await ticket.save(); 15 | 16 | // Create a mock user 17 | const user = global.signin_and_get_cookie(); 18 | 19 | // Make request to build an order with this ticket 20 | const { body: order } = await request(app) 21 | .post("/api/orders") 22 | .set("Cookie", user) 23 | .send({ ticketId: ticket.id }) 24 | .expect(201); 25 | 26 | // Make request to fetch the order 27 | const { body: fetchedOrder } = await request(app) 28 | .get(`/api/orders/${order.id}`) 29 | .set("Cookie", user) 30 | .send() 31 | .expect(200); 32 | 33 | expect(fetchedOrder.id).toEqual(order.id); 34 | }); 35 | 36 | it("returns an error if one user tries to fetch another user's order", async () => { 37 | // Create a ticket 38 | const ticket = Ticket.build({ 39 | id: mongoose.Types.ObjectId().toHexString(), 40 | title: "concert", 41 | price: 20, 42 | }); 43 | 44 | await ticket.save(); 45 | 46 | // Create two mock users 47 | const userOne = global.signin_and_get_cookie(); 48 | 49 | const userTwo = global.signin_and_get_cookie(); 50 | 51 | // userOne Makes request to build an order with this ticket 52 | const { body: order } = await request(app) 53 | .post("/api/orders") 54 | .set("Cookie", userOne) 55 | .send({ ticketId: ticket.id }) 56 | .expect(201); 57 | 58 | // UserTwo Makes request to fetch UserOne's order 59 | await request(app) 60 | .get(`/api/orders/${order.id}`) 61 | .set("Cookie", userTwo) 62 | .send() 63 | .expect(401); 64 | }); 65 | -------------------------------------------------------------------------------- /orders/src/routes/delete.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { 3 | requireAuthMiddleware, 4 | NotFoundError, 5 | NotAuthorizedError, 6 | } from "@wwticketing/common"; 7 | import { Order, OrderStatus } from "../models/order"; 8 | import { natsWrapper } from "../nats-wrapper"; 9 | import { OrderCancelledPublisher } from "../events/publishers/order-cancelled-publisher"; 10 | 11 | const router = express.Router(); 12 | 13 | // We are only actually `deleting` an order, but instead set Order Status to `Cancelled` 14 | // `patch` HTTP Method might be more suitable for this case 15 | router.delete( 16 | "/api/orders/:orderId", 17 | requireAuthMiddleware, 18 | async (req: Request, res: Response) => { 19 | const { orderId } = req.params; 20 | 21 | const order = await Order.findById(orderId).populate("ticket"); 22 | 23 | if (!order) { 24 | throw new NotFoundError(); 25 | } 26 | 27 | if (order.userId !== req.currentUser!.id) { 28 | throw new NotAuthorizedError(); 29 | } 30 | 31 | order.status = OrderStatus.Cancelled; 32 | await order.save(); 33 | 34 | // Publishing an event saying that the order is cancelled 35 | new OrderCancelledPublisher(natsWrapper.client).publish({ 36 | id: order.id, 37 | version: order.version, 38 | ticket: { 39 | id: order.ticket.id, 40 | }, 41 | }); 42 | 43 | res.status(204).send(order); 44 | } 45 | ); 46 | 47 | export { router as deleteOrderRouter }; 48 | -------------------------------------------------------------------------------- /orders/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { requireAuthMiddleware } from "@wwticketing/common"; 3 | import { Order } from "../models/order"; 4 | 5 | const router = express.Router(); 6 | 7 | router.get( 8 | "/api/orders", 9 | requireAuthMiddleware, 10 | async (req: Request, res: Response) => { 11 | const orders = await Order.find({ 12 | userId: req.currentUser!.id, 13 | }).populate("ticket"); 14 | 15 | res.send(orders); 16 | } 17 | ); 18 | 19 | export { router as indexOrderRouter }; 20 | -------------------------------------------------------------------------------- /orders/src/routes/new.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { 3 | requireAuthMiddleware, 4 | validateRequestMiddleware, 5 | NotFoundError, 6 | OrderStatus, 7 | BadRequestError, 8 | } from "@wwticketing/common"; 9 | import { body } from "express-validator"; 10 | import mongoose from "mongoose"; 11 | import { Ticket } from "../models/ticket"; 12 | import { Order } from "../models/order"; 13 | import { natsWrapper } from "../nats-wrapper"; 14 | import { OrderCreatedPublisher } from "../events/publishers/order-created-pubisher"; 15 | 16 | const router = express.Router(); 17 | 18 | const EXPIRATION_WINDOW_SECONDS = 1 * 60; //TODO: change this back to 15 minutes 19 | 20 | router.post( 21 | "/api/orders", 22 | requireAuthMiddleware, 23 | [ 24 | body("ticketId") 25 | .not() 26 | .isEmpty() 27 | .custom((input: string) => mongoose.Types.ObjectId.isValid(input)) 28 | .withMessage("TicketId must be provided"), 29 | ], 30 | validateRequestMiddleware, 31 | async (req: Request, res: Response) => { 32 | const { ticketId } = req.body; 33 | 34 | // 1) 35 | // Find the ticket the user is trying to order in the database 36 | const ticket = await Ticket.findById(ticketId); 37 | if (!ticket) { 38 | throw new NotFoundError(); 39 | } 40 | 41 | // 2) 42 | // Make sure that this ticket is not already reserved 43 | const isReserved = await ticket.isReserved(); 44 | if (isReserved) { 45 | throw new BadRequestError("Ticket is already reserved"); 46 | } 47 | 48 | // 3) 49 | // Calculate an expiration date for this order 50 | const expiration = new Date(); 51 | expiration.setSeconds( 52 | expiration.getSeconds() + EXPIRATION_WINDOW_SECONDS 53 | ); 54 | 55 | // 4) 56 | // Build the order and save it to database 57 | const order = Order.build({ 58 | userId: req.currentUser!.id, 59 | status: OrderStatus.Created, 60 | expiresAt: expiration, 61 | ticket: ticket, 62 | }); 63 | // save order to db 64 | await order.save(); 65 | 66 | // 5) 67 | // Publish an event saying that an order was created 68 | new OrderCreatedPublisher(natsWrapper.client).publish({ 69 | id: order.id, 70 | version: order.version, 71 | status: order.status, 72 | userId: order.userId, 73 | expiresAt: order.expiresAt.toISOString(), //UTC Timestamp as string 74 | ticket: { 75 | id: ticket.id, 76 | price: ticket.price, 77 | }, 78 | }); 79 | 80 | res.status(201).send(order); 81 | } 82 | ); 83 | 84 | export { router as newOrderRouter }; 85 | -------------------------------------------------------------------------------- /orders/src/routes/show.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { 3 | requireAuthMiddleware, 4 | NotFoundError, 5 | NotAuthorizedError, 6 | } from "@wwticketing/common"; 7 | import { Order } from "../models/order"; 8 | 9 | const router = express.Router(); 10 | 11 | router.get( 12 | "/api/orders/:orderId", 13 | requireAuthMiddleware, 14 | async (req: Request, res: Response) => { 15 | const order = await Order.findById(req.params.orderId).populate( 16 | "ticket" 17 | ); 18 | 19 | if (!order) { 20 | throw new NotFoundError(); 21 | } 22 | if (order.userId !== req.currentUser!.id) { 23 | throw new NotAuthorizedError(); 24 | } 25 | 26 | res.send(order); 27 | } 28 | ); 29 | 30 | export { router as showOrderRouter }; 31 | -------------------------------------------------------------------------------- /orders/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from "mongodb-memory-server"; 2 | import mongoose from "mongoose"; 3 | import jwt from "jsonwebtoken"; 4 | 5 | declare global { 6 | namespace NodeJS { 7 | interface Global { 8 | signin_and_get_cookie(): string[]; 9 | } 10 | } 11 | } 12 | 13 | // use jest to mock the nats-wrapper 14 | // jest will automatically use the fake nats-wrapper inside __mock__ folder 15 | jest.mock("../nats-wrapper"); 16 | 17 | // declare in-memory mock mongo server 18 | let mongo: any; 19 | 20 | beforeAll(async () => { 21 | process.env.JWT_KEY = "test_jwt_key"; 22 | mongo = new MongoMemoryServer(); 23 | const mongoUri = await mongo.getUri(); 24 | 25 | await mongoose.connect(mongoUri, { 26 | useNewUrlParser: true, 27 | useUnifiedTopology: true, 28 | }); 29 | }); 30 | 31 | beforeEach(async () => { 32 | jest.clearAllMocks(); 33 | // get all existing collections in mongodb 34 | const collections = await mongoose.connection.db.collections(); 35 | 36 | for (let collection of collections) { 37 | await collection.deleteMany({}); 38 | } 39 | }); 40 | 41 | afterAll(async () => { 42 | await mongo.stop(); 43 | await mongoose.connection.close(); 44 | }); 45 | 46 | // global function only for test environment 47 | // signup and return the cookie from the response 48 | global.signin_and_get_cookie = () => { 49 | // Build a JWT payload. {id, email} 50 | const payload = { 51 | id: new mongoose.Types.ObjectId().toHexString(), 52 | email: "test@test.com", 53 | }; 54 | 55 | // Create the JWT 56 | const token = jwt.sign(payload, process.env.JWT_KEY!); 57 | 58 | // Build session object. {jwt: MY_JWT} 59 | const session = { jwt: token }; 60 | 61 | // Turn that session into JSON 62 | const sessionJSON = JSON.stringify(session); 63 | 64 | // Take that JSON and encode it as base64 65 | const base64 = Buffer.from(sessionJSON).toString("base64"); 66 | 67 | // Return a string thats the cookie with the encoded data 68 | const cookie_base64 = `express:sess=${base64}`; 69 | 70 | return [cookie_base64]; // a little gottcha: put the cookie string inside an array to make supertest lib happy :) 71 | }; 72 | -------------------------------------------------------------------------------- /orders/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /payments/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /payments/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # only install prod dependencies 6 | RUN npm install --only=prod 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /payments/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payments", 3 | "version": "1.0.0", 4 | "description": "Payments Service", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev src/index.ts --poll", 8 | "test": "jest --watchAll --no-cache", 9 | "test:ci": "jest" 10 | }, 11 | "jest": { 12 | "preset": "ts-jest", 13 | "testEnvironment": "node", 14 | "setupFilesAfterEnv": [ 15 | "./src/test/setup.ts" 16 | ] 17 | }, 18 | "keywords": [], 19 | "author": "Weilyu Wang", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@types/cookie-session": "2.0.39", 23 | "@types/express": "^4.17.6", 24 | "@types/jsonwebtoken": "^8.5.0", 25 | "@types/mongoose": "^5.7.23", 26 | "@wwticketing/common": "^1.0.13", 27 | "cookie-session": "^1.4.0", 28 | "express": "^4.17.1", 29 | "express-async-errors": "^3.1.1", 30 | "express-validator": "^6.5.0", 31 | "jsonwebtoken": "^8.5.1", 32 | "mongoose": "^5.9.18", 33 | "mongoose-update-if-current": "^1.4.0", 34 | "node-nats-streaming": "^0.3.2", 35 | "stripe": "^8.67.0", 36 | "ts-node-dev": "^1.0.0-pre.44", 37 | "typescript": "^3.9.5" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^26.0.0", 41 | "@types/supertest": "^2.0.9", 42 | "jest": "^26.0.1", 43 | "mongodb-memory-server": "^6.6.1", 44 | "supertest": "^4.0.2", 45 | "ts-jest": "^26.1.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /payments/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | // mock NATS client (Stan) 3 | client: { 4 | publish: jest.fn() 5 | .mockImplementation( 6 | (subject: string, data: string, callback: () => void) => { 7 | callback(); 8 | } 9 | ), 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /payments/src/__mocks__/stripe.ts: -------------------------------------------------------------------------------- 1 | export const stripe = { 2 | charges: { 3 | create: jest.fn().mockResolvedValue({ id: "some_charge_id" }) 4 | } 5 | } -------------------------------------------------------------------------------- /payments/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import "express-async-errors"; 3 | import { json } from "body-parser"; 4 | import cookieSession from "cookie-session"; 5 | import { 6 | errorHandlerMiddleware, 7 | NotFoundError, 8 | currentUserMiddleware, 9 | } from "@wwticketing/common"; 10 | import { createChargeRouter } from "./routes/new"; 11 | 12 | const app = express(); 13 | app.set("trust proxy", true); // trust ingress & nginx proxy 14 | app.use(json()); 15 | 16 | // use cookieSession to add session property to req object 17 | app.use( 18 | cookieSession({ 19 | signed: false, // disable encryption on the cookie - JWT is already secured 20 | // secure: process.env.NODE_ENV !== "test", // *** HTTPS connection only ***, but exception made for testing 21 | secure: false, 22 | }) 23 | ); 24 | 25 | // middleware to add currentUser to req 26 | app.use(currentUserMiddleware); 27 | 28 | // express routes 29 | app.use(createChargeRouter); 30 | 31 | // use express-async-errors lib behind the scene to handle async errors 32 | app.all("*", async (req, res) => { 33 | throw new NotFoundError(); 34 | }); 35 | 36 | // middleware to handle all kinds of errors 37 | app.use(errorHandlerMiddleware); 38 | 39 | export { app }; 40 | -------------------------------------------------------------------------------- /payments/src/events/listeners/__test__/order-cancelled-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { natsWrapper } from '../../../nats-wrapper' 2 | import { OrderCancelledListener } from '../order-cancelled-listener' 3 | import { OrderCancelledEvent, OrderStatus } from '@wwticketing/common' 4 | import mongoose from 'mongoose' 5 | import { Message } from 'node-nats-streaming' 6 | import { Order } from '../../../models/order' 7 | 8 | const setup = async () => { 9 | // create OrderCancelledListener 10 | const listener = new OrderCancelledListener(natsWrapper.client) 11 | 12 | // create order and persist order 13 | const orderId = mongoose.Types.ObjectId().toHexString() 14 | const order = Order.build({ 15 | id: orderId, 16 | version: 0, 17 | userId: 'someuser', 18 | price: 100, 19 | status: OrderStatus.Created 20 | }) 21 | await order.save() 22 | 23 | // create OrderCancelledEvent payload 24 | const data: OrderCancelledEvent['data'] = { 25 | id: orderId, 26 | version: 1, 27 | ticket: { 28 | id: mongoose.Types.ObjectId().toHexString() 29 | } 30 | } 31 | 32 | // mock Message object with ack() func 33 | // @ts-ignore 34 | const msg: Message = { 35 | ack: jest.fn() 36 | } 37 | 38 | return { listener, order, data, msg } 39 | } 40 | 41 | 42 | it('updates the status of the order', async () => { 43 | const { listener, data, msg } = await setup() 44 | await listener.onMessage(data, msg) 45 | 46 | const updatedOrder = await Order.findById(data.id) 47 | expect(updatedOrder!.status).toEqual(OrderStatus.Cancelled) 48 | }) 49 | 50 | 51 | it('acks the message', async () => { 52 | const { listener, data, msg } = await setup() 53 | await listener.onMessage(data, msg) 54 | 55 | expect(msg.ack).toHaveBeenCalled() 56 | }) -------------------------------------------------------------------------------- /payments/src/events/listeners/__test__/order-created-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { natsWrapper } from '../../../nats-wrapper' 2 | import { OrderCreatedListener } from '../order-created-listener' 3 | import { OrderCreatedEvent, OrderStatus } from '@wwticketing/common' 4 | import mongoose from 'mongoose' 5 | import { Message } from 'node-nats-streaming' 6 | import { Order } from '../../../models/order' 7 | 8 | const setup = async () => { 9 | const listener = new OrderCreatedListener(natsWrapper.client) 10 | 11 | const data: OrderCreatedEvent['data'] = { 12 | id: mongoose.Types.ObjectId().toHexString(), 13 | version: 0, 14 | expiresAt: '2020-02-02', 15 | userId: 'someuser', 16 | status: OrderStatus.Created, 17 | ticket: { 18 | id: mongoose.Types.ObjectId().toHexString(), 19 | price: 100 20 | } 21 | } 22 | 23 | // @ts-ignore 24 | const msg: Message = { 25 | ack: jest.fn() 26 | } 27 | 28 | return { listener, data, msg } 29 | } 30 | 31 | 32 | it('replicates the order info', async () => { 33 | const { listener, data, msg } = await setup() 34 | await listener.onMessage(data, msg) 35 | 36 | const order = await Order.findById(data.id) 37 | expect(order!.price).toEqual(data.ticket.price) 38 | }) 39 | 40 | 41 | it('acks the message', async () => { 42 | const { listener, data, msg } = await setup() 43 | await listener.onMessage(data, msg) 44 | 45 | expect(msg.ack).toHaveBeenCalled() 46 | }) -------------------------------------------------------------------------------- /payments/src/events/listeners/order-cancelled-listener.ts: -------------------------------------------------------------------------------- 1 | import { OrderCancelledEvent, Subjects, Listener, OrderStatus } from '@wwticketing/common' 2 | import { Message } from 'node-nats-streaming'; 3 | import { Order } from '../../models/order' 4 | import { queueGroupName } from './queue-group-name' 5 | 6 | export class OrderCancelledListener extends Listener { 7 | readonly subject = Subjects.OrderCancelled; 8 | readonly queueGroupName: string = queueGroupName; 9 | 10 | async onMessage(data: OrderCancelledEvent['data'], msg: Message) { 11 | const order = await Order.findOne({ 12 | _id: data.id, 13 | version: data.version - 1 14 | }) 15 | 16 | if (!order) { 17 | throw new Error('Order not found') 18 | } 19 | 20 | order.set({ status: OrderStatus.Cancelled }) 21 | await order.save() 22 | 23 | msg.ack() 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /payments/src/events/listeners/order-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCreatedEvent, Subjects } from '@wwticketing/common' 2 | import { queueGroupName } from './queue-group-name' 3 | import { Message } from 'node-nats-streaming' 4 | import { Order } from '../../models/order' 5 | 6 | export class OrderCreatedListener extends Listener { 7 | readonly subject: Subjects.OrderCreated = Subjects.OrderCreated; 8 | readonly queueGroupName: string = queueGroupName; 9 | 10 | async onMessage(data: OrderCreatedEvent['data'], msg: Message) { 11 | 12 | const order = Order.build({ 13 | id: data.id, 14 | version: data.version, 15 | userId: data.userId, 16 | price: data.ticket.price, 17 | status: data.status, 18 | }) 19 | 20 | await order.save() 21 | 22 | msg.ack() 23 | 24 | } 25 | } -------------------------------------------------------------------------------- /payments/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGroupName = "payments-service" -------------------------------------------------------------------------------- /payments/src/events/publishers/payment-created-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Subjects, Publisher, PaymentCreatedEvent } from '@wwticketing/common' 2 | 3 | export class PaymentCreatedPublisher extends Publisher { 4 | readonly subject = Subjects.PaymentCreated; 5 | } -------------------------------------------------------------------------------- /payments/src/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { app } from "./app"; 3 | import { natsWrapper } from "./nats-wrapper"; 4 | import { OrderCancelledListener } from './events/listeners/order-cancelled-listener' 5 | import { OrderCreatedListener } from './events/listeners/order-created-listener' 6 | 7 | const start = async () => { 8 | // First check if JWT_KEY is defined 9 | if (!process.env.JWT_KEY) { 10 | throw new Error("JWT_KEY must be defined"); 11 | } 12 | if (!process.env.MONGO_URI) { 13 | throw new Error("MONGO_URI must be defined"); 14 | } 15 | if (!process.env.NATS_URL) { 16 | throw new Error("NATS_URL must be defined"); 17 | } 18 | if (!process.env.NATS_CLUSTER_ID) { 19 | throw new Error("NATS_CLUSTER_ID must be defined"); 20 | } 21 | if (!process.env.NATS_CLIENT_ID) { 22 | throw new Error("NATS_CLIENT_ID must be defined"); 23 | } 24 | 25 | try { 26 | await natsWrapper.connect( 27 | process.env.NATS_CLUSTER_ID, 28 | process.env.NATS_CLIENT_ID, 29 | process.env.NATS_URL 30 | ); 31 | 32 | // Gracefully shut down NATS connection 33 | natsWrapper.client.on("close", () => { 34 | console.log("NATS connection closed!"); 35 | process.exit(); 36 | }); 37 | process.on("SIGINT", () => natsWrapper.client.close()); 38 | process.on("SIGTERM", () => natsWrapper.client.close()); 39 | 40 | // init listeners to start listen to events 41 | new OrderCreatedListener(natsWrapper.client).listen() 42 | new OrderCancelledListener(natsWrapper.client).listen() 43 | 44 | await mongoose.connect(process.env.MONGO_URI, { 45 | useNewUrlParser: true, 46 | useUnifiedTopology: true, 47 | useCreateIndex: true, 48 | }); 49 | console.log("Connected to MongoDB"); 50 | } catch (err) { 51 | console.log(err); 52 | } 53 | 54 | app.listen(3000, () => { 55 | console.log("Tickets Service listening on port 3000"); 56 | }); 57 | }; 58 | 59 | start(); 60 | -------------------------------------------------------------------------------- /payments/src/models/order.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { OrderStatus } from '@wwticketing/common' 3 | import { updateIfCurrentPlugin } from 'mongoose-update-if-current' 4 | 5 | interface OrderAttrs { 6 | id: string; 7 | version: number; 8 | userId: string; 9 | price: number; 10 | status: OrderStatus; 11 | } 12 | 13 | interface OrderDoc extends mongoose.Document { 14 | version: number; 15 | userId: string; 16 | price: number; 17 | status: OrderStatus; 18 | } 19 | 20 | interface OrderModel extends mongoose.Model { 21 | build(attrs: OrderAttrs): OrderDoc 22 | } 23 | 24 | const orderSchema = new mongoose.Schema({ 25 | userId: { 26 | type: String, 27 | required: true 28 | }, 29 | price: { 30 | type: Number, 31 | required: true 32 | }, 33 | status: { 34 | type: String, 35 | required: true 36 | }, 37 | }, { 38 | toJSON: { 39 | transform(doc, ret) { 40 | ret.id = ret._id; 41 | delete ret._id; 42 | } 43 | } 44 | }) 45 | 46 | orderSchema.set('versionKey', 'version'); 47 | orderSchema.plugin(updateIfCurrentPlugin); 48 | 49 | orderSchema.statics.build = (attrs: OrderAttrs) => { 50 | return new Order({ 51 | _id: attrs.id, 52 | version: attrs.version, 53 | price: attrs.price, 54 | userId: attrs.userId, 55 | status: attrs.status 56 | }) 57 | } 58 | 59 | const Order = mongoose.model('Order', orderSchema) 60 | 61 | export { Order } -------------------------------------------------------------------------------- /payments/src/models/payment.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | interface PaymentAttrs { 4 | orderId: string; 5 | stripeId: string; 6 | } 7 | 8 | interface PaymentDoc extends mongoose.Document { 9 | orderId: string; 10 | stripeId: string; 11 | } 12 | 13 | interface PaymentModel extends mongoose.Model { 14 | build(attrs: PaymentAttrs): PaymentDoc 15 | } 16 | 17 | const paymentSchema = new mongoose.Schema({ 18 | orderId: { 19 | required: true, 20 | type: String, 21 | }, 22 | stripeId: { 23 | required: true, 24 | type: String 25 | } 26 | }, { 27 | toJSON: { 28 | transform(_, ret) { 29 | ret.id = ret._id; 30 | delete ret._id; 31 | } 32 | } 33 | }) 34 | 35 | paymentSchema.statics.build = (attrs: PaymentAttrs) => { 36 | return new Payment(attrs); 37 | } 38 | 39 | const Payment = mongoose.model('Payment', paymentSchema) 40 | 41 | export { Payment } -------------------------------------------------------------------------------- /payments/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from "node-nats-streaming"; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | get client() { 7 | if (!this._client) { 8 | throw new Error("Cannot access NATS client before connecting"); 9 | } 10 | 11 | return this._client; 12 | } 13 | 14 | connect(clusterId: string, clientId: string, url: string) { 15 | this._client = nats.connect(clusterId, clientId, { url }); 16 | 17 | return new Promise((resolve, reject) => { 18 | this.client.on("connect", () => { 19 | console.log("Connected to NATS"); 20 | resolve(); 21 | }); 22 | this.client.on("error", (err) => { 23 | reject(err); 24 | }); 25 | }); 26 | } 27 | } 28 | 29 | export const natsWrapper = new NatsWrapper(); 30 | -------------------------------------------------------------------------------- /payments/src/routes/__test__/new.test.ts: -------------------------------------------------------------------------------- 1 | import { app } from '../../app' 2 | import request from 'supertest' 3 | import mongoose from 'mongoose' 4 | import { OrderStatus } from '@wwticketing/common' 5 | import { Order } from '../../models/order' 6 | import { stripe } from '../../stripe' 7 | import { Payment } from '../../models/payment' 8 | 9 | jest.mock('../../stripe') 10 | 11 | it('returns a 404 when purchasing an order that does not exist', async () => { 12 | await request(app).post('/api/payments') 13 | .set('Cookie', global.signin_and_get_cookie()) 14 | .send({ 15 | token: 'aaaaaaa', 16 | orderId: mongoose.Types.ObjectId().toHexString() 17 | }) 18 | .expect(404) 19 | }) 20 | 21 | it('returns a 401 when purchasing an order that does not belong to the current user', async () => { 22 | const order = Order.build({ 23 | id: mongoose.Types.ObjectId().toHexString(), 24 | userId: mongoose.Types.ObjectId().toHexString(), 25 | version: 0, 26 | price: 100, 27 | status: OrderStatus.Created, 28 | }) 29 | await order.save() 30 | 31 | await request(app) 32 | .post('/api/payments') 33 | .set('Cookie', global.signin_and_get_cookie()) 34 | .send({ 35 | token: 'aaaaaaa', 36 | orderId: order.id 37 | }) 38 | .expect(401) 39 | }) 40 | 41 | it('returns a 400 when purchasing a cancelled order', async () => { 42 | const userId = mongoose.Types.ObjectId().toHexString() 43 | const order = Order.build({ 44 | id: mongoose.Types.ObjectId().toHexString(), 45 | userId: userId, 46 | version: 0, 47 | price: 100, 48 | status: OrderStatus.Cancelled, 49 | }) 50 | await order.save() 51 | 52 | await request(app) 53 | .post('/api/payments') 54 | .set('Cookie', global.signin_and_get_cookie(userId)) 55 | .send({ 56 | token: 'secret_token', 57 | orderId: order.id 58 | }) 59 | .expect(400) 60 | }) 61 | 62 | it('returns a 204 with valid input', async () => { 63 | const userId = mongoose.Types.ObjectId().toHexString() 64 | const order = Order.build({ 65 | id: mongoose.Types.ObjectId().toHexString(), 66 | userId: userId, 67 | version: 0, 68 | price: 100, 69 | status: OrderStatus.Created, 70 | }) 71 | await order.save() 72 | 73 | await request(app) 74 | .post('/api/payments') 75 | .set('Cookie', global.signin_and_get_cookie(userId)) 76 | .send({ 77 | token: 'tok_visa', 78 | orderId: order.id 79 | }) 80 | .expect(201) 81 | 82 | const chargeOptions = (stripe.charges.create as jest.Mock).mock.calls[0][0] 83 | expect(chargeOptions.source).toEqual('tok_visa') 84 | expect(chargeOptions.amount).toEqual(100 * 100) 85 | expect(chargeOptions.currency).toEqual('usd') 86 | 87 | const payment = await Payment.findOne({ 88 | orderId: order.id 89 | }) 90 | expect(payment!.stripeId).not.toBeNull() 91 | 92 | }) -------------------------------------------------------------------------------- /payments/src/routes/new.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { body } from "express-validator"; 3 | import { 4 | requireAuthMiddleware, 5 | validateRequestMiddleware, 6 | BadRequestError, 7 | NotFoundError, 8 | NotAuthorizedError, 9 | OrderStatus, 10 | } from "@wwticketing/common"; 11 | import { Order } from "../models/order"; 12 | import { Payment } from "../models/payment"; 13 | import { stripe } from "../stripe"; 14 | import { PaymentCreatedPublisher } from "../events/publishers/payment-created-publisher"; 15 | import { natsWrapper } from "../nats-wrapper"; 16 | 17 | const router = express.Router(); 18 | 19 | router.post( 20 | "/api/payments", 21 | requireAuthMiddleware, 22 | [body("token").not().isEmpty(), body("orderId").not().isEmpty()], 23 | validateRequestMiddleware, 24 | async (req: Request, res: Response) => { 25 | const { token, orderId } = req.body; 26 | 27 | const order = await Order.findById(orderId); 28 | if (!order) { 29 | throw new NotFoundError(); 30 | } 31 | 32 | if (order.userId !== req.currentUser!.id) { 33 | throw new NotAuthorizedError(); 34 | } 35 | 36 | if (order.status === OrderStatus.Cancelled) { 37 | throw new BadRequestError("Cannot pay for an cancelled order"); 38 | } 39 | 40 | const charge = await stripe.charges.create({ 41 | currency: "usd", 42 | amount: order.price * 100, // convert to cents 43 | source: token, 44 | }); 45 | 46 | const payment = Payment.build({ 47 | orderId: orderId, 48 | stripeId: charge.id, 49 | }); 50 | await payment.save(); 51 | 52 | // publish payment created event 53 | await new PaymentCreatedPublisher(natsWrapper.client).publish({ 54 | id: payment.id, 55 | orderId: payment.orderId, 56 | stripeId: payment.stripeId, 57 | }); 58 | 59 | res.status(201).send({ id: payment.id }); 60 | } 61 | ); 62 | 63 | export { router as createChargeRouter }; 64 | -------------------------------------------------------------------------------- /payments/src/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_KEY!, { 4 | apiVersion: '2020-03-02' 5 | }) -------------------------------------------------------------------------------- /payments/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from "mongodb-memory-server"; 2 | import mongoose from "mongoose"; 3 | import jwt from "jsonwebtoken"; 4 | 5 | declare global { 6 | namespace NodeJS { 7 | interface Global { 8 | signin_and_get_cookie(id?: string): string[]; 9 | } 10 | } 11 | } 12 | 13 | // use jest to mock the nats-wrapper 14 | // jest will automatically use the fake nats-wrapper inside __mock__ folder 15 | jest.mock("../nats-wrapper"); 16 | 17 | // declare in-memory mock mongo server 18 | let mongo: any; 19 | 20 | beforeAll(async () => { 21 | process.env.JWT_KEY = "test_jwt_key"; 22 | mongo = new MongoMemoryServer(); 23 | const mongoUri = await mongo.getUri(); 24 | 25 | await mongoose.connect(mongoUri, { 26 | useNewUrlParser: true, 27 | useUnifiedTopology: true, 28 | }); 29 | }); 30 | 31 | beforeEach(async () => { 32 | jest.clearAllMocks(); 33 | // get all existing collections in mongodb 34 | const collections = await mongoose.connection.db.collections(); 35 | 36 | for (let collection of collections) { 37 | await collection.deleteMany({}); 38 | } 39 | }); 40 | 41 | afterAll(async () => { 42 | await mongo.stop(); 43 | await mongoose.connection.close(); 44 | }); 45 | 46 | // global function only for test environment 47 | // signup and return the cookie from the response 48 | global.signin_and_get_cookie = (id?: string) => { 49 | // Build a JWT payload. {id, email} 50 | const payload = { 51 | id: id || new mongoose.Types.ObjectId().toHexString(), 52 | email: "test@test.com", 53 | }; 54 | 55 | // Create the JWT 56 | const token = jwt.sign(payload, process.env.JWT_KEY!); 57 | 58 | // Build session object. {jwt: MY_JWT} 59 | const session = { jwt: token }; 60 | 61 | // Turn that session into JSON 62 | const sessionJSON = JSON.stringify(session); 63 | 64 | // Take that JSON and encode it as base64 65 | const base64 = Buffer.from(sessionJSON).toString("base64"); 66 | 67 | // Return a string thats the cookie with the encoded data 68 | const cookie_base64 = `express:sess=${base64}`; 69 | 70 | return [cookie_base64]; // a little gottcha: put the cookie string inside an array to make supertest lib happy :) 71 | }; 72 | -------------------------------------------------------------------------------- /payments/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2alpha3 2 | kind: Config 3 | deploy: 4 | kubectl: 5 | manifests: 6 | - ./infra/k8s/* 7 | - ./infra/k8s-dev/* 8 | build: 9 | local: 10 | push: false 11 | artifacts: 12 | - image: weilyuwang/ticketing-auth 13 | context: auth 14 | docker: 15 | dockerfile: Dockerfile 16 | sync: 17 | manual: 18 | - src: "src/**/*.ts" 19 | dest: . 20 | - image: weilyuwang/ticketing-client 21 | context: client 22 | docker: 23 | dockerfile: Dockerfile 24 | sync: 25 | manual: 26 | - src: "**/*.js" 27 | dest: . 28 | - image: weilyuwang/ticketing-tickets 29 | context: tickets 30 | docker: 31 | dockerfile: Dockerfile 32 | sync: 33 | manual: 34 | - src: "src/**/*.ts" 35 | dest: . 36 | - image: weilyuwang/ticketing-orders 37 | context: orders 38 | docker: 39 | dockerfile: Dockerfile 40 | sync: 41 | manual: 42 | - src: "src/**/*.ts" 43 | dest: . 44 | - image: weilyuwang/ticketing-expiration 45 | context: expiration 46 | docker: 47 | dockerfile: Dockerfile 48 | sync: 49 | manual: 50 | - src: "src/**/*.ts" 51 | dest: . 52 | - image: weilyuwang/ticketing-payments 53 | context: payments 54 | docker: 55 | dockerfile: Dockerfile 56 | sync: 57 | manual: 58 | - src: "src/**/*.ts" 59 | dest: . 60 | -------------------------------------------------------------------------------- /tickets/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /tickets/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # only install prod dependencies 6 | RUN npm install --only=prod 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /tickets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tickets", 3 | "version": "1.0.0", 4 | "description": "Tickets Service", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev src/index.ts --poll", 8 | "test": "jest --watchAll --no-cache", 9 | "test:ci": "jest" 10 | }, 11 | "jest": { 12 | "preset": "ts-jest", 13 | "testEnvironment": "node", 14 | "setupFilesAfterEnv": [ 15 | "./src/test/setup.ts" 16 | ] 17 | }, 18 | "keywords": [], 19 | "author": "Weilyu Wang", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@types/cookie-session": "2.0.39", 23 | "@types/express": "^4.17.6", 24 | "@types/jsonwebtoken": "^8.5.0", 25 | "@types/mongoose": "^5.7.23", 26 | "@wwticketing/common": "^1.0.13", 27 | "cookie-session": "^1.4.0", 28 | "express": "^4.17.1", 29 | "express-async-errors": "^3.1.1", 30 | "express-validator": "^6.5.0", 31 | "jsonwebtoken": "^8.5.1", 32 | "mongoose": "^5.9.18", 33 | "mongoose-update-if-current": "^1.4.0", 34 | "node-nats-streaming": "^0.3.2", 35 | "ts-node-dev": "^1.0.0-pre.44", 36 | "typescript": "^3.9.5" 37 | }, 38 | "devDependencies": { 39 | "@types/jest": "^26.0.0", 40 | "@types/supertest": "^2.0.9", 41 | "jest": "^26.0.1", 42 | "mongodb-memory-server": "^6.6.1", 43 | "supertest": "^4.0.2", 44 | "ts-jest": "^26.1.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tickets/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | // mock NATS client (Stan) 3 | client: { 4 | publish: jest 5 | .fn() 6 | .mockImplementation( 7 | (subject: string, data: string, callback: () => void) => { 8 | callback(); 9 | } 10 | ), 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /tickets/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import "express-async-errors"; 3 | import { json } from "body-parser"; 4 | import cookieSession from "cookie-session"; 5 | import { 6 | errorHandlerMiddleware, 7 | NotFoundError, 8 | currentUserMiddleware, 9 | } from "@wwticketing/common"; 10 | import { createTicketRouter } from "./routes/new"; 11 | import { showTicketRouter } from "./routes/show"; 12 | import { indexTicketRouter } from "./routes/index"; 13 | import { updateTicketRouter } from "./routes/update"; 14 | 15 | const app = express(); 16 | app.set("trust proxy", true); // trust ingress & nginx proxy 17 | app.use(json()); 18 | 19 | // use cookieSession to add session property to req object 20 | app.use( 21 | cookieSession({ 22 | signed: false, // disable encryption on the cookie - JWT is already secured 23 | // secure: process.env.NODE_ENV !== "test", // *** HTTPS connection only ***, but exception made for testing 24 | secure: false, 25 | }) 26 | ); 27 | 28 | // middleware to add currentUser to req 29 | app.use(currentUserMiddleware); 30 | 31 | // express routes 32 | app.use(createTicketRouter); 33 | app.use(showTicketRouter); 34 | app.use(indexTicketRouter); 35 | app.use(updateTicketRouter); 36 | 37 | // use express-async-errors lib behind the scene to handle async errors 38 | app.all("*", async (req, res) => { 39 | throw new NotFoundError(); 40 | }); 41 | 42 | // middleware to handle all kinds of errors 43 | app.use(errorHandlerMiddleware); 44 | 45 | export { app }; 46 | -------------------------------------------------------------------------------- /tickets/src/events/listeners/__test__/order-cancelled-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { OrderCancelledListener } from '../order-cancelled-listener' 2 | import { natsWrapper } from '../../../nats-wrapper' 3 | import { Ticket } from '../../../models/ticket' 4 | import mongoose from 'mongoose' 5 | import { OrderCancelledEvent } from '@wwticketing/common' 6 | import { Message } from 'node-nats-streaming' 7 | 8 | const setup = async () => { 9 | // Create an instance of the listener 10 | const listener = new OrderCancelledListener(natsWrapper.client) 11 | 12 | // Create and save a ticket 13 | const orderId = mongoose.Types.ObjectId().toHexString() 14 | 15 | const ticket = Ticket.build({ 16 | title: 'concert', 17 | price: 100, 18 | userId: 'someuser' 19 | }) 20 | ticket.set({ orderId }) 21 | await ticket.save() 22 | 23 | 24 | // Create the fake order cancelled event data 25 | const data: OrderCancelledEvent['data'] = { 26 | id: orderId, 27 | version: 0, 28 | ticket: { 29 | id: ticket.id, 30 | } 31 | } 32 | 33 | // @ts-ignore 34 | const msg: Message = { 35 | ack: jest.fn() 36 | } 37 | 38 | 39 | return { listener, ticket, data, orderId, msg } 40 | 41 | } 42 | 43 | 44 | it('updates the ticket, publishes an event, and acks the message', async () => { 45 | const { listener, ticket, data, orderId, msg } = await setup() 46 | 47 | await listener.onMessage(data, msg) 48 | 49 | const updatedTicket = await Ticket.findById(ticket.id) 50 | 51 | // expect the ticket's orderId property to be `undefined` 52 | expect(updatedTicket!.orderId).not.toBeDefined() 53 | expect(msg.ack).toHaveBeenCalled() 54 | expect(natsWrapper.client.publish).toHaveBeenCalled() 55 | }) -------------------------------------------------------------------------------- /tickets/src/events/listeners/__test__/order-created-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { OrderCreatedListener } from '../order-created-listener' 2 | import { natsWrapper } from '../../../nats-wrapper' 3 | import { Ticket } from '../../../models/ticket' 4 | import mongoose from 'mongoose' 5 | import { OrderCreatedEvent, OrderStatus } from '@wwticketing/common' 6 | import { Message } from 'node-nats-streaming' 7 | 8 | const setup = async () => { 9 | // Create an instance of the listener 10 | const listener = new OrderCreatedListener(natsWrapper.client) 11 | 12 | // Create and save a ticket 13 | const ticket = Ticket.build({ 14 | title: 'concert', 15 | price: 100, 16 | userId: mongoose.Types.ObjectId().toHexString() 17 | }) 18 | await ticket.save() 19 | 20 | // Create the fake data event 21 | const data: OrderCreatedEvent['data'] = { 22 | id: mongoose.Types.ObjectId().toHexString(), 23 | version: 0, 24 | status: OrderStatus.Created, 25 | userId: 'somebuyer', 26 | expiresAt: 'sometime', 27 | ticket: { 28 | id: ticket.id, 29 | price: ticket.price, 30 | } 31 | } 32 | 33 | // @ts-ignore 34 | const msg: Message = { 35 | ack: jest.fn() 36 | } 37 | 38 | 39 | return { listener, ticket, data, msg } 40 | 41 | } 42 | 43 | 44 | it('sets the userId of the ticket', async () => { 45 | const { listener, ticket, data, msg } = await setup() 46 | 47 | await listener.onMessage(data, msg) 48 | 49 | const updatedTicket = await Ticket.findById(ticket.id) 50 | 51 | // expect the ticket document will have an order id appended. 52 | expect(updatedTicket!.orderId).toEqual(data.id) 53 | 54 | }) 55 | 56 | it('acks the message', async () => { 57 | const { listener, data, msg } = await setup() 58 | 59 | await listener.onMessage(data, msg) 60 | 61 | expect(msg.ack).toHaveBeenCalled() 62 | }) 63 | 64 | it('pubishes a ticket updated event', async () => { 65 | const { listener, data: orderCreatedEventData, msg } = await setup() 66 | 67 | await listener.onMessage(orderCreatedEventData, msg) 68 | 69 | expect(natsWrapper.client.publish).toHaveBeenCalled() 70 | 71 | // get the data thats got published 72 | const ticketUpdatedEventData = JSON.parse((natsWrapper.client.publish as jest.Mock).mock.calls[0][1]) 73 | 74 | expect(orderCreatedEventData.id).toEqual(ticketUpdatedEventData.orderId) 75 | 76 | }) 77 | -------------------------------------------------------------------------------- /tickets/src/events/listeners/order-cancelled-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCancelledEvent, Subjects } from '@wwticketing/common' 2 | import { Message } from 'node-nats-streaming'; 3 | import { queueGroupName } from './queue-group-name' 4 | import { Ticket } from '../../models/ticket' 5 | import { TicketUpdatedPublisher } from '../publishers/ticket-updated-publisher'; 6 | 7 | export class OrderCancelledListener extends Listener { 8 | readonly subject = Subjects.OrderCancelled; 9 | readonly queueGroupName: string = queueGroupName; 10 | 11 | async onMessage(data: OrderCancelledEvent['data'], msg: Message) { 12 | const ticket = await Ticket.findById(data.ticket.id) 13 | 14 | if (!ticket) { 15 | throw new Error('Ticket not found!') 16 | } 17 | 18 | ticket.set({ orderId: undefined }) 19 | await ticket.save() 20 | 21 | // publish a ticket updated event 22 | await new TicketUpdatedPublisher(this.client).publish({ 23 | id: ticket.id, 24 | version: ticket.version, 25 | title: ticket.title, 26 | price: ticket.price, 27 | userId: ticket.userId, 28 | orderId: ticket.orderId, 29 | }) 30 | 31 | msg.ack() 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /tickets/src/events/listeners/order-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCreatedEvent, Subjects } from '@wwticketing/common' 2 | import { Message } from 'node-nats-streaming'; 3 | import { queueGroupName } from './queue-group-name' 4 | import { Ticket } from '../../models/ticket' 5 | import { TicketUpdatedPublisher } from '../publishers/ticket-updated-publisher' 6 | 7 | export class OrderCreatedListener extends Listener { 8 | readonly subject = Subjects.OrderCreated; 9 | readonly queueGroupName: string = queueGroupName 10 | 11 | async onMessage(data: OrderCreatedEvent['data'], msg: Message) { 12 | // Find the ticket that the order is reserving 13 | const ticket = await Ticket.findById(data.ticket.id) 14 | 15 | // If no tickets, throw error 16 | if (!ticket) { 17 | throw new Error('Ticket not found') 18 | } 19 | 20 | // Mark the ticket as being reserved by setting its orderId property 21 | // - update orderId property on the ticket once we receive an order created/updated event 22 | ticket.set({ orderId: data.id }) 23 | 24 | // Save the ticket 25 | await ticket.save() 26 | 27 | // pubish ticket updated event 28 | new TicketUpdatedPublisher(this.client).publish({ 29 | id: ticket.id, 30 | version: ticket.version, 31 | title: ticket.title, 32 | price: ticket.price, 33 | userId: ticket.userId, 34 | orderId: ticket.orderId, 35 | }) 36 | 37 | // ack the message 38 | msg.ack() 39 | 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /tickets/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGroupName = "tickets-service" -------------------------------------------------------------------------------- /tickets/src/events/publishers/ticket-created-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Publisher, Subjects, TicketCreatedEvent } from "@wwticketing/common"; 2 | 3 | export class TicketCreatedPublisher extends Publisher { 4 | readonly subject = Subjects.TicketCreated; 5 | } 6 | -------------------------------------------------------------------------------- /tickets/src/events/publishers/ticket-updated-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Publisher, Subjects, TicketUpdatedEvent } from "@wwticketing/common"; 2 | 3 | export class TicketUpdatedPublisher extends Publisher { 4 | readonly subject = Subjects.TicketUpdated; 5 | } 6 | -------------------------------------------------------------------------------- /tickets/src/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { app } from "./app"; 3 | import { natsWrapper } from "./nats-wrapper"; 4 | import { OrderCreatedListener } from './events/listeners/order-created-listener' 5 | import { OrderCancelledListener } from './events/listeners/order-cancelled-listener' 6 | 7 | const start = async () => { 8 | // First check if JWT_KEY is defined 9 | if (!process.env.JWT_KEY) { 10 | throw new Error("JWT_KEY must be defined"); 11 | } 12 | if (!process.env.MONGO_URI) { 13 | throw new Error("MONGO_URI must be defined"); 14 | } 15 | if (!process.env.NATS_URL) { 16 | throw new Error("NATS_URL must be defined"); 17 | } 18 | if (!process.env.NATS_CLUSTER_ID) { 19 | throw new Error("NATS_CLUSTER_ID must be defined"); 20 | } 21 | if (!process.env.NATS_CLIENT_ID) { 22 | throw new Error("NATS_CLIENT_ID must be defined"); 23 | } 24 | 25 | try { 26 | await natsWrapper.connect( 27 | process.env.NATS_CLUSTER_ID, 28 | process.env.NATS_CLIENT_ID, 29 | process.env.NATS_URL 30 | ); 31 | 32 | // Gracefully shut down NATS connection 33 | natsWrapper.client.on("close", () => { 34 | console.log("NATS connection closed!"); 35 | process.exit(); 36 | }); 37 | process.on("SIGINT", () => natsWrapper.client.close()); 38 | process.on("SIGTERM", () => natsWrapper.client.close()); 39 | 40 | // let two listeners start to listen for events 41 | new OrderCreatedListener(natsWrapper.client).listen() 42 | new OrderCancelledListener(natsWrapper.client).listen() 43 | 44 | await mongoose.connect(process.env.MONGO_URI, { 45 | useNewUrlParser: true, 46 | useUnifiedTopology: true, 47 | useCreateIndex: true, 48 | }); 49 | console.log("Connected to MongoDB"); 50 | } catch (err) { 51 | console.log(err); 52 | } 53 | 54 | app.listen(3000, () => { 55 | console.log("Tickets Service listening on port 3000"); 56 | }); 57 | }; 58 | 59 | start(); 60 | -------------------------------------------------------------------------------- /tickets/src/models/__test__/ticket.test.ts: -------------------------------------------------------------------------------- 1 | import { Ticket } from "../ticket"; 2 | 3 | it("implements optimistic concurrency control", async (done) => { 4 | // Create an instance of a ticket 5 | const ticket = Ticket.build({ 6 | title: "concert", 7 | price: 5, 8 | userId: "123", 9 | }); 10 | 11 | // Save the ticket to the database 12 | await ticket.save(); 13 | 14 | // Fetch the ticket twice 15 | const firstInstance = await Ticket.findById(ticket.id); 16 | const secondInstance = await Ticket.findById(ticket.id); 17 | 18 | // Make two separate changes to the tickets we fetched 19 | firstInstance!.set({ price: 10 }); 20 | secondInstance!.set({ price: 15 }); 21 | 22 | // Save the first fetched ticket 23 | await firstInstance!.save(); 24 | 25 | // Save the second fetched ticket and expect an error 26 | try { 27 | await secondInstance!.save(); 28 | } catch (err) { 29 | return done(); 30 | } 31 | 32 | throw new Error("Should not reach this point"); 33 | }); 34 | 35 | it("increments the version number on multiple saves", async () => { 36 | const ticket = Ticket.build({ 37 | title: "concert", 38 | price: 20, 39 | userId: "123", 40 | }); 41 | 42 | await ticket.save(); 43 | expect(ticket.version).toEqual(0); 44 | 45 | await ticket.save(); 46 | expect(ticket.version).toEqual(1); 47 | 48 | await ticket.save(); 49 | expect(ticket.version).toEqual(2); 50 | }); 51 | -------------------------------------------------------------------------------- /tickets/src/models/ticket.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { updateIfCurrentPlugin } from "mongoose-update-if-current"; 3 | 4 | interface TicketAttrs { 5 | title: string; 6 | price: number; 7 | userId: string; 8 | } 9 | 10 | interface TicketDoc extends mongoose.Document { 11 | title: string; 12 | price: number; 13 | userId: string; 14 | version: number; 15 | orderId?: string; 16 | } 17 | 18 | interface TicketModel extends mongoose.Model { 19 | build(attrs: TicketAttrs): TicketDoc; 20 | } 21 | 22 | const ticketSchema = new mongoose.Schema( 23 | { 24 | title: { 25 | type: String, // String type is used by mongoose, not typescript and hence the captical S 26 | required: true, 27 | }, 28 | price: { 29 | type: Number, 30 | required: true, 31 | }, 32 | userId: { 33 | type: String, 34 | required: true, 35 | }, 36 | orderId: { 37 | type: String, 38 | } 39 | }, 40 | { 41 | toJSON: { 42 | transform(doc, ret) { 43 | // modify ret: returned json object directly 44 | // convert id => _id (normalize id property as other databases use _id) 45 | ret.id = ret._id; 46 | delete ret._id; 47 | }, 48 | }, 49 | } 50 | ); 51 | 52 | ticketSchema.set("versionKey", "version"); 53 | ticketSchema.plugin(updateIfCurrentPlugin); 54 | 55 | // The only reason we create this static build method is to let Typescript to check the 56 | // attribute type (attrs) 57 | ticketSchema.statics.build = (attrs: TicketAttrs) => { 58 | return new Ticket(attrs); 59 | }; 60 | 61 | const Ticket: TicketModel = mongoose.model( 62 | "Ticket", 63 | ticketSchema 64 | ); 65 | 66 | export { Ticket }; 67 | -------------------------------------------------------------------------------- /tickets/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from "node-nats-streaming"; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | get client() { 7 | if (!this._client) { 8 | throw new Error("Cannot access NATS client before connecting"); 9 | } 10 | 11 | return this._client; 12 | } 13 | 14 | connect(clusterId: string, clientId: string, url: string) { 15 | this._client = nats.connect(clusterId, clientId, { url }); 16 | 17 | return new Promise((resolve, reject) => { 18 | this.client.on("connect", () => { 19 | console.log("Connected to NATS"); 20 | resolve(); 21 | }); 22 | this.client.on("error", (err) => { 23 | reject(err); 24 | }); 25 | }); 26 | } 27 | } 28 | 29 | export const natsWrapper = new NatsWrapper(); 30 | -------------------------------------------------------------------------------- /tickets/src/routes/__test__/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | 4 | const createTicket = () => { 5 | return request(app) 6 | .post("/api/tickets") 7 | .set("Cookie", global.signin_and_get_cookie()) 8 | .send({ title: "test_title", price: 100 }); 9 | }; 10 | 11 | it("can fetch a list of tickets", async () => { 12 | await createTicket(); 13 | await createTicket(); 14 | await createTicket(); 15 | 16 | const response = await request(app).get("/api/tickets").send().expect(200); 17 | 18 | expect(response.body.length).toEqual(3); 19 | }); 20 | -------------------------------------------------------------------------------- /tickets/src/routes/__test__/new.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | import { Ticket } from "../../models/ticket"; 4 | import { natsWrapper } from "../../nats-wrapper"; 5 | 6 | it("has a route handler listening to /api/tickets for post request", async () => { 7 | const response = await request(app).post("/api/tickets").send({}); 8 | 9 | expect(response.status).not.toEqual(404); 10 | }); 11 | 12 | it("can only be accessed if user is signed in", async () => { 13 | const response = await request(app).post("/api/tickets").send({}); 14 | 15 | expect(response.status).toEqual(401); 16 | }); 17 | 18 | it("returns a status other than 401 if the user is signed in", async () => { 19 | const response = await request(app) 20 | .post("/api/tickets") 21 | .set("Cookie", global.signin_and_get_cookie()) // create a cookie to mock sign in 22 | .send({}); 23 | 24 | expect(response.status).not.toEqual(401); 25 | }); 26 | 27 | it("returns an error if an invalid title is provided", async () => { 28 | // case #1: title is invalid 29 | await request(app) 30 | .post("/api/tickets") 31 | .set("Cookie", global.signin_and_get_cookie()) 32 | .send({ 33 | title: "", 34 | price: 10, 35 | }) 36 | .expect(400); 37 | 38 | // case #2: no title at all 39 | await request(app) 40 | .post("/api/tickets") 41 | .set("Cookie", global.signin_and_get_cookie()) 42 | .send({ 43 | price: 10, 44 | }) 45 | .expect(400); 46 | }); 47 | 48 | it("returns an error if an invalid price is provided", async () => { 49 | // case #1: price is invalid 50 | await request(app) 51 | .post("/api/tickets") 52 | .set("Cookie", global.signin_and_get_cookie()) 53 | .send({ 54 | title: "Valid Title", 55 | price: -10, 56 | }) 57 | .expect(400); 58 | 59 | // case #2: no price at all 60 | await request(app) 61 | .post("/api/tickets") 62 | .set("Cookie", global.signin_and_get_cookie()) 63 | .send({ 64 | title: "Valid Title", 65 | }) 66 | .expect(400); 67 | }); 68 | 69 | it("creates a ticket with valid inputs", async () => { 70 | let tickets = await Ticket.find({}); 71 | 72 | // initially, there wont be any data in the mock mongodb 73 | expect(tickets.length).toEqual(0); 74 | 75 | const TITLE = "test_ticket_title"; 76 | 77 | await request(app) 78 | .post("/api/tickets") 79 | .set("Cookie", global.signin_and_get_cookie()) 80 | .send({ title: TITLE, price: 20 }) 81 | .expect(201); // expect 201 CREATED 82 | 83 | tickets = await Ticket.find({}); 84 | expect(tickets.length).toEqual(1); 85 | expect(tickets[0].price).toEqual(20); 86 | expect(tickets[0].title).toEqual(TITLE); 87 | }); 88 | 89 | it("publishes an event", async () => { 90 | // create a ticket 91 | const TITLE = "test_ticket_title"; 92 | 93 | await request(app) 94 | .post("/api/tickets") 95 | .set("Cookie", global.signin_and_get_cookie()) 96 | .send({ title: TITLE, price: 20 }) 97 | .expect(201); // expect 201 CREATED 98 | 99 | // check that if the publish() function has been called (an event has been sent) 100 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 101 | }); 102 | -------------------------------------------------------------------------------- /tickets/src/routes/__test__/show.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | import mongoose from "mongoose"; 4 | 5 | it("returns a 404 if the ticket is not found", async () => { 6 | const ticketId = new mongoose.Types.ObjectId().toHexString(); 7 | 8 | await request(app).get(`/api/tickets/${ticketId}`).send().expect(404); 9 | }); 10 | 11 | it("returns the ticket if the ticket is found", async () => { 12 | const title = "concert"; 13 | const price = 50; 14 | 15 | // sign in and create a ticket first 16 | const response = await request(app) 17 | .post("/api/tickets") 18 | .set("Cookie", global.signin_and_get_cookie()) 19 | .send({ title, price }) 20 | .expect(201); 21 | 22 | // then test if the ticket info can be found 23 | const ticketResponse = await request(app) 24 | .get(`/api/tickets/${response.body.id}`) 25 | .send() 26 | .expect(200); 27 | 28 | expect(ticketResponse.body.title).toEqual(title); 29 | expect(ticketResponse.body.price).toEqual(price); 30 | }); 31 | -------------------------------------------------------------------------------- /tickets/src/routes/__test__/update.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { app } from "../../app"; 3 | import mongoose from "mongoose"; 4 | import { Ticket } from '../../models/ticket' 5 | import { natsWrapper } from "../../nats-wrapper"; 6 | 7 | it("returns a 404 if the provided id does no exist", async () => { 8 | const ticketId = new mongoose.Types.ObjectId().toHexString(); 9 | 10 | await request(app) 11 | .put(`/api/tickets/${ticketId}`) 12 | .set("Cookie", global.signin_and_get_cookie()) 13 | .send({ title: "updated_title", price: 20 }) 14 | .expect(404); 15 | }); 16 | 17 | it("returns a 401 if the user is not authenticated", async () => { 18 | const ticketId = new mongoose.Types.ObjectId().toHexString(); 19 | 20 | await request(app) 21 | .put(`/api/tickets/${ticketId}`) 22 | .send({ title: "updated_title", price: 20 }) 23 | .expect(401); 24 | }); 25 | 26 | it("returns a 401 if the user does not own the ticket", async () => { 27 | // everytime signin_and_get_cookie() is called, a new user id is generated 28 | const response = await request(app) 29 | .post("/api/tickets") 30 | .set("Cookie", global.signin_and_get_cookie()) 31 | .send({ title: "some_title", price: 100 }); 32 | 33 | await request(app) 34 | .put(`/api/tickets/${response.body.id}`) 35 | .set("Cookie", global.signin_and_get_cookie()) 36 | .send({ title: "update_title", price: 80 }) 37 | .expect(401); 38 | }); 39 | 40 | it("returns a 400 if the user provides an invalid title or price", async () => { 41 | const cookie: string[] = global.signin_and_get_cookie(); 42 | 43 | const response = await request(app) 44 | .post("/api/tickets") 45 | .set("Cookie", cookie) 46 | .send({ title: "some_title", price: 100 }) 47 | .expect(201); 48 | 49 | // use the same cookie => same user 50 | await request(app) 51 | .put(`/api/tickets/${response.body.id}`) 52 | .set("Cookie", cookie) 53 | .send({ title: "", price: 80 }) 54 | .expect(400); 55 | 56 | await request(app) 57 | .put(`/api/tickets/${response.body.id}`) 58 | .set("Cookie", cookie) 59 | .send({ title: "valid_title", price: -100 }) 60 | .expect(400); 61 | 62 | await request(app) 63 | .put(`/api/tickets/${response.body.id}`) 64 | .set("Cookie", cookie) 65 | .send({ price: 80 }) 66 | .expect(400); 67 | 68 | await request(app) 69 | .put(`/api/tickets/${response.body.id}`) 70 | .set("Cookie", cookie) 71 | .send({}) 72 | .expect(400); 73 | }); 74 | 75 | it("udpates the ticket provided valid inputs", async () => { 76 | const cookie: string[] = global.signin_and_get_cookie(); 77 | 78 | // user creates a new ticket 79 | const response = await request(app) 80 | .post("/api/tickets") 81 | .set("Cookie", cookie) 82 | .send({ title: "some_title", price: 100 }) 83 | .expect(201); //CREATED 84 | 85 | // update the ticket by the same user 86 | await request(app) 87 | .put(`/api/tickets/${response.body.id}`) 88 | .set("Cookie", cookie) 89 | .send({ title: "updated_title", price: 80 }) 90 | .expect(200); //UPDATE SUCCESSFULLY 91 | 92 | const ticketResponse = await request(app) 93 | .get(`/api/tickets/${response.body.id}`) 94 | .send() 95 | .expect(200); 96 | 97 | expect(ticketResponse.body.title).toEqual("updated_title"); 98 | expect(ticketResponse.body.price).toEqual(80); 99 | }); 100 | 101 | it("publishes an event", async () => { 102 | const cookie: string[] = global.signin_and_get_cookie(); 103 | 104 | // user creates a new ticket 105 | const response = await request(app) 106 | .post("/api/tickets") 107 | .set("Cookie", cookie) 108 | .send({ title: "some_title", price: 100 }) 109 | .expect(201); //CREATED 110 | 111 | // update the ticket by the same user 112 | await request(app) 113 | .put(`/api/tickets/${response.body.id}`) 114 | .set("Cookie", cookie) 115 | .send({ title: "updated_title", price: 80 }) 116 | .expect(200); //UPDATE SUCCESSFULLY 117 | 118 | // test if an event has been published 119 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 120 | }); 121 | 122 | 123 | it('rejects updates if the ticket is reserved', async () => { 124 | const cookie: string[] = global.signin_and_get_cookie(); 125 | 126 | // user creates a new ticket 127 | const response = await request(app) 128 | .post("/api/tickets") 129 | .set("Cookie", cookie) 130 | .send({ title: "some_title", price: 100 }) 131 | .expect(201); //CREATED 132 | 133 | const ticket = await Ticket.findById(response.body.id) 134 | // mock ticket reservation: add orderId property to ticket document 135 | ticket!.set({ orderId: mongoose.Types.ObjectId().toHexString() }) 136 | await ticket!.save() 137 | 138 | // try to update the reserved ticket 139 | await request(app) 140 | .put(`/api/tickets/${response.body.id}`) 141 | .set("Cookie", cookie) 142 | .send({ title: "updated_title", price: 80 }) 143 | .expect(400); // BAD REQUEST : ticket already reserved 144 | }) -------------------------------------------------------------------------------- /tickets/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { Ticket } from "../models/ticket"; 3 | 4 | const router = express.Router(); 5 | 6 | router.get("/api/tickets", async (req: Request, res: Response) => { 7 | const tickets = await Ticket.find({ 8 | orderId: undefined, 9 | }); 10 | 11 | res.send(tickets); 12 | }); 13 | 14 | export { router as indexTicketRouter }; 15 | -------------------------------------------------------------------------------- /tickets/src/routes/new.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { 3 | requireAuthMiddleware, 4 | validateRequestMiddleware, 5 | } from "@wwticketing/common"; 6 | import { body } from "express-validator"; 7 | import { Ticket } from "../models/ticket"; 8 | import { TicketCreatedPublisher } from "../events/publishers/ticket-created-publisher"; 9 | import { natsWrapper } from "../nats-wrapper"; 10 | 11 | const router = express.Router(); 12 | 13 | router.post( 14 | "/api/tickets", 15 | requireAuthMiddleware, 16 | [ 17 | body("title").not().isEmpty().withMessage("Title is required"), 18 | body("price") 19 | .isFloat({ gt: 0 }) 20 | .withMessage("Price must be greater than 0"), 21 | ], 22 | validateRequestMiddleware, 23 | async (req: Request, res: Response) => { 24 | const { title, price } = req.body; 25 | 26 | const ticket = Ticket.build({ 27 | title, 28 | price, 29 | userId: req.currentUser!.id, 30 | }); 31 | 32 | // save ticket to database 33 | await ticket.save(); 34 | 35 | // publish ticket create event/message to NATS Streaming server 36 | await new TicketCreatedPublisher(natsWrapper.client).publish({ 37 | id: ticket.id, 38 | version: ticket.version, 39 | title: ticket.title, 40 | price: ticket.price, 41 | userId: ticket.userId, 42 | }); 43 | 44 | res.status(201).send(ticket); 45 | } 46 | ); 47 | 48 | export { router as createTicketRouter }; 49 | -------------------------------------------------------------------------------- /tickets/src/routes/show.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { Ticket } from "../models/ticket"; 3 | import { NotFoundError } from "@wwticketing/common"; 4 | 5 | const router = express.Router(); 6 | 7 | router.get("/api/tickets/:id", async (req: Request, res: Response) => { 8 | const ticket = await Ticket.findById(req.params.id); 9 | 10 | if (!ticket) { 11 | throw new NotFoundError(); 12 | } 13 | 14 | res.send(ticket); 15 | }); 16 | 17 | export { router as showTicketRouter }; 18 | -------------------------------------------------------------------------------- /tickets/src/routes/update.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { body } from "express-validator"; 3 | import { 4 | validateRequestMiddleware, 5 | requireAuthMiddleware, 6 | NotFoundError, 7 | NotAuthorizedError, 8 | BadRequestError, 9 | } from "@wwticketing/common"; 10 | import { Ticket } from "../models/ticket"; 11 | import { TicketUpdatedPublisher } from "../events/publishers/ticket-updated-publisher"; 12 | import { natsWrapper } from "../nats-wrapper"; 13 | 14 | const router = express.Router(); 15 | 16 | router.put( 17 | "/api/tickets/:id", 18 | requireAuthMiddleware, 19 | [ 20 | body("title").not().isEmpty().withMessage("Title is required"), 21 | body("price") 22 | .isFloat({ gt: 0 }) 23 | .withMessage("Price must be provided and must be greater than 0"), 24 | ], 25 | validateRequestMiddleware, 26 | async (req: Request, res: Response) => { 27 | const ticket = await Ticket.findById(req.params.id); 28 | 29 | if (!ticket) { 30 | throw new NotFoundError(); 31 | } 32 | 33 | // if the ticket is locked (has orderId property) 34 | if (ticket.orderId) { 35 | throw new BadRequestError('Cannot edit a reserved ticket') 36 | } 37 | 38 | // to update the ticket, the user must be the author of the ticket 39 | if (ticket.userId !== req.currentUser!.id) { 40 | throw new NotAuthorizedError(); 41 | } 42 | 43 | ticket.set({ 44 | title: req.body.title, 45 | price: req.body.price, 46 | }); 47 | 48 | await ticket.save(); 49 | 50 | // publish ticket updated event/message to NATS Streaming server 51 | new TicketUpdatedPublisher(natsWrapper.client).publish({ 52 | id: ticket.id, 53 | version: ticket.version, 54 | title: ticket.title, 55 | price: ticket.price, 56 | userId: ticket.userId, 57 | }); 58 | 59 | res.send(ticket); 60 | } 61 | ); 62 | 63 | export { router as updateTicketRouter }; 64 | -------------------------------------------------------------------------------- /tickets/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from "mongodb-memory-server"; 2 | import mongoose from "mongoose"; 3 | import jwt from "jsonwebtoken"; 4 | 5 | declare global { 6 | namespace NodeJS { 7 | interface Global { 8 | signin_and_get_cookie(): string[]; 9 | } 10 | } 11 | } 12 | 13 | // use jest to mock the nats-wrapper 14 | // jest will automatically use the fake nats-wrapper inside __mock__ folder 15 | jest.mock("../nats-wrapper"); 16 | 17 | // declare in-memory mock mongo server 18 | let mongo: any; 19 | 20 | beforeAll(async () => { 21 | process.env.JWT_KEY = "test_jwt_key"; 22 | mongo = new MongoMemoryServer(); 23 | const mongoUri = await mongo.getUri(); 24 | 25 | await mongoose.connect(mongoUri, { 26 | useNewUrlParser: true, 27 | useUnifiedTopology: true, 28 | }); 29 | }); 30 | 31 | beforeEach(async () => { 32 | jest.clearAllMocks(); 33 | // get all existing collections in mongodb 34 | const collections = await mongoose.connection.db.collections(); 35 | 36 | for (let collection of collections) { 37 | await collection.deleteMany({}); 38 | } 39 | }); 40 | 41 | afterAll(async () => { 42 | await mongo.stop(); 43 | await mongoose.connection.close(); 44 | }); 45 | 46 | // global function only for test environment 47 | // signup and return the cookie from the response 48 | global.signin_and_get_cookie = () => { 49 | // Build a JWT payload. {id, email} 50 | const payload = { 51 | id: new mongoose.Types.ObjectId().toHexString(), 52 | email: "test@test.com", 53 | }; 54 | 55 | // Create the JWT 56 | const token = jwt.sign(payload, process.env.JWT_KEY!); 57 | 58 | // Build session object. {jwt: MY_JWT} 59 | const session = { jwt: token }; 60 | 61 | // Turn that session into JSON 62 | const sessionJSON = JSON.stringify(session); 63 | 64 | // Take that JSON and encode it as base64 65 | const base64 = Buffer.from(sessionJSON).toString("base64"); 66 | 67 | // Return a string thats the cookie with the encoded data 68 | const cookie_base64 = `express:sess=${base64}`; 69 | 70 | return [cookie_base64]; // a little gottcha: put the cookie string inside an array to make supertest lib happy :) 71 | }; 72 | -------------------------------------------------------------------------------- /tickets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | --------------------------------------------------------------------------------