├── .DS_Store ├── .github └── workflows │ ├── test-payments.yml │ ├── test-tickets.yml │ ├── tests-auth.yml │ └── tests-orders.yml ├── .gitignore ├── Readme.md ├── auth ├── .dockerignore ├── .gitignore ├── 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-users.ts │ │ ├── signin.ts │ │ ├── signout.ts │ │ └── singup.ts │ ├── services │ │ └── password.ts │ └── test │ │ └── setup.ts ├── tsconfig.json └── yarn.lock ├── client ├── .dockerignore ├── .gitignore ├── Dockerfile ├── api │ └── build-client.js ├── components │ └── header.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 ├── collection-postman └── Ticketing.postman_collection.json ├── expiration ├── .dockerignore ├── .gitignore ├── 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 │ ├── auth-depl.yml │ ├── auth-mongo-depl.yml │ ├── client-depl.yaml │ ├── expiration-depl.yml │ ├── expiration-redis-depl.yml │ ├── ingress-srv.yaml │ ├── nats-depl.yaml │ ├── orders-depl.yml │ ├── orders-mongo-depl.yml │ ├── payments-depl.yml │ ├── payments-mongo-depl.yml │ ├── tickets-depl.yml │ └── tickets-mongo-depl.yaml ├── nats-test ├── .gitignore ├── package-lock.json ├── package.json ├── src │ ├── events │ │ ├── abstract │ │ │ ├── base-listener.ts │ │ │ └── base-publisher.ts │ │ ├── enums │ │ │ └── Subjects.ts │ │ ├── ticket-created-events.ts │ │ ├── ticket-created-listener.ts │ │ └── ticket-created-publisher.ts │ ├── listener.ts │ └── publisher.ts └── tsconfig.json ├── orders ├── .dockerignore ├── .gitignore ├── 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-publisher.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 │ │ ├── news.ts │ │ └── show.ts │ └── test │ │ └── setup.ts └── tsconfig.json ├── package-lock.json ├── payments ├── .dockerignore ├── .gitignore ├── Dockerfile ├── package-lock.json ├── package.json ├── src │ ├── __mocks__ │ │ ├── nats-wrapper.ts │ │ └── stripe.ts.old │ ├── 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 ├── .gitignore ├── 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 ├── model │ ├── __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/VictorHugoAguilar/ticketing-microservices-node-nextjs-react/ea16ec8981a58055d9a515b4ae0351a776e9fefb/.DS_Store -------------------------------------------------------------------------------- /.github/workflows/test-payments.yml: -------------------------------------------------------------------------------- 1 | name: tests-payments 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - run: cd payments && npm install && npm run test:ci 11 | -------------------------------------------------------------------------------- /.github/workflows/test-tickets.yml: -------------------------------------------------------------------------------- 1 | name: tests-tickets 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - run: cd tickets && npm install && npm run test:ci 11 | -------------------------------------------------------------------------------- /.github/workflows/tests-auth.yml: -------------------------------------------------------------------------------- 1 | name: tests-auth 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - run: cd auth && npm install && npm run test:ci 11 | -------------------------------------------------------------------------------- /.github/workflows/tests-orders.yml: -------------------------------------------------------------------------------- 1 | name: tests-orders 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - run: cd orders && npm install && npm run test:ci 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # file: ~/.gitignore_global 2 | node_modules 3 | .DS_Store 4 | .idea -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## Docker Build Image of service in first place for all services created 2 | 3 | ```` 4 | docker build -t {user}/auth . 5 | ```` 6 | 7 | ## Next to building image it publish in docker 8 | 9 | ````` 10 | docker push {user}/auth 11 | ````` 12 | 13 | 14 | ## Setup nginx service for local dev 15 | 16 | Set-up local variable 17 | In Mac 18 | ````` 19 | /etc/host 20 | add: 21 | 127.0.0.1 ticketing.dev 22 | ````` 23 | 24 | ## For develop set-up skaffold 25 | 26 | this allows to raise all the images that we have configured in our infra 27 | 28 | ````` 29 | skaffold dev 30 | ````` 31 | 32 | ## For enviroment development, create a secret JWT in kubernete with command 33 | 34 | ````` 35 | kubectl create secret generic jwt-secret --from-literal=JWT_KEY=asdf 36 | 37 | kubectl get secrets 38 | ````` 39 | 40 | ## Problem resolution 41 | 42 | - if it does not let you open in the browser due to certificate problems 43 | - [ ] write thisisunsafe in navigator 44 | 45 | - In the upcoming lecture, we will be adding the ingress-nginx service name and namespace to our axios request. 46 | - [ ] kubectl get services -n ingress-nginx 47 | 48 | - Specifying the host 49 | - [ ] kubectl get namespace 50 | - [ ] kubectl get service -n ingress-nginx 51 | - [ ] add in axios the route 'http://ingress-nginx.ingress-nginx.svc.cluster.local' 52 | 53 | 1. The url should have the format `http://..svc.cluster.local` 54 | 55 | 2. The way to check existing namespaces is `kubectl get namespace` 56 | 57 | 3. The way to check servicenames inside a namespace is `kubectl get services -n ` 58 | 59 | - if we want to start a service in a different port without having to configure anything we can do it with the option 60 | 61 | ````` 62 | kubectl port-forward 4222:4222 63 | ````` 64 | 65 | - if restart of pods, only delete 66 | ````` 67 | kubectl get pods 68 | 69 | -nats-depl-84d875cf5f-bzxd4 70 | 71 | kubectl delete pod nats-depl-84d875cf5f-bzxd4 72 | ````` 73 | 74 | -------------------------------------------------------------------------------- /auth/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /auth/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /auth/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # --only=prod so you don't create dev dependencies 6 | RUN npm install --omit=dev 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev --poll src/index.ts", 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": "victor hugo aguilar", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@black_sheep/common": "^1.0.5", 23 | "@types/cookie-session": "^2.0.44", 24 | "@types/express": "^4.17.13", 25 | "@types/jsonwebtoken": "^8.5.8", 26 | "@types/mongoose": "^5.11.97", 27 | "@types/node": "^18.0.0", 28 | "cookie-session": "^2.0.0", 29 | "express": "^4.18.1", 30 | "express-async-errors": "^3.1.1", 31 | "express-validator": "^6.14.2", 32 | "jsonwebtoken": "^8.5.1", 33 | "mongoose": "^6.4.0", 34 | "ts-node-dev": "^2.0.0", 35 | "typescript": "^4.7.4" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^28.1.3", 39 | "@types/supertest": "^2.0.12", 40 | "jest": "^28.1.2", 41 | "mongodb-memory-server": "^8.7.1", 42 | "supertest": "^6.2.3", 43 | "ts-jest": "^28.0.5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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 | 6 | import { currentUserRouter } from './routes/current-users'; 7 | import { signinRouter } from './routes/signin'; 8 | import { signoutRouter } from './routes/signout'; 9 | import { signupRouter } from './routes/singup'; 10 | import { NotFoundError, errorHandler } from '@black_sheep/common'; 11 | 12 | const app = express(); 13 | app.set('trust proxy', true); 14 | app.use(json()); 15 | app.use(cookieSession({ 16 | signed: false, 17 | secure: process.env.NODE_ENV !== 'test' 18 | })); 19 | 20 | app.use(currentUserRouter); 21 | app.use(signinRouter); 22 | app.use(signupRouter); 23 | app.use(signoutRouter); 24 | 25 | app.all('*', async (req, res) => { 26 | throw new NotFoundError(); 27 | }) 28 | 29 | app.use(errorHandler); 30 | 31 | export default app; -------------------------------------------------------------------------------- /auth/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import mongoose from 'mongoose'; 3 | 4 | 5 | const start = async () => { 6 | console.log('Starting up...'); 7 | 8 | if (!process.env.JWT_KEY) { 9 | throw new Error('JWT_KEY not found, must be defined'); 10 | } 11 | 12 | if (!process.env.MONGO_URI) { 13 | throw new Error('MONGO_URI not found, must be defined'); 14 | } 15 | 16 | try { 17 | await mongoose.connect(process.env.MONGO_URI, { 18 | }); 19 | console.info('Connect to MongoDb'); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | } 24 | 25 | app.listen(3000, () => { 26 | console.log('Listen from port: ', 3000) 27 | }); 28 | 29 | start(); 30 | -------------------------------------------------------------------------------- /auth/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from "mongoose"; 2 | import { Password } from '../services/password'; 3 | 4 | // An interface that describes the properties that 5 | // are required to create new User 6 | interface UserAttrs { 7 | email: string; 8 | password: string; 9 | } 10 | // An interface that describe the properties that 11 | // a User Model has 12 | interface UserModel extends mongoose.Model { 13 | build(attrs: UserAttrs): UserDoc; 14 | } 15 | // An interface that describe tha propeties 16 | // that a User Document has 17 | interface UserDoc extends mongoose.Document { 18 | email: string; 19 | password: string; 20 | } 21 | 22 | const userSchema = new Schema({ 23 | email: { 24 | type: String, 25 | required: true, 26 | }, 27 | password: { 28 | type: String, 29 | required: true 30 | } 31 | }, { 32 | toJSON: { 33 | transform(doc, ret){ 34 | ret.id = ret._id; 35 | delete ret._id; 36 | delete ret.password; 37 | delete ret.__v; 38 | } 39 | } 40 | }); 41 | 42 | userSchema.pre('save', async function (done) { 43 | if (this.isModified('password')) { 44 | const hashed = await Password.toHash(this.get('password')); 45 | this.set('password', hashed); 46 | } 47 | }); 48 | 49 | userSchema.statics.build = (atrrs: UserAttrs) => { 50 | return new User(atrrs); 51 | }; 52 | 53 | const User = mongoose.model('User', userSchema); 54 | 55 | 56 | export { User }; -------------------------------------------------------------------------------- /auth/src/routes/__test_/current-user.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../../app'; 3 | 4 | it('response with details about the current user', async () => { 5 | const cookie = await global.signin(); 6 | 7 | const response = await request(app) 8 | .get('/api/users/currentuser') 9 | .set('Cookie', cookie) 10 | .send() 11 | .expect(200); 12 | 13 | const body = response.body; 14 | expect(body.currentUser.email).toEqual('test@test.com'); 15 | }); 16 | 17 | it('response with 401, not authorized if not authenticated', async () => { 18 | const response = await request(app) 19 | .get('/api/users/currentuser') 20 | .send() 21 | .expect(200); 22 | 23 | const body = response.body; 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 not existe 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 | 15 | it('fails when an incorrect password is supplied', async () => { 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 | await request(app) 25 | .post('/api/users/signin') 26 | .send({ 27 | email: 'test@test.com', 28 | password: 'password_error' 29 | }) 30 | .expect(400) 31 | }); 32 | 33 | it('fails when not existing user supplied', async () => { 34 | await request(app) 35 | .post('/api/users/signup') 36 | .send({ 37 | email: 'test@test.com', 38 | password: 'password' 39 | }) 40 | .expect(201) 41 | 42 | await request(app) 43 | .post('/api/users/signin') 44 | .send({ 45 | email: 'test_error@test.com', 46 | password: 'password' 47 | }) 48 | .expect(400) 49 | }); 50 | 51 | it('response with a cookie when given valid credentials', async () => { 52 | await request(app) 53 | .post('/api/users/signup') 54 | .send({ 55 | email: 'test@test.com', 56 | password: 'password' 57 | }) 58 | .expect(201) 59 | 60 | const response = await request(app) 61 | .post('/api/users/signin') 62 | .send({ 63 | email: 'test@test.com', 64 | password: 'password' 65 | }) 66 | .expect(201) 67 | 68 | expect(response.get('Set-Cookie')).toBeDefined(); 69 | }); -------------------------------------------------------------------------------- /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 | }) 17 | .expect(200) 18 | 19 | console.log(response.get('Set-Cookie')); 20 | expect(response.get('Set-Cookie')[0]).toEqual('session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; httponly') 21 | }); -------------------------------------------------------------------------------- /auth/src/routes/__test_/signup.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../../app'; 3 | 4 | it('returns a 201 on successfull signup', async () => { 5 | return request(app) 6 | .post('/api/users/signup') 7 | .send({ 8 | email: 'test@test.com', 9 | password: 'password' 10 | }) 11 | .expect(201) 12 | }); 13 | 14 | it('return a 400 with an invalid email', async () => { 15 | return request(app) 16 | .post('/api/users/signup') 17 | .send({ 18 | email: 'test', 19 | password: 'password' 20 | }) 21 | .expect(400) 22 | }); 23 | 24 | it('return a 400 with an invalid password', async () => { 25 | return request(app) 26 | .post('/api/users/signup') 27 | .send({ 28 | email: 'test', 29 | password: '1c' 30 | }) 31 | .expect(400) 32 | }); 33 | 34 | it('return a 400 with missing email and password', async () => { 35 | await request(app) 36 | .post('/api/users/signup') 37 | .send({ 38 | "email": "test@test.com" 39 | }) 40 | .expect(400) 41 | await request(app) 42 | .post('/api/users/signup') 43 | .send({ 44 | "password": "password" 45 | }) 46 | .expect(400) 47 | await request(app) 48 | .post('/api/users/signup') 49 | .send({}) 50 | .expect(400) 51 | }); 52 | 53 | it('disallows duplicate emails', async () => { 54 | await request(app) 55 | .post('/api/users/signup') 56 | .send({ 57 | email: 'test@test.com', 58 | password: 'password' 59 | }) 60 | .expect(201) 61 | 62 | await request(app) 63 | .post('/api/users/signup') 64 | .send({ 65 | email: 'test@test.com', 66 | password: 'password' 67 | }) 68 | .expect(400) 69 | }); 70 | 71 | it('sets a cookie after successful signup', async () => { 72 | const response = await request(app) 73 | .post('/api/users/signup') 74 | .send({ 75 | email: 'test@test.com', 76 | password: 'password' 77 | }) 78 | .expect(201) 79 | 80 | expect(response.get('Set-Cookie')).toBeDefined(); 81 | }); -------------------------------------------------------------------------------- /auth/src/routes/current-users.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | import { currentUser, requireAuth } from '@black_sheep/common'; 4 | 5 | const router = express.Router(); 6 | 7 | // middleware requireAuth 8 | router.get('/api/users/currentuser', currentUser, async (req: Request, res: Response) => { 9 | res.status(200).send({ currentUser: req.currentUser || null }); 10 | }); 11 | 12 | export { router as currentUserRouter }; -------------------------------------------------------------------------------- /auth/src/routes/signin.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { body } from 'express-validator'; 3 | import { BadRequestError, validateRequest } from '@black_sheep/common'; 4 | import { User } from '../models/user'; 5 | import { Password } from '../services/password'; 6 | import jwt from 'jsonwebtoken'; 7 | 8 | const router = express.Router(); 9 | 10 | router.post('/api/users/signin', [ 11 | body('email') 12 | .isEmail() 13 | .withMessage('Email must be valid'), 14 | body('password') 15 | .trim() 16 | .notEmpty() 17 | .withMessage('You must suppy a password') 18 | ], 19 | validateRequest 20 | , async (req: Request, res: Response) => { 21 | const { email, password } = req.body; 22 | 23 | const existingUser = await User.findOne({ email }); 24 | if (!existingUser) { 25 | throw new BadRequestError('Invalid credentials'); 26 | } 27 | 28 | const passwordMatch = await Password.compare(existingUser.password, password); 29 | if (!passwordMatch) { 30 | throw new BadRequestError('Invalid credentials'); 31 | } 32 | 33 | // generate JWT 34 | const userJwt = jwt.sign({ 35 | id: existingUser.id, 36 | email: existingUser.email 37 | }, 38 | process.env.JWT_KEY!); 39 | 40 | // Strore it on session object 41 | req.session = { 42 | jwt: userJwt 43 | }; 44 | 45 | res.status(201).send(existingUser); 46 | }); 47 | 48 | export { router as signinRouter }; -------------------------------------------------------------------------------- /auth/src/routes/signout.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | 3 | const router = express.Router(); 4 | 5 | router.post('/api/users/signout', (req: Request, res: Response) => { 6 | req.session = null; 7 | res.status(200).send({}); 8 | }); 9 | 10 | export { router as signoutRouter }; -------------------------------------------------------------------------------- /auth/src/routes/singup.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { body } from 'express-validator'; 3 | import { User } from '../models/user'; 4 | import { BadRequestError, validateRequest } from '@black_sheep/common'; 5 | import jwt from 'jsonwebtoken'; 6 | 7 | const router = express.Router(); 8 | 9 | router.post('/api/users/signup', [ 10 | body('email') 11 | .isEmail() 12 | .withMessage('Email must be valid'), 13 | body('password') 14 | .trim() 15 | .isLength({ min: 4, max: 20 }) 16 | .withMessage('Password must be between 4 and 20 characters') 17 | ], 18 | validateRequest, 19 | async (req: Request, res: Response) => { 20 | console.log('Creating a user ...'); 21 | const { email, password } = req.body; 22 | 23 | const existingUser = await User.findOne({ email }); 24 | if (existingUser) { 25 | // console.log('email in user'); 26 | // return res.send({}); 27 | throw new BadRequestError('email in user'); 28 | } 29 | 30 | const user = User.build({ email, password }); 31 | await user.save(); 32 | 33 | // generate JWT 34 | const userJwt = jwt.sign({ 35 | id: user.id, 36 | email: user.email 37 | }, 38 | process.env.JWT_KEY!); 39 | 40 | // Strore it on session object 41 | req.session = { 42 | jwt: userJwt 43 | }; 44 | 45 | res.status(201).send(user); 46 | }); 47 | 48 | export { router as signupRouter }; -------------------------------------------------------------------------------- /auth/src/services/password.ts: -------------------------------------------------------------------------------- 1 | import { scrypt, randomBytes } from 'crypto'; 2 | import { promisify } from 'util'; 3 | 4 | const scryptAsync = promisify(scrypt); 5 | 6 | export class Password { 7 | static async toHash(password: string) { 8 | const salt = randomBytes(8).toString('hex'); 9 | const buf = (await scryptAsync(password, salt, 64)) as Buffer; 10 | 11 | return `${buf.toString('hex')}.${salt}`; 12 | } 13 | 14 | static async compare(storedPassword: string, suppliedPassword: string) { 15 | const [hashedPassword, salt] = storedPassword.split('.'); 16 | const buf = (await scryptAsync(suppliedPassword, salt, 64)) as Buffer; 17 | return buf.toString('hex') === hashedPassword; 18 | } 19 | } -------------------------------------------------------------------------------- /auth/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server'; 2 | import request from 'supertest'; 3 | import mongoose from 'mongoose'; 4 | import app from '../app'; 5 | 6 | declare global { 7 | var signin: () => Promise; 8 | } 9 | 10 | let mongo: any; 11 | 12 | beforeAll(async () => { 13 | process.env.JWT_KEY = 'asdf'; 14 | 15 | mongo = await MongoMemoryServer.create(); 16 | const mongoUri = await mongo.getUri(); 17 | 18 | await mongoose.connect(mongoUri, {}); 19 | }); 20 | 21 | beforeEach(async () => { 22 | const collections = await mongoose.connection.db.collections(); 23 | for (let collection of collections) { 24 | await collection.deleteMany({}); 25 | } 26 | }); 27 | 28 | afterAll(async () => { 29 | await mongo.stop(); 30 | await mongoose.connection.close(); 31 | }); 32 | 33 | global.signin = async () => { 34 | const email = "test@test.com"; 35 | const password = "password"; 36 | 37 | const response = await request(app) 38 | .post('/api/users/signup') 39 | .send({ 40 | email, 41 | password 42 | }) 43 | .expect(201) 44 | 45 | const cookie = response.get('Set-Cookie'); 46 | 47 | return cookie; 48 | } 49 | 50 | 51 | -------------------------------------------------------------------------------- /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 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | *node_modules/* 2 | .next 3 | .swc -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | RUN npm install 6 | COPY . . 7 | 8 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /client/api/build-client.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default ({ req }) => { 4 | if (typeof window === 'undefined') { 5 | // We are on the server 6 | return axios.create({ 7 | baseURL: 8 | 'http://ingress-nginx-controller.ingress-nginx.svc.cluster.local', 9 | headers: req.headers, 10 | }); 11 | } else { 12 | // We must be on the browser 13 | return axios.create({ 14 | baseUrl: '/', 15 | }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /client/components/header.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export default ({ currentUser }) => { 4 | const links = [ 5 | !currentUser && { label: 'Sign Up', href: '/auth/signup' }, 6 | !currentUser && { label: 'Sign In', href: '/auth/signin' }, 7 | currentUser && { label: 'Sell Ticket', href: '/tickets/new' }, 8 | currentUser && { label: 'My Orders', href: '/orders' }, 9 | currentUser && { label: 'Sign Out', href: '/auth/signout' } 10 | ] 11 | .filter(linkConfig => linkConfig) 12 | .map(({ label, href }) => { 13 | return ( 14 |
  • 15 | 16 | {label} 17 | 18 |
  • 19 | ); 20 | }); 21 | 22 | return ( 23 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /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 | console.log(response.data) 12 | 13 | if (onSuccess) { 14 | onSuccess(response.data) 15 | } 16 | } catch (err) { 17 | console.log(err.response.data) 18 | setErrors( 19 |
    20 |
    Ooops...
    21 |
      22 | { 23 | err.response.data.errors ? 24 | err.response.data.errors.map(err => 25 |
    • {err.message}
    • 26 | ) 27 | : null 28 | } 29 |
    30 |
    31 | ) 32 | } 33 | }; 34 | return { doRequest, errors }; 35 | }; -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpackDevMiddleware: config => { 3 | config.watchOptions.poll = 300; 4 | return config; 5 | } 6 | }; -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "next", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@stripe/react-stripe-js": "^1.9.0", 15 | "@stripe/stripe-js": "^1.32.0", 16 | "axios": "^0.27.2", 17 | "bootstrap": "^5.1.3", 18 | "next": "^12.2.0", 19 | "prop-types": "^15.8.1", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-stripe-checkout": "^2.6.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/pages/_app.js: -------------------------------------------------------------------------------- 1 | import 'bootstrap/dist/css/bootstrap.css'; 2 | import buildClient from '../api/build-client'; 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 | const client = buildClient(appContext.ctx); 18 | const { data } = await client.get('/api/users/currentuser'); 19 | 20 | let pageProps = {}; 21 | if (appContext.Component.getInitialProps) { 22 | pageProps = await appContext.Component.getInitialProps( 23 | appContext.ctx, client, data.currentUser); 24 | } 25 | 26 | return { 27 | pageProps, 28 | ...data 29 | }; 30 | }; 31 | 32 | export default AppComponent; 33 | -------------------------------------------------------------------------------- /client/pages/auth/signin.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Router from 'next/router'; 3 | import useRequest from "../../hooks/use-request"; 4 | 5 | export default () => { 6 | const [email, setEmail] = useState(''); 7 | const [password, setPassword] = useState(''); 8 | 9 | const { doRequest, errors } = useRequest({ 10 | url: '/api/users/signin', 11 | method: 'post', 12 | body: { email, password }, 13 | onSuccess: () => Router.push('/') 14 | }); 15 | 16 | const onSubmit = async (e) => { 17 | e.preventDefault(); 18 | await doRequest(); 19 | }; 20 | 21 | return
    22 |

    Sign In

    23 |
    24 | 25 | setEmail(e.target.value)} 28 | className="form-control"> 29 |
    30 |
    31 | 32 | setPassword(e.target.value)} 35 | type="password" className="form-control"> 36 |
    37 | {errors} 38 | 39 |
    40 | }; -------------------------------------------------------------------------------- /client/pages/auth/signout.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import Router from 'next/router'; 3 | import useRequest from '../../hooks/use-request'; 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 Router from 'next/router'; 3 | import useRequest from "../../hooks/use-request"; 4 | 5 | export default () => { 6 | const [email, setEmail] = useState(''); 7 | const [password, setPassword] = useState(''); 8 | 9 | const { doRequest, errors } = useRequest({ 10 | url: '/api/users/signup', 11 | method: 'post', 12 | body: { email, password }, 13 | onSuccess: () => Router.push('/') 14 | }); 15 | 16 | const onSubmit = async (e) => { 17 | e.preventDefault(); 18 | await doRequest(); 19 | }; 20 | 21 | return
    22 |

    Sign Up

    23 |
    24 | 25 | setEmail(e.target.value)} 28 | className="form-control"> 29 |
    30 |
    31 | 32 | setPassword(e.target.value)} 35 | type="password" className="form-control"> 36 |
    37 | {errors} 38 | 39 |
    40 | }; -------------------------------------------------------------------------------- /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 | 30 | {ticketList} 31 | 32 |
    TitlePriceLink
    33 |
    34 | ); 35 | }; 36 | 37 | LandingPage.getInitialProps = async (context, client, currentUser) => { 38 | const { data } = await client.get('/api/tickets/'); 39 | console.log(data) 40 | return { tickets: data }; 41 | }; 42 | 43 | export default LandingPage; -------------------------------------------------------------------------------- /client/pages/orders/[orderId].js: -------------------------------------------------------------------------------- 1 | import Router from 'next/router'; 2 | import { useEffect, useState } from 'react'; 3 | import StripeCheckout from 'react-stripe-checkout'; 4 | import useRequest from '../../hooks/use-request'; 5 | 6 | const OrderShow = ({ order, currentUser }) => { 7 | const [timeLeft, setTimeLeft] = useState(''); 8 | const { doRequest, errors } = useRequest({ 9 | url: '/api/payments', 10 | method: 'post', 11 | body: { 12 | orderId: order.id, 13 | }, 14 | onSuccess: (payment) => Router.push('/orders'), 15 | }); 16 | 17 | useEffect(() => { 18 | const findTimeLeft = () => { 19 | const msLeft = new Date(order.expiresAt) - new Date(); 20 | setTimeLeft(Math.round(msLeft / 1000)); 21 | }; 22 | 23 | findTimeLeft(); 24 | const timerId = setInterval(findTimeLeft, 1000); 25 | 26 | return () => { 27 | clearInterval(timerId); 28 | }; 29 | }, [order]); 30 | 31 | if (timeLeft < 0) { 32 | return
    Order Expired
    ; 33 | } 34 | 35 | return ( 36 |
    Time left to pay: {timeLeft} seconds 37 | { 39 | console.log(id); 40 | doRequest({ token: id }); 41 | }} 42 | stripeKey="pk_test_51LJ750KJJaZxpvincmx7z3aLoPVfVpO0zTkxTRRSWJIcfMUNQS6F4mWXham6vwxYWUAjSyzDwagEXOzdnP5fpYIu00WtEBRX7m" 43 | amount={order.ticket.price * 100} 44 | email={currentUser.email} 45 | /> 46 | {errors} 47 |
    48 | ); 49 | }; 50 | 51 | OrderShow.getInitialProps = async (context, client) => { 52 | const { orderId } = context.query; 53 | const { data } = await client.get(`/api/orders/${orderId}`); 54 | 55 | return { order: data }; 56 | }; 57 | 58 | export default OrderShow; 59 | -------------------------------------------------------------------------------- /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 | return { orders: data }; 18 | }; 19 | 20 | export default OrderIndex; -------------------------------------------------------------------------------- /client/pages/tickets/[ticketId].js: -------------------------------------------------------------------------------- 1 | import Router from 'next/router'; 2 | import useRequest from '../../hooks/use-request'; 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 |

    Price: {ticket.price}

    19 | {errors} 20 | 23 |
    24 | ); 25 | }; 26 | 27 | TicketShow.getInitialProps = async (context, client) => { 28 | const { ticketId } = context.query; 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 [title, setTitle] = useState(''); 7 | const [price, setPrice] = useState(''); 8 | const { doRequest, errors } = useRequest({ 9 | url: '/api/tickets', 10 | method: 'post', 11 | body: { 12 | title, price 13 | }, 14 | onSuccess: (ticket) => { 15 | console.log(ticket); 16 | Router.push('/'); 17 | }, 18 | }); 19 | 20 | const onBlur = () => { 21 | const value = parseFloat(price); 22 | if (isNaN(value)) { 23 | return; 24 | } 25 | setPrice(value.toFixed(2)); 26 | }; 27 | 28 | const onSubmit = (e) => { 29 | e.preventDefault(); 30 | 31 | doRequest(); 32 | } 33 | 34 | return ( 35 |
    36 |

    Create Ticket

    37 |
    38 |
    39 | 40 | setTitle(e.target.value)} 44 | /> 45 |
    46 |
    47 | 48 | setPrice(e.target.value)} 52 | onBlur={onBlur} 53 | /> 54 |
    55 | {errors} 56 | 57 |
    58 |
    59 | ); 60 | } 61 | 62 | export default NewTicket; -------------------------------------------------------------------------------- /collection-postman/Ticketing.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "a2882f4d-2af8-48a2-9b61-38d318069cd8", 4 | "name": "Ticketing", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "7004513" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Auth", 11 | "item": [ 12 | { 13 | "name": "GET currentuser", 14 | "request": { 15 | "method": "GET", 16 | "header": [], 17 | "url": { 18 | "raw": "https://ticketing.dev/api/users/currentuser", 19 | "protocol": "https", 20 | "host": [ 21 | "ticketing", 22 | "dev" 23 | ], 24 | "path": [ 25 | "api", 26 | "users", 27 | "currentuser" 28 | ] 29 | } 30 | }, 31 | "response": [] 32 | }, 33 | { 34 | "name": "POST signout", 35 | "request": { 36 | "method": "POST", 37 | "header": [], 38 | "url": { 39 | "raw": "https://ticketing.dev/api/users/signout", 40 | "protocol": "https", 41 | "host": [ 42 | "ticketing", 43 | "dev" 44 | ], 45 | "path": [ 46 | "api", 47 | "users", 48 | "signout" 49 | ] 50 | } 51 | }, 52 | "response": [] 53 | }, 54 | { 55 | "name": "POST signup", 56 | "request": { 57 | "method": "POST", 58 | "header": [], 59 | "body": { 60 | "mode": "raw", 61 | "raw": "{\n \"email\" :\"test@test.com\",\n \"password\" :\"123456\"\n}", 62 | "options": { 63 | "raw": { 64 | "language": "json" 65 | } 66 | } 67 | }, 68 | "url": { 69 | "raw": "https://ticketing.dev/api/users/signup", 70 | "protocol": "https", 71 | "host": [ 72 | "ticketing", 73 | "dev" 74 | ], 75 | "path": [ 76 | "api", 77 | "users", 78 | "signup" 79 | ] 80 | } 81 | }, 82 | "response": [] 83 | }, 84 | { 85 | "name": "POST signin", 86 | "request": { 87 | "method": "POST", 88 | "header": [], 89 | "body": { 90 | "mode": "raw", 91 | "raw": "{\n \"email\" :\"test@test.com\",\n \"password\" :\"123456\"\n}", 92 | "options": { 93 | "raw": { 94 | "language": "json" 95 | } 96 | } 97 | }, 98 | "url": { 99 | "raw": "https://ticketing.dev/api/users/signin", 100 | "protocol": "https", 101 | "host": [ 102 | "ticketing", 103 | "dev" 104 | ], 105 | "path": [ 106 | "api", 107 | "users", 108 | "signin" 109 | ] 110 | } 111 | }, 112 | "response": [] 113 | } 114 | ] 115 | }, 116 | { 117 | "name": "client", 118 | "item": [ 119 | { 120 | "name": "Get initial", 121 | "request": { 122 | "method": "GET", 123 | "header": [], 124 | "url": { 125 | "raw": "https://ticketing.dev/", 126 | "protocol": "https", 127 | "host": [ 128 | "ticketing", 129 | "dev" 130 | ], 131 | "path": [ 132 | "" 133 | ] 134 | } 135 | }, 136 | "response": [] 137 | } 138 | ] 139 | }, 140 | { 141 | "name": "tickets", 142 | "item": [ 143 | { 144 | "name": "Show ticket GET", 145 | "request": { 146 | "method": "GET", 147 | "header": [], 148 | "url": { 149 | "raw": "https://ticketing.dev/api/tickets/", 150 | "protocol": "https", 151 | "host": [ 152 | "ticketing", 153 | "dev" 154 | ], 155 | "path": [ 156 | "api", 157 | "tickets", 158 | "" 159 | ] 160 | } 161 | }, 162 | "response": [] 163 | }, 164 | { 165 | "name": "POST create ticket", 166 | "request": { 167 | "method": "POST", 168 | "header": [], 169 | "body": { 170 | "mode": "raw", 171 | "raw": "{\n \"title\": \"la la land 3\",\n \"price\": 10\n}", 172 | "options": { 173 | "raw": { 174 | "language": "json" 175 | } 176 | } 177 | }, 178 | "url": { 179 | "raw": "https://ticketing.dev/api/tickets", 180 | "protocol": "https", 181 | "host": [ 182 | "ticketing", 183 | "dev" 184 | ], 185 | "path": [ 186 | "api", 187 | "tickets" 188 | ] 189 | } 190 | }, 191 | "response": [] 192 | }, 193 | { 194 | "name": "PUT update ticket", 195 | "request": { 196 | "method": "PUT", 197 | "header": [], 198 | "body": { 199 | "mode": "raw", 200 | "raw": "{\n \"title\": \"la la land\",\n \"price\": 201\n}", 201 | "options": { 202 | "raw": { 203 | "language": "json" 204 | } 205 | } 206 | }, 207 | "url": { 208 | "raw": "https://ticketing.dev/api/tickets/62c6bf7c356bcca8afbf1a31", 209 | "protocol": "https", 210 | "host": [ 211 | "ticketing", 212 | "dev" 213 | ], 214 | "path": [ 215 | "api", 216 | "tickets", 217 | "62c6bf7c356bcca8afbf1a31" 218 | ] 219 | } 220 | }, 221 | "response": [] 222 | } 223 | ] 224 | }, 225 | { 226 | "name": "orders", 227 | "item": [ 228 | { 229 | "name": "POST create orders", 230 | "request": { 231 | "method": "POST", 232 | "header": [], 233 | "body": { 234 | "mode": "raw", 235 | "raw": "{\n \"ticketId\" : \"62c7ad032dac0664b754c1b3\"\n}", 236 | "options": { 237 | "raw": { 238 | "language": "json" 239 | } 240 | } 241 | }, 242 | "url": { 243 | "raw": "https://ticketing.dev/api/orders", 244 | "protocol": "https", 245 | "host": [ 246 | "ticketing", 247 | "dev" 248 | ], 249 | "path": [ 250 | "api", 251 | "orders" 252 | ] 253 | } 254 | }, 255 | "response": [] 256 | } 257 | ] 258 | }, 259 | { 260 | "name": "payments", 261 | "item": [ 262 | { 263 | "name": "POST new payments", 264 | "request": { 265 | "method": "POST", 266 | "header": [], 267 | "body": { 268 | "mode": "raw", 269 | "raw": "{\n \"token\": \"tok_visa\",\n \"orderId\": \"62c7ad088628fe2390872b45\"\n}", 270 | "options": { 271 | "raw": { 272 | "language": "json" 273 | } 274 | } 275 | }, 276 | "url": { 277 | "raw": "https://ticketing.dev/api/payments", 278 | "protocol": "https", 279 | "host": [ 280 | "ticketing", 281 | "dev" 282 | ], 283 | "path": [ 284 | "api", 285 | "payments" 286 | ] 287 | } 288 | }, 289 | "response": [] 290 | } 291 | ] 292 | } 293 | ] 294 | } -------------------------------------------------------------------------------- /expiration/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /expiration/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /expiration/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # --only=prod so you don't create dev dependencies 6 | RUN npm install --omit=dev 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /expiration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expiration", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev --poll src/index.ts", 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": "victor hugo aguilar", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@black_sheep/common": "^1.0.11", 22 | "@types/bull": "^3.15.8", 23 | "bull": "^4.8.4", 24 | "node-nats-streaming": "^0.3.2", 25 | "ts-node-dev": "^2.0.0", 26 | "typescript": "^4.7.4" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^28.1.3", 30 | "jest": "^28.1.2", 31 | "ts-jest": "^28.0.5" 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /expiration/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | client: { 3 | publish: jest 4 | .fn() 5 | .mockImplementation( 6 | (subject: string, data: string, callback: () => void) => { 7 | callback() 8 | }) 9 | }, 10 | }; -------------------------------------------------------------------------------- /expiration/src/events/listeners/order-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCreatedEvent, OrderStatus, Subjects } from "@black_sheep/common"; 2 | import { Message } from "node-nats-streaming"; 3 | import { expirationQueue } from "../../queues/expiration-queue"; 4 | import { queueGroupName } from "./queue-group-name"; 5 | 6 | export class OrderCreatedListener extends Listener { 7 | readonly subject = Subjects.OrderCreated; 8 | queueGroupName: string = queueGroupName; 9 | 10 | async onMessage(data: OrderCreatedEvent['data'], msg: Message) { 11 | const delay = new Date(data.expiresAt).getTime() - new Date().getTime(); 12 | console.log('Waiting this many milliseconds to process the job: ', delay); 13 | 14 | await expirationQueue.add( 15 | { 16 | orderId: data.id 17 | }, 18 | { 19 | delay: delay 20 | }, 21 | ); 22 | 23 | msg.ack(); 24 | } 25 | } -------------------------------------------------------------------------------- /expiration/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGroupName = 'expiration-service'; -------------------------------------------------------------------------------- /expiration/src/events/publishers/expiration-complete-publisher.ts: -------------------------------------------------------------------------------- 1 | import { ExpirationCompleteEvent, Publisher, Subjects } from "@black_sheep/common"; 2 | 3 | export class ExpirationCompletePublisher extends Publisher{ 4 | readonly subject = Subjects.ExpirationComplete; 5 | } -------------------------------------------------------------------------------- /expiration/src/index.ts: -------------------------------------------------------------------------------- 1 | import { OrderCreatedListener } from './events/listeners/order-created-listener'; 2 | import { natsWrapper } from './nats-wrapper'; 3 | 4 | 5 | const start = async () => { 6 | if (!process.env.NATS_CLIENT_ID) { 7 | throw new Error('NATS_CLIENT_ID not found, must be defined'); 8 | } 9 | 10 | if (!process.env.NATS_URL) { 11 | throw new Error('NATS_URL not found, must be defined'); 12 | } 13 | 14 | if (!process.env.NATS_CLUSTER_ID) { 15 | throw new Error('NATS_CLUSTER_ID not found, must be defined'); 16 | } 17 | try { 18 | // Event service 19 | // connect to service nats 20 | await natsWrapper.connect( 21 | process.env.NATS_CLUSTER_ID, 22 | process.env.NATS_CLIENT_ID, 23 | process.env.NATS_URL 24 | ); 25 | // for close app close service too gracefull shutdown 26 | natsWrapper.client.on('close', () => { 27 | console.log('NATS connection close'); 28 | process.exit(); 29 | }); 30 | process.on('SIGINT', () => natsWrapper.client.close()); 31 | process.on('SIGTERM', () => natsWrapper.client.close()); 32 | 33 | new OrderCreatedListener(natsWrapper.client).listen(); 34 | } catch (error) { 35 | console.error(error); 36 | } 37 | } 38 | 39 | start(); 40 | -------------------------------------------------------------------------------- /expiration/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from 'node-nats-streaming'; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | connect(clusterId: string, clientId: string, url: string) { 7 | this._client = nats.connect(clusterId, clientId, { url }); 8 | 9 | return new Promise((resolve, reject) => { 10 | this._client!.on('connect', () => { 11 | console.log('Connected to NATS'); 12 | resolve(); 13 | }); 14 | 15 | this._client!.on('error', (err) => { 16 | reject(err); 17 | }); 18 | }); 19 | } 20 | 21 | get client() { 22 | if (!this._client) { 23 | throw new Error('Cannot acces NATS client before connecting'); 24 | } 25 | return this._client; 26 | } 27 | }; 28 | 29 | export const natsWrapper = new NatsWrapper(); -------------------------------------------------------------------------------- /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 | interface Payload { 6 | orderId: string; 7 | } 8 | 9 | const expirationQueue = new Queue('order:expiration', { 10 | redis: { 11 | host: process.env.REDIS_HOST 12 | } 13 | }); 14 | 15 | expirationQueue.process(async (job) => { 16 | console.log('I want to publish an expiration: complete event for orderId', job.data.orderId) 17 | new ExpirationCompletePublisher(natsWrapper.client).publish({ 18 | orderId: job.data.orderId 19 | }); 20 | }); 21 | 22 | export { expirationQueue } 23 | -------------------------------------------------------------------------------- /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 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /infra/k8s/auth-depl.yml: -------------------------------------------------------------------------------- 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: victoruugo/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.yml: -------------------------------------------------------------------------------- 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 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: auth-mongo-srv 23 | spec: 24 | selector: 25 | app: auth-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /infra/k8s/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: victoruugo/ticketing-client 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: client-srv 23 | spec: 24 | selector: 25 | app: client 26 | ports: 27 | - name: client 28 | protocol: TCP 29 | port: 3000 30 | targetPort: 3000 31 | -------------------------------------------------------------------------------- /infra/k8s/expiration-depl.yml: -------------------------------------------------------------------------------- 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: victoruugo/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.yml: -------------------------------------------------------------------------------- 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 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: expiration-redis-srv 23 | spec: 24 | selector: 25 | app: expiration-redis 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 6379 30 | targetPort: 6379 31 | -------------------------------------------------------------------------------- /infra/k8s/ingress-srv.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 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 | nginx.ingress.kubernetes.io/from-to-www-redirect: "true" 9 | spec: 10 | rules: 11 | - host: ticketing.dev 12 | http: 13 | paths: 14 | - path: /api/payments/?(.*) 15 | pathType: Prefix 16 | backend: 17 | service: 18 | name: payments-srv 19 | port: 20 | number: 3000 21 | - path: /api/users/?(.*) 22 | pathType: Prefix 23 | backend: 24 | service: 25 | name: auth-srv 26 | port: 27 | number: 3000 28 | - path: /api/tickets/?(.*) 29 | pathType: Prefix 30 | backend: 31 | service: 32 | name: tickets-srv 33 | port: 34 | number: 3000 35 | - path: /api/orders/?(.*) 36 | pathType: Prefix 37 | backend: 38 | service: 39 | name: orders-srv 40 | port: 41 | number: 3000 42 | - path: /?(.*) 43 | pathType: Prefix 44 | backend: 45 | service: 46 | name: client-srv 47 | port: 48 | number: 3000 49 | -------------------------------------------------------------------------------- /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.yml: -------------------------------------------------------------------------------- 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: victoruugo/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: JWT_KEY 28 | valueFrom: 29 | secretKeyRef: 30 | name: jwt-secret 31 | key: JWT_KEY 32 | - name: MONGO_URI 33 | value: "mongodb://orders-mongo-srv:27017/orders" 34 | - name: JWT_KEY 35 | valueFrom: 36 | secretKeyRef: 37 | name: jwt-secret 38 | key: JWT_KEY 39 | --- 40 | apiVersion: v1 41 | kind: Service 42 | metadata: 43 | name: orders-srv 44 | spec: 45 | selector: 46 | app: orders 47 | ports: 48 | - name: orders 49 | protocol: TCP 50 | port: 3000 51 | targetPort: 3000 52 | -------------------------------------------------------------------------------- /infra/k8s/orders-mongo-depl.yml: -------------------------------------------------------------------------------- 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 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: orders-mongo-srv 23 | spec: 24 | selector: 25 | app: orders-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /infra/k8s/payments-depl.yml: -------------------------------------------------------------------------------- 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: victoruugo/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: JWT_KEY 28 | valueFrom: 29 | secretKeyRef: 30 | name: jwt-secret 31 | key: JWT_KEY 32 | - name: MONGO_URI 33 | value: "mongodb://payments-mongo-srv:27017/payments" 34 | - name: JWT_KEY 35 | valueFrom: 36 | secretKeyRef: 37 | name: jwt-secret 38 | key: JWT_KEY 39 | - name: STRIPE_KEY 40 | valueFrom: 41 | secretKeyRef: 42 | name: stripe-secret 43 | key: STRIPE_KEY 44 | --- 45 | apiVersion: v1 46 | kind: Service 47 | metadata: 48 | name: payments-srv 49 | spec: 50 | selector: 51 | app: payments 52 | ports: 53 | - name: payments 54 | protocol: TCP 55 | port: 3000 56 | targetPort: 3000 57 | -------------------------------------------------------------------------------- /infra/k8s/payments-mongo-depl.yml: -------------------------------------------------------------------------------- 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 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: payments-mongo-srv 23 | spec: 24 | selector: 25 | app: payments-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /infra/k8s/tickets-depl.yml: -------------------------------------------------------------------------------- 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: victoruugo/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: JWT_KEY 28 | valueFrom: 29 | secretKeyRef: 30 | name: jwt-secret 31 | key: JWT_KEY 32 | - name: MONGO_URI 33 | value: "mongodb://tickets-mongo-srv:27017/tickets" 34 | - name: JWT_KEY 35 | valueFrom: 36 | secretKeyRef: 37 | name: jwt-secret 38 | key: JWT_KEY 39 | --- 40 | apiVersion: v1 41 | kind: Service 42 | metadata: 43 | name: tickets-srv 44 | spec: 45 | selector: 46 | app: tickets 47 | ports: 48 | - name: tickets 49 | protocol: TCP 50 | port: 3000 51 | targetPort: 3000 52 | -------------------------------------------------------------------------------- /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 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: tickets-mongo-srv 23 | spec: 24 | selector: 25 | app: tickets-mongo 26 | ports: 27 | - name: db 28 | protocol: TCP 29 | port: 27017 30 | targetPort: 27017 31 | -------------------------------------------------------------------------------- /nats-test/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /nats-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nats-test", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "publish": "ts-node-dev --rs --notify false src/publisher.ts", 8 | "listen": "ts-node-dev --rs --notify false src/listener.ts" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@types/node": "^18.0.1", 15 | "node-nats-streaming": "^0.3.2", 16 | "ts-node-dev": "^2.0.0", 17 | "typescript": "^4.7.4" 18 | } 19 | } -------------------------------------------------------------------------------- /nats-test/src/events/abstract/base-listener.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Message, Stan } from 'node-nats-streaming'; 3 | import Subjects from '../enums/Subjects'; 4 | 5 | interface Event { 6 | subject: Subjects; 7 | data: any; 8 | } 9 | abstract class Listener { 10 | abstract subject: T['subject']; 11 | abstract queueGroupName: string; 12 | abstract onMessage(data: T['data'], msg: Message): void; 13 | private client: Stan; 14 | protected ackWait = 5 * 1000; 15 | 16 | constructor(client: Stan) { 17 | this.client = client; 18 | } 19 | 20 | subscriptionOptions() { 21 | return this.client.subscriptionOptions() 22 | .setDeliverAllAvailable() 23 | .setManualAckMode(true) 24 | .setAckWait(this.ackWait) 25 | .setDurableName(this.queueGroupName); 26 | } 27 | 28 | listen() { 29 | const subscription = this.client.subscribe( 30 | this.subject, this.queueGroupName, this.subscriptionOptions() 31 | ); 32 | 33 | subscription.on('message', (msg: Message) => { 34 | console.log(`Message received: ${this.subject} / ${this.queueGroupName}`); 35 | 36 | const parsedData = this.parseMessage(msg); 37 | this.onMessage(parsedData, msg); 38 | }); 39 | 40 | } 41 | 42 | parseMessage(msg: Message) { 43 | const data = msg.getData(); 44 | return typeof data === 'string' ? 45 | JSON.parse(data) 46 | : JSON.parse(data.toString('utf-8')) 47 | } 48 | } 49 | 50 | export default Listener; -------------------------------------------------------------------------------- /nats-test/src/events/abstract/base-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Message, Stan } from 'node-nats-streaming'; 2 | import Subjects from '../enums/Subjects'; 3 | 4 | interface Event { 5 | subject: Subjects; 6 | data: any; 7 | } 8 | 9 | abstract class Publisher { 10 | abstract subject: T['subject']; 11 | private client: Stan; 12 | 13 | constructor(client: Stan) { 14 | this.client = client; 15 | } 16 | 17 | publish(data: T['data']): Promise { 18 | return new Promise((resolve, reject) => { 19 | this.client.publish(this.subject, JSON.stringify(data), (err) => { 20 | if (err) { 21 | return reject(err); 22 | } 23 | 24 | console.log('Event published to subject', this.subject); 25 | resolve(); 26 | }); 27 | }) 28 | } 29 | } 30 | 31 | 32 | export default Publisher; -------------------------------------------------------------------------------- /nats-test/src/events/enums/Subjects.ts: -------------------------------------------------------------------------------- 1 | enum Subjects { 2 | TicketCreated = 'ticket:created', 3 | OrderUpdated = 'order:updated' 4 | } 5 | 6 | export default Subjects; -------------------------------------------------------------------------------- /nats-test/src/events/ticket-created-events.ts: -------------------------------------------------------------------------------- 1 | import Subjects from "./enums/Subjects"; 2 | 3 | interface TicketCreatedEvent { 4 | subject: Subjects.TicketCreated, 5 | data: { 6 | id: string, 7 | title: string, 8 | price: number 9 | } 10 | } 11 | 12 | export default TicketCreatedEvent; -------------------------------------------------------------------------------- /nats-test/src/events/ticket-created-listener.ts: -------------------------------------------------------------------------------- 1 | import nats from 'node-nats-streaming'; 2 | import Listener from './abstract/base-listener'; 3 | import Subjects from './enums/Subjects'; 4 | import TicketCreatedEvent from './ticket-created-events'; 5 | 6 | class TicketCreatedListener extends Listener { 7 | readonly subject = Subjects.TicketCreated; 8 | queueGroupName: string = 'payments-service'; 9 | 10 | onMessage(data: TicketCreatedEvent['data'], msg: nats.Message): void { 11 | console.log('Event data!', data); 12 | 13 | console.log(data.id); 14 | console.log(data.title); 15 | console.log(data.price); 16 | 17 | msg.ack(); 18 | } 19 | }; 20 | 21 | export default TicketCreatedListener; -------------------------------------------------------------------------------- /nats-test/src/events/ticket-created-publisher.ts: -------------------------------------------------------------------------------- 1 | import Publisher from './abstract/base-publisher'; 2 | import Subjects from './enums/Subjects'; 3 | import TicketCreatedEvent from './ticket-created-events'; 4 | 5 | class TicketCreatedPublisher extends Publisher { 6 | readonly subject = Subjects.TicketCreated; 7 | } 8 | 9 | export default TicketCreatedPublisher; -------------------------------------------------------------------------------- /nats-test/src/listener.ts: -------------------------------------------------------------------------------- 1 | import nats from 'node-nats-streaming'; 2 | import { randomBytes } from 'crypto'; 3 | import TicketCreatedListener from './events/ticket-created-listener'; 4 | 5 | console.clear(); 6 | 7 | const stan = nats.connect('ticketing', randomBytes(4).toString('hex'), { 8 | url: 'http://localhost:4222', 9 | }); 10 | 11 | stan.on('connect', () => { 12 | console.log('Listener connected to NATS'); 13 | 14 | stan.on('close', () => { 15 | console.log('NATS connection closed!'); 16 | process.exit(); 17 | }); 18 | 19 | new TicketCreatedListener(stan).listen(); 20 | 21 | }); 22 | 23 | process.on('SIGINT', () => stan.close()); 24 | process.on('SIGTERM', () => stan.close()); -------------------------------------------------------------------------------- /nats-test/src/publisher.ts: -------------------------------------------------------------------------------- 1 | import nats from 'node-nats-streaming'; 2 | import TicketCreatedPublisher from './events/ticket-created-publisher'; 3 | 4 | const stan = nats.connect('ticketing', 'abc', { 5 | url: 'http://localhost:4222', 6 | }); 7 | 8 | stan.on('connect', async () => { 9 | console.log('Publisher connected to NATS'); 10 | 11 | // const data = JSON.stringify({ 12 | // id: '123', 13 | // title: 'concet', 14 | // price: 20 15 | // }); 16 | 17 | // stan.publish('Ticket:Created', data, () => { 18 | // console.log('Event published'); 19 | // }); 20 | 21 | const publisher = new TicketCreatedPublisher(stan); 22 | 23 | try { 24 | await publisher.publish({ 25 | id: '123', 26 | title: 'concet', 27 | price: 20 28 | }); 29 | } catch (err) { 30 | console.error(err); 31 | } 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /nats-test/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 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /orders/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /orders/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /orders/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # --only=prod so you don't create dev dependencies 6 | RUN npm install --omit=dev 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /orders/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orders", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev --poll src/index.ts", 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": "victor hugo aguilar", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@black_sheep/common": "^1.0.13", 23 | "@types/cookie-session": "^2.0.44", 24 | "@types/express": "^4.17.13", 25 | "@types/jsonwebtoken": "^8.5.8", 26 | "@types/mongoose": "^5.11.97", 27 | "@types/node": "^18.0.0", 28 | "cookie-session": "^2.0.0", 29 | "express": "^4.18.1", 30 | "express-async-errors": "^3.1.1", 31 | "express-validator": "^6.14.2", 32 | "jsonwebtoken": "^8.5.1", 33 | "mongoose": "^6.4.0", 34 | "mongoose-update-if-current": "^1.4.0", 35 | "ts-node-dev": "^2.0.0", 36 | "typescript": "^4.7.4" 37 | }, 38 | "devDependencies": { 39 | "@types/jest": "^28.1.3", 40 | "@types/supertest": "^2.0.12", 41 | "jest": "^28.1.2", 42 | "mongodb-memory-server": "^8.7.1", 43 | "supertest": "^6.2.3", 44 | "ts-jest": "^28.0.5" 45 | } 46 | } -------------------------------------------------------------------------------- /orders/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | client: { 3 | publish: jest 4 | .fn() 5 | .mockImplementation( 6 | (subject: string, data: string, callback: () => void) => { 7 | callback() 8 | }) 9 | }, 10 | }; -------------------------------------------------------------------------------- /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 { NotFoundError, errorHandler, currentUser } from '@black_sheep/common'; 6 | 7 | // Routes 8 | import { indexOrderRouter } from './routes/index'; 9 | import { newOrderRouter } from './routes/news'; 10 | import { deleteOrderRouter } from './routes/delete'; 11 | import { showOrderRouter } from './routes/show'; 12 | 13 | 14 | const app = express(); 15 | app.set('trust proxy', true); 16 | app.use(json()); 17 | app.use(cookieSession({ 18 | signed: false, 19 | secure: process.env.NODE_ENV !== 'test' 20 | })); 21 | 22 | app.use(currentUser); 23 | 24 | app.use(indexOrderRouter); 25 | app.use(newOrderRouter); 26 | app.use(deleteOrderRouter); 27 | app.use(showOrderRouter); 28 | 29 | 30 | app.all('*', async (req, res) => { 31 | throw new NotFoundError(); 32 | }); 33 | 34 | app.use(errorHandler); 35 | 36 | export default app; -------------------------------------------------------------------------------- /orders/src/events/listeners/__test__/expiration-complete-listener.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Message } from 'node-nats-streaming'; 3 | import { ExpirationCompleteListener } from '../expiration-complete-listener'; 4 | import { natsWrapper } from '../../../nats-wrapper'; 5 | import { Order } from '../../../models/order'; 6 | import { Ticket } from '../../../models/ticket'; 7 | import { ExpirationCompleteEvent, OrderStatus } from '@black_sheep/common'; 8 | 9 | const setup = async () => { 10 | const listener = new ExpirationCompleteListener(natsWrapper.client); 11 | 12 | const ticket = Ticket.build({ 13 | id: new mongoose.Types.ObjectId().toHexString(), 14 | title: 'concert', 15 | price: 20, 16 | }); 17 | await ticket.save(); 18 | const order = Order.build({ 19 | status: OrderStatus.Created, 20 | userId: 'alskdfj', 21 | expiresAt: new Date(), 22 | ticket, 23 | }); 24 | await order.save(); 25 | 26 | const data: ExpirationCompleteEvent['data'] = { 27 | orderId: order.id, 28 | }; 29 | 30 | // @ts-ignore 31 | const msg: Message = { 32 | ack: jest.fn(), 33 | }; 34 | 35 | return { listener, order, ticket, data, msg }; 36 | }; 37 | 38 | it('updates the order status to cancelled', async () => { 39 | const { listener, order, data, msg } = await setup(); 40 | 41 | await listener.onMessage(data, msg); 42 | 43 | const updatedOrder = await Order.findById(order.id); 44 | expect(updatedOrder!.status).toEqual(OrderStatus.Cancelled); 45 | }); 46 | 47 | it('emit an OrderCancelled event', async () => { 48 | const { listener, order, data, msg } = await setup(); 49 | 50 | await listener.onMessage(data, msg); 51 | 52 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 53 | 54 | const eventData = JSON.parse( 55 | (natsWrapper.client.publish as jest.Mock).mock.calls[0][1] 56 | ); 57 | expect(eventData.id).toEqual(order.id); 58 | }); 59 | 60 | it('ack the message', async () => { 61 | const { listener, data, msg } = await setup(); 62 | 63 | await listener.onMessage(data, msg); 64 | 65 | expect(msg.ack).toHaveBeenCalled(); 66 | }); 67 | 68 | -------------------------------------------------------------------------------- /orders/src/events/listeners/__test__/ticket-created-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'node-nats-streaming'; 2 | import mongoose from 'mongoose'; 3 | import { TicketCreatedListener } from '../ticket-created-listener'; 4 | import { natsWrapper } from '../../../nats-wrapper'; 5 | import { Ticket } from '../../../models/ticket'; 6 | import { TicketCreatedEvent } from '@black_sheep/common'; 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: new mongoose.Types.ObjectId().toHexString(), 16 | title: 'concert', 17 | price: 10, 18 | userId: new 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 assertions to make sure a ticket was created! 37 | const ticket = await Ticket.findById(data.id); 38 | 39 | expect(ticket).toBeDefined(); 40 | expect(ticket!.title).toEqual(data.title); 41 | expect(ticket!.price).toEqual(data.price); 42 | }); 43 | 44 | it('acks the message', async () => { 45 | const { data, listener, msg } = await setup(); 46 | 47 | // call the onMessage function with the data object + message object 48 | await listener.onMessage(data, msg); 49 | 50 | // write assertions to make sure ack function is called 51 | expect(msg.ack).toHaveBeenCalled(); 52 | }); 53 | -------------------------------------------------------------------------------- /orders/src/events/listeners/__test__/ticket-updated-listener.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Message } from 'node-nats-streaming'; 3 | import { TicketUpdatedListener } from '../ticket-updated-listener'; 4 | import { natsWrapper } from '../../../nats-wrapper'; 5 | import { Ticket } from '../../../models/ticket'; 6 | import { TicketUpdatedEvent } from '@black_sheep/common'; 7 | 8 | const setup = async () => { 9 | // Create a listener 10 | const listener = new TicketUpdatedListener(natsWrapper.client); 11 | 12 | // Create and save a ticket 13 | const ticket = Ticket.build({ 14 | id: new mongoose.Types.ObjectId().toHexString(), 15 | title: 'concert', 16 | price: 20, 17 | }); 18 | await ticket.save(); 19 | 20 | // Create a fake data object 21 | const data: TicketUpdatedEvent['data'] = { 22 | id: ticket.id, 23 | version: ticket.version + 1, 24 | title: 'new concert', 25 | price: 999, 26 | userId: 'ablskdjf', 27 | }; 28 | 29 | // Create a fake msg object 30 | // @ts-ignore 31 | const msg: Message = { 32 | ack: jest.fn(), 33 | }; 34 | 35 | // return all of this stuff 36 | return { msg, data, ticket, listener }; 37 | }; 38 | 39 | it('finds, updates, and saves a ticket', async () => { 40 | const { msg, data, ticket, listener } = await setup(); 41 | 42 | await listener.onMessage(data, msg); 43 | 44 | const updatedTicket = await Ticket.findById(ticket.id); 45 | 46 | expect(updatedTicket!.title).toEqual(data.title); 47 | expect(updatedTicket!.price).toEqual(data.price); 48 | expect(updatedTicket!.version).toEqual(data.version); 49 | }); 50 | 51 | it('acks the message', async () => { 52 | const { msg, data, listener } = await setup(); 53 | 54 | await listener.onMessage(data, msg); 55 | 56 | expect(msg.ack).toHaveBeenCalled(); 57 | }); 58 | 59 | it('dows not call ack if the envet has a skipped version number', async () => { 60 | const { msg, data, listener, ticket } = await setup(); 61 | data.version = 10; 62 | 63 | try { 64 | await listener.onMessage(data, msg); 65 | } catch (error) { } 66 | 67 | expect(msg.ack).not.toHaveBeenCalled(); 68 | }); -------------------------------------------------------------------------------- /orders/src/events/listeners/expiration-complete-listener.ts: -------------------------------------------------------------------------------- 1 | import { ExpirationCompleteEvent, Listener, OrderStatus, Subjects } from "@black_sheep/common"; 2 | import { Message } from "node-nats-streaming"; 3 | import { Order } from "../../models/order"; 4 | import { OrderCancelledPublisher } from "../publishers/order-cancelled-publisher"; 5 | import { queueGroupName } from "./queue-group-name"; 6 | 7 | export class ExpirationCompleteListener extends Listener { 8 | readonly subject = Subjects.ExpirationComplete; 9 | queueGroupName: string = queueGroupName; 10 | 11 | async onMessage(data: ExpirationCompleteEvent['data'], msg: Message) { 12 | const order = await Order.findById(data.orderId).populate('ticket'); 13 | 14 | if (!order) { 15 | throw new Error('Order not found'); 16 | } 17 | 18 | order.set({ 19 | status: OrderStatus.Cancelled, 20 | }); 21 | await order.save(); 22 | 23 | new OrderCancelledPublisher(this.client).publish({ 24 | id: order.id, 25 | version: order.version, 26 | ticket: { 27 | id: order.ticket.id 28 | } 29 | }); 30 | 31 | msg.ack(); 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /orders/src/events/listeners/payment-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderStatus, PaymentCreatedEvent, Subjects } from "@black_sheep/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 PaymentsCreatedListener extends Listener{ 7 | readonly subject: Subjects.PaymentCreated = Subjects.PaymentCreated; 8 | queueGroupName: string = queueGroupName; 9 | 10 | async onMessage(data: PaymentCreatedEvent['data'], msg: Message) { 11 | const order = await Order.findById(data.orderId); 12 | if (!order) { 13 | throw new Error('Order not found'); 14 | } 15 | if (order.status === OrderStatus.Complete) { 16 | return msg.ack(); 17 | } 18 | 19 | order.set({ 20 | status: OrderStatus.Complete 21 | }); 22 | await order.save(); 23 | 24 | msg.ack(); 25 | } 26 | 27 | 28 | } -------------------------------------------------------------------------------- /orders/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGroupName = 'orders-service'; -------------------------------------------------------------------------------- /orders/src/events/listeners/ticket-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, Subjects, TicketCreatedEvent } from "@black_sheep/common"; 2 | import { Message } from "node-nats-streaming"; 3 | import { Ticket } from "../../models/ticket"; 4 | import { queueGroupName } from "./queue-group-name"; 5 | 6 | 7 | export class TicketCreatedListener extends Listener { 8 | readonly subject = Subjects.TicketCreated; 9 | queueGroupName: string = queueGroupName; 10 | 11 | async onMessage(data: TicketCreatedEvent['data'], msg: Message) { 12 | const { id, title, price } = data; 13 | 14 | const ticket = Ticket.build({ 15 | id, 16 | title, 17 | price, 18 | }); 19 | 20 | await ticket.save(); 21 | 22 | msg.ack(); 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /orders/src/events/listeners/ticket-updated-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, Subjects, TicketUpdatedEvent } from "@black_sheep/common"; 2 | import { Message } from "node-nats-streaming"; 3 | import { Ticket } from "../../models/ticket"; 4 | import { queueGroupName } from "./queue-group-name"; 5 | 6 | 7 | export class TicketUpdatedListener extends Listener { 8 | readonly subject = Subjects.TicketUpdated; 9 | queueGroupName = queueGroupName; 10 | 11 | async onMessage(data: TicketUpdatedEvent['data'], msg: Message) { 12 | const ticket = await Ticket.findByEvent(data); 13 | 14 | if (!ticket) { 15 | throw new Error('Ticket not found'); 16 | } 17 | 18 | const { title, price } = data; 19 | ticket.set({ title, price }); 20 | 21 | await ticket.save(); 22 | 23 | msg.ack(); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /orders/src/events/publishers/order-cancelled-publisher.ts: -------------------------------------------------------------------------------- 1 | import { OrderCancelledEvent, Publisher, Subjects } from "@black_sheep/common"; 2 | 3 | export class OrderCancelledPublisher extends Publisher { 4 | readonly subject = Subjects.OrderCancelled; 5 | } -------------------------------------------------------------------------------- /orders/src/events/publishers/order-created-publisher.ts: -------------------------------------------------------------------------------- 1 | import { OrderCreatedEvent, Publisher, Subjects } from "@black_sheep/common"; 2 | 3 | export class OrderCreatedPublisher extends Publisher { 4 | readonly subject = Subjects.OrderCreated; 5 | } -------------------------------------------------------------------------------- /orders/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import mongoose from 'mongoose'; 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 { PaymentsCreatedListener } from './events/listeners/payment-created-listener'; 8 | 9 | 10 | const start = async () => { 11 | console.log('Starting orders...'); 12 | 13 | if (!process.env.JWT_KEY) { 14 | throw new Error('JWT_KEY not found, must be defined'); 15 | } 16 | 17 | if (!process.env.MONGO_URI) { 18 | throw new Error('MONGO_URI not found, must be defined'); 19 | } 20 | 21 | if (!process.env.NATS_CLIENT_ID) { 22 | throw new Error('NATS_CLIENT_ID not found, must be defined'); 23 | } 24 | 25 | if (!process.env.NATS_URL) { 26 | throw new Error('NATS_URL not found, must be defined'); 27 | } 28 | 29 | if (!process.env.NATS_CLUSTER_ID) { 30 | throw new Error('NATS_CLUSTER_ID not found, must be defined'); 31 | } 32 | try { 33 | 34 | // Event service 35 | // connect to service nats 36 | await natsWrapper.connect( 37 | process.env.NATS_CLUSTER_ID, 38 | process.env.NATS_CLIENT_ID, 39 | process.env.NATS_URL 40 | ); 41 | // for close app close service too gracefull shutdown 42 | natsWrapper.client.on('close', () => { 43 | console.log('NATS connection close'); 44 | process.exit(); 45 | }); 46 | process.on('SIGINT', () => natsWrapper.client.close()); 47 | process.on('SIGTERM', () => natsWrapper.client.close()); 48 | 49 | new TicketCreatedListener(natsWrapper.client).listen(); 50 | new TicketUpdatedListener(natsWrapper.client).listen(); 51 | new ExpirationCompleteListener(natsWrapper.client).listen(); 52 | new PaymentsCreatedListener(natsWrapper.client).listen(); 53 | 54 | // connect to mongodb 55 | await mongoose.connect(process.env.MONGO_URI, {}); 56 | console.info('Connect to MongoDb'); 57 | } catch (error) { 58 | console.error(error); 59 | } 60 | } 61 | 62 | app.listen(3000, () => { 63 | console.log('Listen from port: ', 3000) 64 | }); 65 | 66 | start(); 67 | -------------------------------------------------------------------------------- /orders/src/models/order.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { OrderStatus } from "@black_sheep/common"; 3 | import { TicketDoc } from "./ticket"; 4 | import { updateIfCurrentPlugin } from 'mongoose-update-if-current'; 5 | 6 | interface OrderAttrs { 7 | userId: string; 8 | status: OrderStatus; 9 | expiresAt: Date; 10 | ticket: TicketDoc; 11 | } 12 | 13 | interface OrderDoc extends mongoose.Document { 14 | userId: string; 15 | status: OrderStatus; 16 | expiresAt: Date; 17 | ticket: TicketDoc; 18 | version: number; 19 | } 20 | 21 | interface OrderModel extends mongoose.Model { 22 | build(attrs: OrderAttrs): OrderDoc; 23 | } 24 | 25 | const orderSchema = new mongoose.Schema({ 26 | userId: { 27 | type: String, 28 | required: true 29 | }, 30 | status: { 31 | type: String, 32 | required: true, 33 | enum: Object.values(OrderStatus), 34 | default: OrderStatus.Created 35 | }, 36 | expiresAt: { 37 | type: mongoose.Schema.Types.Date 38 | }, 39 | ticket: { 40 | type: mongoose.Schema.Types.ObjectId, 41 | ref: 'Ticket' 42 | } 43 | }, { 44 | toJSON: { 45 | transform(doc, ret) { 46 | ret.id = ret._id; 47 | delete ret._id; 48 | } 49 | } 50 | }); 51 | 52 | orderSchema.set('versionKey', 'version'); 53 | orderSchema.plugin(updateIfCurrentPlugin); 54 | 55 | orderSchema.statics.build = (attrs: OrderAttrs) => { 56 | return new Order(attrs); 57 | }; 58 | 59 | const Order = mongoose.model('Order', orderSchema); 60 | 61 | export { Order }; 62 | -------------------------------------------------------------------------------- /orders/src/models/ticket.ts: -------------------------------------------------------------------------------- 1 | import { OrderStatus } from "@black_sheep/common"; 2 | import mongoose from "mongoose"; 3 | import { Order } from "./order"; 4 | import { updateIfCurrentPlugin } from 'mongoose-update-if-current'; 5 | 6 | interface TicketAttrs { 7 | id: string; 8 | title: string; 9 | price: number; 10 | } 11 | 12 | export interface TicketDoc extends mongoose.Document { 13 | title: string; 14 | price: number; 15 | version: number; 16 | isReserved(): Promise; 17 | } 18 | 19 | interface TicketModel extends mongoose.Model { 20 | build(attrs: TicketAttrs): TicketDoc; 21 | findByEvent(event: { id: string, version: number }): Promise; 22 | } 23 | 24 | const ticketSchema = new mongoose.Schema({ 25 | title: { 26 | type: String, 27 | require: true 28 | } 29 | , price: { 30 | type: Number, 31 | require: true 32 | } 33 | }, { 34 | toJSON: { 35 | transform(doc, ret) { 36 | ret.id = ret._id; 37 | delete ret._id; 38 | } 39 | } 40 | }); 41 | 42 | ticketSchema.set('versionKey', 'version'); 43 | ticketSchema.plugin(updateIfCurrentPlugin); 44 | 45 | ticketSchema.statics.findByEvent = (event: { id: string, version: number }) => { 46 | return Ticket.findOne({ 47 | _id: event.id, 48 | version: event.version - 1 49 | }); 50 | } 51 | 52 | ticketSchema.statics.build = (attrs: TicketAttrs) => { 53 | return new Ticket({ 54 | _id: attrs.id, 55 | title: attrs.title, 56 | price: attrs.price, 57 | }); 58 | } 59 | 60 | ticketSchema.methods.isReserved = async function () { 61 | // this === the ticket document that we just called 'isReserved' 62 | const existingOrder = await Order.findOne({ 63 | ticket: this as any, 64 | status: { 65 | $in: [ 66 | OrderStatus.Created, 67 | OrderStatus.AwaitingPayment, 68 | OrderStatus.Complete 69 | ] 70 | } 71 | }); 72 | 73 | return !!existingOrder; 74 | } 75 | 76 | const Ticket = mongoose.model('Ticket', ticketSchema); 77 | 78 | export { Ticket }; -------------------------------------------------------------------------------- /orders/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from 'node-nats-streaming'; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | connect(clusterId: string, clientId: string, url: string) { 7 | this._client = nats.connect(clusterId, clientId, { url }); 8 | 9 | return new Promise((resolve, reject) => { 10 | this._client!.on('connect', () => { 11 | console.log('Connected to NATS'); 12 | resolve(); 13 | }); 14 | 15 | this._client!.on('error', (err) => { 16 | reject(err); 17 | }); 18 | }); 19 | } 20 | 21 | get client() { 22 | if (!this._client) { 23 | throw new Error('Cannot acces NATS client before connecting'); 24 | } 25 | return this._client; 26 | } 27 | }; 28 | 29 | export const natsWrapper = new NatsWrapper(); -------------------------------------------------------------------------------- /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 } from '../../models/order'; 5 | import { OrderStatus } from '@black_sheep/common'; 6 | import { natsWrapper } from '../../nats-wrapper'; 7 | import mongoose from 'mongoose'; 8 | 9 | it('marks an order as cancelled', async () => { 10 | // create a ticket with Ticket Model 11 | const ticket = Ticket.build({ 12 | id: new mongoose.Types.ObjectId().toHexString(), 13 | title: 'concert', 14 | price: 20, 15 | }); 16 | await ticket.save(); 17 | 18 | const user = global.signin(); 19 | // make a request to create an order 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 a request to cancel the order 27 | await request(app) 28 | .delete(`/api/orders/${order.id}`) 29 | .set('Cookie', user) 30 | .send() 31 | .expect(204); 32 | 33 | // expectation to make sure the thing is cancelled 34 | const updatedOrder = await Order.findById(order.id); 35 | 36 | expect(updatedOrder!.status).toEqual(OrderStatus.Cancelled); 37 | }); 38 | 39 | it('emits a order cancelled event', async () => { 40 | // create a ticket with Ticket Model 41 | const ticket = Ticket.build({ 42 | id: new mongoose.Types.ObjectId().toHexString(), 43 | title: 'concert', 44 | price: 20, 45 | }); 46 | await ticket.save(); 47 | 48 | const user = global.signin(); 49 | // make a request to create an order 50 | const { body: order } = await request(app) 51 | .post('/api/orders') 52 | .set('Cookie', user) 53 | .send({ ticketId: ticket.id }) 54 | .expect(201); 55 | 56 | // make a request to cancel the order 57 | await request(app) 58 | .delete(`/api/orders/${order.id}`) 59 | .set('Cookie', user) 60 | .send() 61 | .expect(204); 62 | 63 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 64 | }); 65 | -------------------------------------------------------------------------------- /orders/src/routes/__test__/index.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import request from 'supertest'; 3 | import app from '../../app'; 4 | import { Ticket } from '../../models/ticket'; 5 | 6 | const buildTicket = async () => { 7 | const ticket = Ticket.build({ 8 | id: new mongoose.Types.ObjectId().toHexString(), 9 | title: 'concert', 10 | price: 20, 11 | }); 12 | await ticket.save(); 13 | 14 | return ticket; 15 | }; 16 | 17 | it('fetches orders for an particular user', async () => { 18 | // Create three tickets 19 | const ticketOne = await buildTicket(); 20 | const ticketTwo = await buildTicket(); 21 | const ticketThree = await buildTicket(); 22 | 23 | const userOne = global.signin(); 24 | const userTwo = global.signin(); 25 | // Create one order as User #1 26 | await request(app) 27 | .post('/api/orders') 28 | .set('Cookie', userOne) 29 | .send({ ticketId: ticketOne.id }) 30 | .expect(201); 31 | 32 | // Create two orders as User #2 33 | const { body: orderOne } = await request(app) 34 | .post('/api/orders') 35 | .set('Cookie', userTwo) 36 | .send({ ticketId: ticketTwo.id }) 37 | .expect(201); 38 | const { body: orderTwo } = await request(app) 39 | .post('/api/orders') 40 | .set('Cookie', userTwo) 41 | .send({ ticketId: ticketThree.id }) 42 | .expect(201); 43 | 44 | // Make request to get orders for User #2 45 | const response = await request(app) 46 | .get('/api/orders') 47 | .set('Cookie', userTwo) 48 | .expect(200); 49 | 50 | // Make sure we only got the orders for User #2 51 | expect(response.body.length).toEqual(2); 52 | expect(response.body[0].id).toEqual(orderOne.id); 53 | expect(response.body[1].id).toEqual(orderTwo.id); 54 | expect(response.body[0].ticket.id).toEqual(ticketTwo.id); 55 | expect(response.body[1].ticket.id).toEqual(ticketThree.id); 56 | }); 57 | -------------------------------------------------------------------------------- /orders/src/routes/__test__/new.test.ts: -------------------------------------------------------------------------------- 1 | import { OrderStatus } from '@black_sheep/common'; 2 | import mongoose from 'mongoose'; 3 | import request from 'supertest'; 4 | import app from '../../app'; 5 | import { Order } from '../../models/order'; 6 | import { Ticket } from '../../models/ticket'; 7 | import { natsWrapper } from '../../nats-wrapper'; 8 | 9 | it('returns an error if the ticket does not exist', async () => { 10 | const ticketId = new mongoose.Types.ObjectId(); 11 | 12 | await request(app) 13 | .post('/api/orders') 14 | .set('Cookie', global.signin()) 15 | .send({ ticketId }) 16 | .expect(404); 17 | }); 18 | 19 | it('returns an error if the ticket is already reserved', async () => { 20 | const ticket = Ticket.build({ 21 | id: new mongoose.Types.ObjectId().toHexString(), 22 | title: 'concert', 23 | price: 20, 24 | }); 25 | await ticket.save(); 26 | 27 | const order = Order.build({ 28 | ticket, 29 | userId: 'laskdflkajsdf', 30 | status: OrderStatus.Created, 31 | expiresAt: new Date(), 32 | }); 33 | await order.save(); 34 | 35 | await request(app) 36 | .post('/api/orders') 37 | .set('Cookie', global.signin()) 38 | .send({ ticketId: ticket.id }) 39 | .expect(400); 40 | }); 41 | 42 | it('reserves a ticket', async () => { 43 | const ticket = Ticket.build({ 44 | id: new mongoose.Types.ObjectId().toHexString(), 45 | title: 'concert', 46 | price: 20, 47 | }); 48 | await ticket.save(); 49 | 50 | await request(app) 51 | .post('/api/orders') 52 | .set('Cookie', global.signin()) 53 | .send({ ticketId: ticket.id }) 54 | .expect(201); 55 | }); 56 | 57 | it('emit an order created event', async () => { 58 | const ticket = Ticket.build({ 59 | id: new mongoose.Types.ObjectId().toHexString(), 60 | title: 'concert', 61 | price: 20, 62 | }); 63 | await ticket.save(); 64 | 65 | await request(app) 66 | .post('/api/orders') 67 | .set('Cookie', global.signin()) 68 | .send({ ticketId: ticket.id }) 69 | .expect(201); 70 | 71 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 72 | }); -------------------------------------------------------------------------------- /orders/src/routes/__test__/show.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import request from 'supertest'; 3 | import app from '../../app'; 4 | import { Ticket } from '../../models/ticket'; 5 | 6 | it('fetches the order', async () => { 7 | // Create a ticket 8 | const ticket = Ticket.build({ 9 | id: new mongoose.Types.ObjectId().toHexString(), 10 | title: 'concert', 11 | price: 20, 12 | }); 13 | await ticket.save(); 14 | 15 | const user = global.signin(); 16 | // make a request to build an order with this ticket 17 | const { body: order } = await request(app) 18 | .post('/api/orders') 19 | .set('Cookie', user) 20 | .send({ ticketId: ticket.id }) 21 | .expect(201); 22 | 23 | // make request to fetch the order 24 | const { body: fetchedOrder } = await request(app) 25 | .get(`/api/orders/${order.id}`) 26 | .set('Cookie', user) 27 | .send() 28 | .expect(200); 29 | 30 | expect(fetchedOrder.id).toEqual(order.id); 31 | }); 32 | 33 | it('returns an error if one user tries to fetch another users order', async () => { 34 | // Create a ticket 35 | const ticket = Ticket.build({ 36 | id: new mongoose.Types.ObjectId().toHexString(), 37 | title: 'concert', 38 | price: 20, 39 | }); 40 | await ticket.save(); 41 | 42 | const user = global.signin(); 43 | // make a request to build an order with this ticket 44 | const { body: order } = await request(app) 45 | .post('/api/orders') 46 | .set('Cookie', user) 47 | .send({ ticketId: ticket.id }) 48 | .expect(201); 49 | 50 | // make request to fetch the order 51 | await request(app) 52 | .get(`/api/orders/${order.id}`) 53 | .set('Cookie', global.signin()) 54 | .send() 55 | .expect(401); 56 | }); 57 | -------------------------------------------------------------------------------- /orders/src/routes/delete.ts: -------------------------------------------------------------------------------- 1 | import { NotAuthorizedError, NotFoundError, OrderStatus, requireAuth } from '@black_sheep/common'; 2 | import express, { Request, Response } from 'express'; 3 | import { OrderCancelledPublisher } from '../events/publishers/order-cancelled-publisher'; 4 | import { Order } from '../models/order'; 5 | import { natsWrapper } from '../nats-wrapper'; 6 | 7 | const router = express.Router(); 8 | 9 | 10 | router.delete('/api/orders/:orderId', 11 | requireAuth, 12 | async (req: Request, res: Response) => { 13 | const { orderId } = req.params; 14 | 15 | const order = await Order.findById(orderId).populate('ticket'); 16 | 17 | if (!order) { 18 | throw new NotFoundError(); 19 | } 20 | 21 | if (order.userId !== req.currentUser!.id) { 22 | throw new NotAuthorizedError(); 23 | } 24 | 25 | order.status = OrderStatus.Cancelled; 26 | await order.save(); 27 | 28 | // publishing an event sayung this was cancelled 29 | new OrderCancelledPublisher(natsWrapper.client).publish({ 30 | id: order.id, 31 | version: order.version, 32 | ticket: { 33 | id: order.ticket.id 34 | }, 35 | }); 36 | 37 | res.status(204).send(order); 38 | }); 39 | 40 | export { router as deleteOrderRouter }; -------------------------------------------------------------------------------- /orders/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { requireAuth } from '@black_sheep/common'; 2 | import express, { Request, Response } from 'express'; 3 | import { Order } from '../models/order'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/api/orders', requireAuth, async (req: Request, res: Response) => { 8 | const orders = await Order.find({ 9 | userId: req.currentUser!.id 10 | }).populate('ticket'); 11 | 12 | res.send(orders); 13 | }); 14 | 15 | export { router as indexOrderRouter }; -------------------------------------------------------------------------------- /orders/src/routes/news.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import mongoose from 'mongoose'; 3 | import { BadRequestError, NotFoundError, OrderStatus, requireAuth, validateRequest } from '@black_sheep/common'; 4 | import { body } from 'express-validator'; 5 | import { Ticket } from '../models/ticket'; 6 | import { Order } from '../models/order'; 7 | import { OrderCreatedPublisher } from '../events/publishers/order-created-publisher'; 8 | import { natsWrapper } from '../nats-wrapper'; 9 | 10 | const router = express.Router(); 11 | 12 | const EXPIRATION_WINDOW_SECONDS = 1 * 60; 13 | 14 | router.post('/api/orders', 15 | requireAuth, 16 | [ 17 | body('ticketId') 18 | .not() 19 | .isEmpty() 20 | .custom((input: string) => mongoose.Types.ObjectId.isValid(input)) 21 | .withMessage('TicketId must be provided') 22 | ], 23 | validateRequest, 24 | async (req: Request, res: Response) => { 25 | const { ticketId } = req.body; 26 | 27 | // find the ticket the user is trying to order in the database 28 | const ticket = await Ticket.findById(ticketId); 29 | if (!ticket) { 30 | throw new NotFoundError(); 31 | } 32 | 33 | // Make sure that this ticket is not already reserved 34 | // Run query to look at all orders. Find an order where the ticket 35 | // is the ticket we just found *and* the orders satatus is *not* cancelled. 36 | // If we find an order from that means the ticket *is* reserved 37 | 38 | // make sure that this ticket is not already reserved 39 | const isReserved = await ticket.isReserved(); 40 | if (isReserved) { 41 | throw new BadRequestError('Ticket is already reserved'); 42 | } 43 | 44 | // calculate an expiration date for this order 45 | const expiration = new Date(); 46 | expiration.setSeconds(expiration.getSeconds() + EXPIRATION_WINDOW_SECONDS) 47 | 48 | // build the order and save it to the database 49 | const order = Order.build({ 50 | userId: req.currentUser!.id, 51 | status: OrderStatus.Created, 52 | expiresAt: expiration, 53 | ticket: ticket 54 | }); 55 | 56 | await order.save(); 57 | 58 | // publish an event saying tha an order was created 59 | new OrderCreatedPublisher(natsWrapper.client).publish({ 60 | id: order.id, 61 | version: order.version, 62 | status: order.status, 63 | userId: order.userId, 64 | expiresAt: order.expiresAt.toISOString(), 65 | ticket: { 66 | id: ticket.id, 67 | price: ticket.price, 68 | } 69 | }); 70 | 71 | res.status(201).send(order); 72 | }); 73 | 74 | export { router as newOrderRouter }; -------------------------------------------------------------------------------- /orders/src/routes/show.ts: -------------------------------------------------------------------------------- 1 | import { NotAuthorizedError, NotFoundError, requireAuth } from '@black_sheep/common'; 2 | import express, { Request, Response } from 'express'; 3 | import { Order } from '../models/order'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/api/orders/:orderId', 8 | requireAuth, 9 | async (req: Request, res: Response) => { 10 | const order = await Order.findById(req.params.orderId).populate('ticket'); 11 | 12 | if (!order) { 13 | throw new NotFoundError(); 14 | } 15 | 16 | if (order.userId !== req.currentUser!.id) { 17 | throw new NotAuthorizedError(); 18 | } 19 | 20 | res.send(order); 21 | }); 22 | 23 | export { router as showOrderRouter }; -------------------------------------------------------------------------------- /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 | var signin: () => string[]; 7 | } 8 | 9 | jest.mock('../nats-wrapper.ts'); 10 | 11 | let mongo: any; 12 | 13 | beforeAll(async () => { 14 | process.env.JWT_KEY = 'asdf'; 15 | 16 | mongo = await MongoMemoryServer.create(); 17 | const mongoUri = await mongo.getUri(); 18 | 19 | await mongoose.connect(mongoUri, {}); 20 | }); 21 | 22 | beforeEach(async () => { 23 | jest.clearAllMocks(); 24 | 25 | const collections = await mongoose.connection.db.collections(); 26 | for (let collection of collections) { 27 | await collection.deleteMany({}); 28 | } 29 | }); 30 | 31 | afterAll(async () => { 32 | await mongo.stop(); 33 | await mongoose.connection.close(); 34 | }); 35 | 36 | global.signin = () => { 37 | // Build a JWT payload. { id, email } 38 | const payload = { 39 | id: new mongoose.Types.ObjectId().toHexString(), 40 | email: 'test@test.com', 41 | }; 42 | 43 | // Create the JWT! 44 | const token = jwt.sign(payload, process.env.JWT_KEY!); 45 | 46 | // Build session Object. { jwt: MY_JWT } 47 | const session = { jwt: token }; 48 | 49 | // Turn that session into JSON 50 | const sessionJSON = JSON.stringify(session); 51 | 52 | // Take JSON and encode it as base64 53 | const base64 = Buffer.from(sessionJSON).toString('base64'); 54 | 55 | // return a string thats the cookie with the encoded data 56 | return [`session=${base64}`]; 57 | } 58 | 59 | 60 | -------------------------------------------------------------------------------- /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 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ticketing", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /payments/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /payments/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /payments/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # --only=prod so you don't create dev dependencies 6 | RUN npm install --omit=dev 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /payments/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payments", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev --poll src/index.ts", 8 | "test": "jest --watchAll --no-cache --detectOpenHandles", 9 | "test:ci": "jest --detectOpenHandles" 10 | }, 11 | "jest": { 12 | "preset": "ts-jest", 13 | "testEnvironment": "node", 14 | "setupFilesAfterEnv": [ 15 | "./src/test/setup.ts" 16 | ], 17 | "verbose": true, 18 | "testTimeout": 1000000 19 | }, 20 | "keywords": [], 21 | "author": "victor hugo aguilar", 22 | "license": "ISC", 23 | "dependencies": { 24 | "@black_sheep/common": "^1.0.13", 25 | "@types/cookie-session": "^2.0.44", 26 | "@types/express": "^4.17.13", 27 | "@types/jsonwebtoken": "^8.5.8", 28 | "@types/mongoose": "^5.11.97", 29 | "@types/node": "^18.0.0", 30 | "cookie-session": "^2.0.0", 31 | "express": "^4.18.1", 32 | "express-async-errors": "^3.1.1", 33 | "express-validator": "^6.14.2", 34 | "jsonwebtoken": "^8.5.1", 35 | "mongoose": "^6.4.0", 36 | "mongoose-update-if-current": "^1.4.0", 37 | "stripe": "^9.12.0", 38 | "ts-node-dev": "^2.0.0", 39 | "typescript": "^4.7.4" 40 | }, 41 | "devDependencies": { 42 | "@types/jest": "^28.1.3", 43 | "@types/supertest": "^2.0.12", 44 | "jest": "^28.1.2", 45 | "mongodb-memory-server": "^8.7.1", 46 | "supertest": "^6.2.3", 47 | "ts-jest": "^28.0.5" 48 | } 49 | } -------------------------------------------------------------------------------- /payments/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | client: { 3 | publish: jest 4 | .fn() 5 | .mockImplementation( 6 | (subject: string, data: string, callback: () => void) => { 7 | callback() 8 | }) 9 | }, 10 | }; -------------------------------------------------------------------------------- /payments/src/__mocks__/stripe.ts.old: -------------------------------------------------------------------------------- 1 | export const stripe = { 2 | charges: { 3 | create: jest.fn().mockResolvedValue({}), 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /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 { NotFoundError, errorHandler, currentUser } from '@black_sheep/common'; 6 | import { createChargeRouter } from './routes/new'; 7 | 8 | const app = express(); 9 | app.set('trust proxy', true); 10 | app.use(json()); 11 | app.use(cookieSession({ 12 | signed: false, 13 | secure: process.env.NODE_ENV !== 'test' 14 | })); 15 | 16 | app.use(currentUser); 17 | 18 | // config routes custom 19 | app.use(createChargeRouter); 20 | 21 | app.all('*', async (req, res) => { 22 | throw new NotFoundError(); 23 | }); 24 | 25 | app.use(errorHandler); 26 | 27 | export default app; -------------------------------------------------------------------------------- /payments/src/events/listeners/__test__/order-cancelled-listener.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Message } from 'node-nats-streaming'; 3 | import { OrderCancelledListener } from '../order-cancelled-listener'; 4 | import { natsWrapper } from '../../../nats-wrapper'; 5 | import { Order } from '../../../models/order'; 6 | import { OrderCancelledEvent, OrderStatus } from '@black_sheep/common'; 7 | 8 | const setup = async () => { 9 | const listener = new OrderCancelledListener(natsWrapper.client); 10 | 11 | const order = Order.build({ 12 | id: new mongoose.Types.ObjectId().toHexString(), 13 | status: OrderStatus.Created, 14 | price: 10, 15 | userId: 'asldkfj', 16 | version: 0, 17 | }); 18 | await order.save(); 19 | 20 | const data: OrderCancelledEvent['data'] = { 21 | id: order.id, 22 | version: 1, 23 | ticket: { 24 | id: 'asldkfj', 25 | }, 26 | }; 27 | 28 | // @ts-ignore 29 | const msg: Message = { 30 | ack: jest.fn(), 31 | }; 32 | 33 | return { listener, data, msg, order }; 34 | }; 35 | 36 | it('updates the status of the order', async () => { 37 | const { listener, data, msg, order } = await setup(); 38 | 39 | await listener.onMessage(data, msg); 40 | 41 | const updatedOrder = await Order.findById(order.id); 42 | 43 | expect(updatedOrder!.status).toEqual(OrderStatus.Cancelled); 44 | }); 45 | 46 | it('acks the message', async () => { 47 | const { listener, data, msg, order } = await setup(); 48 | 49 | await listener.onMessage(data, msg); 50 | 51 | expect(msg.ack).toHaveBeenCalled(); 52 | }); 53 | -------------------------------------------------------------------------------- /payments/src/events/listeners/__test__/order-created-listener.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Message } from 'node-nats-streaming'; 3 | import { OrderCreatedListener } from '../order-created-listener'; 4 | import { Order } from '../../../models/order'; 5 | import { OrderCreatedEvent, OrderStatus } from '@black_sheep/common'; 6 | import { natsWrapper } from '../../../nats-wrapper'; 7 | 8 | const setup = async () => { 9 | const listener = new OrderCreatedListener(natsWrapper.client); 10 | 11 | const data: OrderCreatedEvent['data'] = { 12 | id: new mongoose.Types.ObjectId().toHexString(), 13 | version: 0, 14 | expiresAt: 'alskdjf', 15 | userId: 'alskdjf', 16 | status: OrderStatus.Created, 17 | ticket: { 18 | id: 'alskdfj', 19 | price: 10, 20 | }, 21 | }; 22 | 23 | // @ts-ignore 24 | const msg: Message = { 25 | ack: jest.fn(), 26 | }; 27 | 28 | return { listener, data, msg }; 29 | }; 30 | 31 | it('replicates the order info', async () => { 32 | const { listener, data, msg } = await setup(); 33 | 34 | await listener.onMessage(data, msg); 35 | 36 | const order = await Order.findById(data.id); 37 | 38 | expect(order!.price).toEqual(data.ticket.price); 39 | }); 40 | 41 | it('acks the message', async () => { 42 | const { listener, data, msg } = await setup(); 43 | 44 | await listener.onMessage(data, msg); 45 | 46 | expect(msg.ack).toHaveBeenCalled(); 47 | }); 48 | -------------------------------------------------------------------------------- /payments/src/events/listeners/order-cancelled-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCancelledEvent, OrderStatus, Subjects } from "@black_sheep/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 = Subjects.OrderCancelled; 8 | 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({ 21 | status: OrderStatus.Cancelled 22 | }); 23 | const orderSaved = await order.save(); 24 | console.log(orderSaved); 25 | 26 | msg.ack(); 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /payments/src/events/listeners/order-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'node-nats-streaming'; 2 | import { queueGroupName } from './queue-group-name'; 3 | import { Order } from '../../models/order'; 4 | import { Listener, OrderCreatedEvent, Subjects } from '@black_sheep/common'; 5 | 6 | export class OrderCreatedListener extends Listener { 7 | readonly subject: Subjects.OrderCreated = Subjects.OrderCreated; 8 | queueGroupName = queueGroupName; 9 | 10 | async onMessage(data: OrderCreatedEvent['data'], msg: Message) { 11 | const order = Order.build({ 12 | id: data.id, 13 | price: data.ticket.price, 14 | status: data.status, 15 | userId: data.userId, 16 | version: data.version, 17 | }); 18 | const orderSaved = await order.save(); 19 | console.log(orderSaved); 20 | 21 | msg.ack(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /payments/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGroupName = 'payments-service'; 2 | -------------------------------------------------------------------------------- /payments/src/events/publishers/payment-created-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Publisher, Subjects, PaymentCreatedEvent } from "@black_sheep/common"; 2 | 3 | export class PaymentCreatedPublisher extends Publisher { 4 | subject: Subjects.PaymentCreated = Subjects.PaymentCreated; 5 | } 6 | -------------------------------------------------------------------------------- /payments/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import mongoose from 'mongoose'; 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 | 8 | const start = async () => { 9 | if (!process.env.JWT_KEY) { 10 | throw new Error('JWT_KEY not found, must be defined'); 11 | } 12 | 13 | if (!process.env.MONGO_URI) { 14 | throw new Error('MONGO_URI not found, must be defined'); 15 | } 16 | 17 | if (!process.env.NATS_CLIENT_ID) { 18 | throw new Error('NATS_CLIENT_ID not found, must be defined'); 19 | } 20 | 21 | if (!process.env.NATS_URL) { 22 | throw new Error('NATS_URL not found, must be defined'); 23 | } 24 | 25 | if (!process.env.NATS_CLUSTER_ID) { 26 | throw new Error('NATS_CLUSTER_ID not found, must be defined'); 27 | } 28 | try { 29 | 30 | // Event service 31 | // connect to service nats 32 | await natsWrapper.connect( 33 | process.env.NATS_CLUSTER_ID, 34 | process.env.NATS_CLIENT_ID, 35 | process.env.NATS_URL 36 | ); 37 | // for close app close service too gracefull shutdown 38 | natsWrapper.client.on('close', () => { 39 | console.log('NATS connection close'); 40 | process.exit(); 41 | }); 42 | process.on('SIGINT', () => natsWrapper.client.close()); 43 | process.on('SIGTERM', () => natsWrapper.client.close()); 44 | 45 | new OrderCreatedListener(natsWrapper.client).listen(); 46 | new OrderCancelledListener(natsWrapper.client).listen(); 47 | 48 | // connect to mongodb 49 | await mongoose.connect(process.env.MONGO_URI, {}); 50 | console.info('Connect to MongoDb'); 51 | } catch (error) { 52 | console.error(error); 53 | } 54 | } 55 | 56 | app.listen(3000, () => { 57 | console.log('Listen from port: ', 3000) 58 | }); 59 | 60 | start(); 61 | -------------------------------------------------------------------------------- /payments/src/models/order.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { updateIfCurrentPlugin } from 'mongoose-update-if-current'; 3 | import { OrderStatus } from '@black_sheep/common'; 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 | { 26 | userId: { 27 | type: String, 28 | required: true, 29 | }, 30 | price: { 31 | type: Number, 32 | required: true, 33 | }, 34 | status: { 35 | type: String, 36 | required: true, 37 | }, 38 | }, 39 | { 40 | toJSON: { 41 | transform(doc, ret) { 42 | ret.id = ret._id; 43 | delete ret._id; 44 | }, 45 | }, 46 | } 47 | ); 48 | 49 | orderSchema.set('versionKey', 'version'); 50 | orderSchema.plugin(updateIfCurrentPlugin); 51 | 52 | orderSchema.statics.build = (attrs: OrderAttrs) => { 53 | return new Order({ 54 | _id: attrs.id, 55 | version: attrs.version, 56 | price: attrs.price, 57 | userId: attrs.userId, 58 | status: attrs.status, 59 | }); 60 | }; 61 | 62 | const Order = mongoose.model('Order', orderSchema); 63 | 64 | export { Order }; 65 | -------------------------------------------------------------------------------- /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 | { 19 | orderId: { 20 | required: true, 21 | type: String, 22 | }, 23 | stripeId: { 24 | required: true, 25 | type: String, 26 | }, 27 | }, 28 | { 29 | toJSON: { 30 | transform(doc, ret) { 31 | ret.id = ret._id; 32 | delete ret._id; 33 | }, 34 | }, 35 | } 36 | ); 37 | 38 | paymentSchema.statics.build = (attrs: PaymentAttrs) => { 39 | return new Payment(attrs); 40 | }; 41 | 42 | const Payment = mongoose.model( 43 | 'Payment', 44 | paymentSchema 45 | ); 46 | 47 | export { Payment }; 48 | -------------------------------------------------------------------------------- /payments/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from 'node-nats-streaming'; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | connect(clusterId: string, clientId: string, url: string) { 7 | this._client = nats.connect(clusterId, clientId, { url }); 8 | 9 | return new Promise((resolve, reject) => { 10 | this._client!.on('connect', () => { 11 | console.log('Connected to NATS'); 12 | resolve(); 13 | }); 14 | 15 | this._client!.on('error', (err) => { 16 | reject(err); 17 | }); 18 | }); 19 | } 20 | 21 | get client() { 22 | if (!this._client) { 23 | throw new Error('Cannot acces NATS client before connecting'); 24 | } 25 | return this._client; 26 | } 27 | }; 28 | 29 | export const natsWrapper = new NatsWrapper(); -------------------------------------------------------------------------------- /payments/src/routes/__test__/new.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import request from 'supertest'; 3 | import app from '../../app'; 4 | import { Order } from '../../models/order'; 5 | import { stripe } from '../../stripe'; 6 | import { Payment } from '../../models/payment'; 7 | import { OrderStatus } from '@black_sheep/common'; 8 | 9 | it('returns a 404 when purchasing an order that does not exist', async () => { 10 | await request(app) 11 | .post('/api/payments') 12 | .set('Cookie', global.signin()) 13 | .send({ 14 | token: 'asldkfj', 15 | orderId: new mongoose.Types.ObjectId().toHexString(), 16 | }) 17 | .expect(404); 18 | }); 19 | 20 | it('returns a 401 when purchasing an order that doesnt belong to the user', async () => { 21 | const order = Order.build({ 22 | id: new mongoose.Types.ObjectId().toHexString(), 23 | userId: new mongoose.Types.ObjectId().toHexString(), 24 | version: 0, 25 | price: 20, 26 | status: OrderStatus.Created, 27 | }); 28 | await order.save(); 29 | 30 | await request(app) 31 | .post('/api/payments') 32 | .set('Cookie', global.signin()) 33 | .send({ 34 | token: 'asldkfj', 35 | orderId: order.id, 36 | }) 37 | .expect(401); 38 | }); 39 | 40 | it('returns a 400 when purchasing a cancelled order', async () => { 41 | const userId = new mongoose.Types.ObjectId().toHexString(); 42 | const order = Order.build({ 43 | id: new mongoose.Types.ObjectId().toHexString(), 44 | userId, 45 | version: 0, 46 | price: 20, 47 | status: OrderStatus.Cancelled, 48 | }); 49 | await order.save(); 50 | 51 | await request(app) 52 | .post('/api/payments') 53 | .set('Cookie', global.signin(userId)) 54 | .send({ 55 | orderId: order.id, 56 | token: 'asdlkfj', 57 | }) 58 | .expect(400); 59 | }); 60 | 61 | it('returns a 201 with valid inputs', async () => { 62 | const userId = new mongoose.Types.ObjectId().toHexString(); 63 | const price = Math.floor(Math.random() * 100000); 64 | const order = Order.build({ 65 | id: new mongoose.Types.ObjectId().toHexString(), 66 | userId, 67 | version: 0, 68 | price, 69 | status: OrderStatus.Created, 70 | }); 71 | const orderSaved = await order.save(); 72 | console.log('orderSaved', orderSaved); 73 | 74 | await request(app) 75 | .post('/api/payments') 76 | .set('Cookie', global.signin(userId)) 77 | .send({ 78 | token: 'tok_visa', 79 | orderId: order.id, 80 | }) 81 | .expect(201); 82 | 83 | const stripeCharges = await stripe.charges.list({ limit: 50 }); 84 | const stripeCharge = stripeCharges.data.find((charge) => { 85 | return charge.amount === price * 100; 86 | }); 87 | 88 | expect(stripeCharge).toBeDefined(); 89 | expect(stripeCharge!.currency).toEqual('usd'); 90 | 91 | const payment = await Payment.findOne({ 92 | orderId: order.id, 93 | stripeId: stripeCharge!.id, 94 | }); 95 | expect(payment).not.toBeNull(); 96 | }); 97 | -------------------------------------------------------------------------------- /payments/src/routes/new.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestError, NotAuthorizedError, NotFoundError, OrderStatus, requireAuth, validateRequest } from '@black_sheep/common'; 2 | import express, { Request, Response } from 'express'; 3 | import { body } from 'express-validator'; 4 | import { PaymentCreatedPublisher } from '../events/publishers/payment-created-publisher'; 5 | import { Order } from '../models/order'; 6 | import { Payment } from '../models/payment'; 7 | import { natsWrapper } from '../nats-wrapper'; 8 | import { stripe } from '../stripe'; 9 | 10 | const router = express.Router(); 11 | 12 | router.post( 13 | '/api/payments', 14 | requireAuth, 15 | [ 16 | body('token') 17 | .not() 18 | .isEmpty() 19 | .withMessage('Not found token') 20 | , 21 | body('orderId') 22 | .not() 23 | .isEmpty() 24 | .withMessage('Not found orderId') 25 | ], 26 | validateRequest, 27 | async (req: Request, res: Response) => { 28 | const { token, orderId } = req.body; 29 | 30 | const order = await Order.findById(orderId); 31 | console.info('order', order); 32 | 33 | if (!order) { 34 | console.error('order not found in new.ts'); 35 | throw new NotFoundError(); 36 | } 37 | if (order.userId !== req.currentUser!.id) { 38 | console.error('not authorized in new.ts'); 39 | throw new NotAuthorizedError(); 40 | } 41 | if (order.status === OrderStatus.Cancelled) { 42 | console.error('cannot pay for an cancelled order in new.ts'); 43 | throw new BadRequestError('cannot pay for an cancelled order'); 44 | } 45 | 46 | const charge = await stripe.charges.create({ 47 | currency: 'usd', 48 | amount: order.price * 100, 49 | source: token, 50 | }); 51 | console.log('charge', charge); 52 | 53 | const payment = Payment.build({ 54 | orderId, 55 | stripeId: charge.id, 56 | }); 57 | console.log('payment', payment); 58 | await payment.save(); 59 | 60 | new PaymentCreatedPublisher(natsWrapper.client).publish({ 61 | id: payment.id, 62 | orderId: payment.orderId, 63 | stripeId: payment.stripeId, 64 | }); 65 | 66 | res.status(201).send({ id: payment.id }); 67 | } 68 | ); 69 | 70 | export { router as createChargeRouter }; -------------------------------------------------------------------------------- /payments/src/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_KEY!, { 4 | apiVersion: '2020-08-27', 5 | }); 6 | 7 | -------------------------------------------------------------------------------- /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 | var signin: (id?: string) => string[]; 7 | } 8 | 9 | jest.mock('../nats-wrapper.ts'); 10 | 11 | process.env.STRIPE_KEY = 'sk_test_hnfrAm8rOkryFEnV23jjfFlw'; 12 | 13 | let mongo: any; 14 | 15 | beforeAll(async () => { 16 | process.env.JWT_KEY = 'asdfasdf'; 17 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; 18 | 19 | mongo = await MongoMemoryServer.create(); 20 | const mongoUri = await mongo.getUri(); 21 | 22 | await mongoose.connect(mongoUri, {}); 23 | }); 24 | 25 | beforeEach(async () => { 26 | jest.clearAllMocks(); 27 | 28 | const collections = await mongoose.connection.db.collections(); 29 | 30 | for (let collection of collections) { 31 | await collection.deleteMany({}); 32 | } 33 | }); 34 | 35 | afterAll(async () => { 36 | await mongo.stop(); 37 | await mongoose.connection.close(); 38 | }, 100000); 39 | 40 | global.signin = (id?: string) => { 41 | // Build a JWT payload. { id, email } 42 | const payload = { 43 | id: id || new mongoose.Types.ObjectId().toHexString(), 44 | email: 'test@test.com', 45 | }; 46 | 47 | // Create the JWT! 48 | const token = jwt.sign(payload, process.env.JWT_KEY!); 49 | 50 | // Build session Object. { jwt: MY_JWT } 51 | const session = { jwt: token }; 52 | 53 | // Turn that session into JSON 54 | const sessionJSON = JSON.stringify(session); 55 | 56 | // Take JSON and encode it as base64 57 | const base64 = Buffer.from(sessionJSON).toString('base64'); 58 | 59 | // return a string thats the cookie with the encoded data 60 | return [`session=${base64}`]; 61 | } -------------------------------------------------------------------------------- /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 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2alpha3 2 | kind: Config 3 | deploy: 4 | kubectl: 5 | manifests: 6 | - ./infra/k8s/* 7 | build: 8 | local: 9 | push: false 10 | artifacts: 11 | - image: victoruugo/auth 12 | context: auth 13 | docker: 14 | dockerfile: Dockerfile 15 | sync: 16 | manual: 17 | - src: "scr/**/*.ts" 18 | dest: . 19 | - image: victoruugo/ticketing-client 20 | context: client 21 | docker: 22 | dockerfile: Dockerfile 23 | sync: 24 | manual: 25 | - src: "**/*.js" 26 | dest: . 27 | - image: victoruugo/tickets 28 | context: tickets 29 | docker: 30 | dockerfile: Dockerfile 31 | sync: 32 | manual: 33 | - src: "scr/**/*.ts" 34 | dest: . 35 | - image: victoruugo/orders 36 | context: orders 37 | docker: 38 | dockerfile: Dockerfile 39 | sync: 40 | manual: 41 | - src: "scr/**/*.ts" 42 | dest: . 43 | - image: victoruugo/expiration 44 | context: expiration 45 | docker: 46 | dockerfile: Dockerfile 47 | sync: 48 | manual: 49 | - src: "scr/**/*.ts" 50 | dest: . 51 | - image: victoruugo/payments 52 | context: payments 53 | docker: 54 | dockerfile: Dockerfile 55 | sync: 56 | manual: 57 | - src: "scr/**/*.ts" 58 | dest: . 59 | -------------------------------------------------------------------------------- /tickets/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /tickets/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /tickets/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | # --only=prod so you don't create dev dependencies 6 | RUN npm install --omit=dev 7 | COPY . . 8 | 9 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /tickets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tickets", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev --poll src/index.ts", 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": "victor hugo aguilar", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@black_sheep/common": "^1.0.13", 23 | "@types/cookie-session": "^2.0.44", 24 | "@types/express": "^4.17.13", 25 | "@types/jsonwebtoken": "^8.5.8", 26 | "@types/mongoose": "^5.11.97", 27 | "@types/node": "^18.0.0", 28 | "cookie-session": "^2.0.0", 29 | "express": "^4.18.1", 30 | "express-async-errors": "^3.1.1", 31 | "express-validator": "^6.14.2", 32 | "jsonwebtoken": "^8.5.1", 33 | "mongoose": "^6.4.0", 34 | "mongoose-update-if-current": "^1.4.0", 35 | "ts-node-dev": "^2.0.0", 36 | "typescript": "^4.7.4" 37 | }, 38 | "devDependencies": { 39 | "@types/jest": "^28.1.3", 40 | "@types/supertest": "^2.0.12", 41 | "jest": "^28.1.2", 42 | "mongodb-memory-server": "^8.7.1", 43 | "supertest": "^6.2.3", 44 | "ts-jest": "^28.0.5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tickets/src/__mocks__/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | export const natsWrapper = { 2 | client: { 3 | publish: jest 4 | .fn() 5 | .mockImplementation( 6 | (subject: string, data: string, callback: () => void) => { 7 | callback() 8 | }) 9 | }, 10 | }; -------------------------------------------------------------------------------- /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 { NotFoundError, errorHandler, currentUser } from '@black_sheep/common'; 6 | 7 | import { createTicketRouter } from './routes/new'; 8 | import { showTicketRouter } from './routes/show'; 9 | import { indexTicketRouter } from './routes'; 10 | import { updateTicketRouter } from './routes/update'; 11 | 12 | const app = express(); 13 | app.set('trust proxy', true); 14 | app.use(json()); 15 | app.use(cookieSession({ 16 | signed: false, 17 | secure: process.env.NODE_ENV !== 'test' 18 | })); 19 | 20 | app.use(currentUser); 21 | 22 | app.use(createTicketRouter); 23 | app.use(showTicketRouter); 24 | app.use(indexTicketRouter); 25 | app.use(updateTicketRouter); 26 | 27 | 28 | app.all('*', async (req, res) => { 29 | throw new NotFoundError(); 30 | }); 31 | 32 | app.use(errorHandler); 33 | 34 | export default app; -------------------------------------------------------------------------------- /tickets/src/events/listeners/__test__/order-cancelled-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { OrderCancelledEvent } from '@black_sheep/common'; 2 | import mongoose from 'mongoose'; 3 | import { Message } from 'node-nats-streaming'; 4 | import { Ticket } from '../../../model/ticket'; 5 | import { natsWrapper } from '../../../nats-wrapper'; 6 | import { OrderCancelledListener } from '../order-cancelled-listener'; 7 | 8 | const setup = async () => { 9 | const listener = new OrderCancelledListener(natsWrapper.client); 10 | 11 | const orderId = new mongoose.Types.ObjectId().toHexString(); 12 | const ticket = Ticket.build({ 13 | title: 'concert', 14 | price: 20, 15 | userId: 'asdf', 16 | }); 17 | ticket.set({ orderId }); 18 | await ticket.save(); 19 | 20 | const data: OrderCancelledEvent['data'] = { 21 | id: orderId, 22 | version: 0, 23 | ticket: { 24 | id: ticket.id, 25 | }, 26 | }; 27 | 28 | // @ts-ignore 29 | const msg: Message = { 30 | ack: jest.fn(), 31 | }; 32 | 33 | return { msg, data, ticket, orderId, listener }; 34 | }; 35 | 36 | it('updates the ticket, publishes an event, and acks the message', async () => { 37 | const { msg, data, ticket, orderId, listener } = await setup(); 38 | 39 | await listener.onMessage(data, msg); 40 | 41 | const updatedTicket = await Ticket.findById(ticket.id); 42 | expect(updatedTicket!.orderId).not.toBeDefined(); 43 | expect(msg.ack).toHaveBeenCalled(); 44 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 45 | }); 46 | -------------------------------------------------------------------------------- /tickets/src/events/listeners/__test__/order-created-listener.test.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'node-nats-streaming'; 2 | import mongoose from 'mongoose'; 3 | import { OrderCreatedListener } from '../order-created-listener'; 4 | import { natsWrapper } from '../../../nats-wrapper'; 5 | import { Ticket } from '../../../model/ticket'; 6 | import { OrderCreatedEvent, OrderStatus } from '@black_sheep/common'; 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: 99, 16 | userId: 'asdf', 17 | }); 18 | await ticket.save(); 19 | 20 | // Create the fake data event 21 | const data: OrderCreatedEvent['data'] = { 22 | id: new mongoose.Types.ObjectId().toHexString(), 23 | version: 0, 24 | status: OrderStatus.Created, 25 | userId: 'alskdfj', 26 | expiresAt: 'alskdjf', 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 | return { listener, ticket, data, msg }; 39 | }; 40 | 41 | it('sets the userId of the ticket', async () => { 42 | const { listener, ticket, data, msg } = await setup(); 43 | 44 | await listener.onMessage(data, msg); 45 | 46 | const updatedTicket = await Ticket.findById(ticket.id); 47 | 48 | expect(updatedTicket!.orderId).toEqual(data.id); 49 | }); 50 | 51 | it('acks the message', async () => { 52 | const { listener, ticket, data, msg } = await setup(); 53 | await listener.onMessage(data, msg); 54 | 55 | expect(msg.ack).toHaveBeenCalled(); 56 | }); 57 | 58 | it('publishes a ticket updated event', async () => { 59 | const { listener, ticket, data, msg } = await setup(); 60 | 61 | await listener.onMessage(data, msg); 62 | 63 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 64 | 65 | const ticketUpdatedData = JSON.parse( 66 | (natsWrapper.client.publish as jest.Mock).mock.calls[0][1] 67 | ); 68 | 69 | expect(data.id).toEqual(ticketUpdatedData.orderId); 70 | }); -------------------------------------------------------------------------------- /tickets/src/events/listeners/order-cancelled-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCancelledEvent, Subjects } from "@black_sheep/common"; 2 | import { Message } from "node-nats-streaming"; 3 | import { Ticket } from "../../model/ticket"; 4 | import { TicketUpdatedPublisher } from "../publishers/ticket-updated-publisher"; 5 | import { queueGrouName } from './queue-group-name' 6 | 7 | export class OrderCancelledListener extends Listener { 8 | readonly subject = Subjects.OrderCancelled; 9 | queueGroupName = queueGrouName; 10 | 11 | async onMessage(data: OrderCancelledEvent['data'], msg: Message) { 12 | 13 | const ticket = await Ticket.findById(data.ticket.id); 14 | 15 | if (!ticket) { 16 | throw new Error('Ticket not found'); 17 | } 18 | 19 | ticket.set({ orderId: undefined }); 20 | await ticket.save(); 21 | 22 | await new TicketUpdatedPublisher(this.client).publish({ 23 | id: ticket.id, 24 | orderId: ticket.orderId, 25 | userId: ticket.userId, 26 | price: ticket.price, 27 | title: ticket.title, 28 | version: ticket.version, 29 | }); 30 | 31 | msg.ack(); 32 | 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /tickets/src/events/listeners/order-created-listener.ts: -------------------------------------------------------------------------------- 1 | import { Listener, OrderCreatedEvent, Subjects } from "@black_sheep/common"; 2 | import { Message } from "node-nats-streaming"; 3 | import { Ticket } from "../../model/ticket"; 4 | import { TicketUpdatedPublisher } from "../publishers/ticket-updated-publisher"; 5 | import { queueGrouName } from "./queue-group-name"; 6 | 7 | export class OrderCreatedListener extends Listener { 8 | readonly subject = Subjects.OrderCreated; 9 | queueGroupName = queueGrouName; 10 | 11 | async onMessage(data: OrderCreatedEvent['data'], msg: Message) { 12 | 13 | // Find the ticket that the order is reserving 14 | const ticket = await Ticket.findById(data.ticket.id); 15 | 16 | // If no ticket, throw error 17 | if (!ticket) { 18 | throw new Error('Ticket not found'); 19 | } 20 | 21 | // Mark the ticket as being reserved by setting its orderId property 22 | ticket.set({ orderId: data.id }); 23 | 24 | // Save the ticket 25 | await ticket.save(); 26 | await new TicketUpdatedPublisher(this.client).publish({ 27 | id: ticket.id, 28 | price: ticket.price, 29 | title: ticket.title, 30 | userId: ticket.userId, 31 | orderId: ticket.orderId, 32 | version: ticket.version, 33 | }); 34 | 35 | // ACK the message 36 | msg.ack(); 37 | 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /tickets/src/events/listeners/queue-group-name.ts: -------------------------------------------------------------------------------- 1 | export const queueGrouName = 'tickets-service'; -------------------------------------------------------------------------------- /tickets/src/events/publishers/ticket-created-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Publisher, Subjects, TicketCreatedEvent } from '@black_sheep/common'; 2 | 3 | export class TicketCreatedPublisher extends Publisher { 4 | readonly subject = Subjects.TicketCreated; 5 | }; 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tickets/src/events/publishers/ticket-updated-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Publisher, Subjects, TicketUpdatedEvent } from '@black_sheep/common'; 2 | 3 | export class TicketUpdatedPublisher extends Publisher { 4 | readonly subject = Subjects.TicketUpdated; 5 | }; 6 | -------------------------------------------------------------------------------- /tickets/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import mongoose from 'mongoose'; 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 | 8 | const start = async () => { 9 | if (!process.env.JWT_KEY) { 10 | throw new Error('JWT_KEY not found, must be defined'); 11 | } 12 | 13 | if (!process.env.MONGO_URI) { 14 | throw new Error('MONGO_URI not found, must be defined'); 15 | } 16 | 17 | if (!process.env.NATS_CLIENT_ID) { 18 | throw new Error('NATS_CLIENT_ID not found, must be defined'); 19 | } 20 | 21 | if (!process.env.NATS_URL) { 22 | throw new Error('NATS_URL not found, must be defined'); 23 | } 24 | 25 | if (!process.env.NATS_CLUSTER_ID) { 26 | throw new Error('NATS_CLUSTER_ID not found, must be defined'); 27 | } 28 | try { 29 | 30 | // Event service 31 | // connect to service nats 32 | await natsWrapper.connect( 33 | process.env.NATS_CLUSTER_ID, 34 | process.env.NATS_CLIENT_ID, 35 | process.env.NATS_URL 36 | ); 37 | // for close app close service too gracefull shutdown 38 | natsWrapper.client.on('close', () => { 39 | console.log('NATS connection close'); 40 | process.exit(); 41 | }); 42 | process.on('SIGINT', () => natsWrapper.client.close()); 43 | process.on('SIGTERM', () => natsWrapper.client.close()); 44 | 45 | // create service of listen 46 | new OrderCreatedListener(natsWrapper.client).listen(); 47 | new OrderCancelledListener(natsWrapper.client).listen(); 48 | 49 | // connect to mongodb 50 | await mongoose.connect(process.env.MONGO_URI, {}); 51 | console.info('Connect to MongoDb'); 52 | } catch (error) { 53 | console.error(error); 54 | } 55 | } 56 | 57 | app.listen(3000, () => { 58 | console.log('Listen from port: ', 3000) 59 | }); 60 | 61 | start(); 62 | -------------------------------------------------------------------------------- /tickets/src/model/__test__/ticket.test.ts: -------------------------------------------------------------------------------- 1 | import { Ticket } from '../ticket'; 2 | 3 | it('implements optimistic concurrency control', async () => { 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; 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 | await ticket.save(); 45 | expect(ticket.version).toEqual(1); 46 | await ticket.save(); 47 | expect(ticket.version).toEqual(2); 48 | }); 49 | -------------------------------------------------------------------------------- /tickets/src/model/ticket.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from "mongoose"; 2 | import { updateIfCurrentPlugin } from 'mongoose-update-if-current'; 3 | 4 | // An interface that describes the properties that 5 | // are required to create new Ticket 6 | interface TicketAttrs { 7 | title: string; 8 | price: number; 9 | userId: string; 10 | } 11 | 12 | // An interface that describe tha propeties 13 | // that a Ticket Document has 14 | interface TicketDoc extends mongoose.Document { 15 | title: string; 16 | price: number; 17 | userId: string; 18 | version: number; 19 | orderId?: string; 20 | } 21 | 22 | // An interface that describe the properties that 23 | // a Ticket Model has 24 | interface TicketModel extends mongoose.Model { 25 | build(attrs: TicketAttrs): TicketDoc; 26 | } 27 | 28 | const ticketSchema = new Schema({ 29 | title: { 30 | type: String, 31 | required: true, 32 | }, 33 | price: { 34 | type: Number, 35 | required: true 36 | } 37 | , userId: { 38 | type: String, 39 | required: true 40 | }, 41 | orderId: { 42 | type: String, 43 | } 44 | }, { 45 | toJSON: { 46 | transform(doc, ret) { 47 | ret.id = ret._id; 48 | delete ret._id; 49 | delete ret.__v; 50 | } 51 | } 52 | }); 53 | 54 | ticketSchema.set('versionKey', 'version'); 55 | ticketSchema.plugin(updateIfCurrentPlugin) 56 | 57 | ticketSchema.statics.build = (atrrs: TicketAttrs) => { 58 | return new Ticket(atrrs); 59 | }; 60 | 61 | const Ticket = mongoose.model('User', ticketSchema); 62 | 63 | export { Ticket }; -------------------------------------------------------------------------------- /tickets/src/nats-wrapper.ts: -------------------------------------------------------------------------------- 1 | import nats, { Stan } from 'node-nats-streaming'; 2 | 3 | class NatsWrapper { 4 | private _client?: Stan; 5 | 6 | connect(clusterId: string, clientId: string, url: string) { 7 | this._client = nats.connect(clusterId, clientId, { url }); 8 | 9 | return new Promise((resolve, reject) => { 10 | this._client!.on('connect', () => { 11 | console.log('Connected to NATS'); 12 | resolve(); 13 | }); 14 | 15 | this._client!.on('error', (err) => { 16 | reject(err); 17 | }); 18 | }); 19 | } 20 | 21 | get client() { 22 | if (!this._client) { 23 | throw new Error('Cannot acces NATS client before connecting'); 24 | } 25 | return this._client; 26 | } 27 | }; 28 | 29 | export const natsWrapper = new NatsWrapper(); -------------------------------------------------------------------------------- /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()) 8 | .send({ 9 | title: 'event 1', 10 | price: 10 11 | }); 12 | } 13 | 14 | it('can fetch a list of tickets', async () => { 15 | await createTicket(); 16 | await createTicket(); 17 | await createTicket(); 18 | 19 | const response = await request(app) 20 | .get('/api/tickets') 21 | .send() 22 | .expect(200); 23 | 24 | expect(response.body.length).toEqual(3); 25 | }); -------------------------------------------------------------------------------- /tickets/src/routes/__test__/new.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import app from '../../app'; 3 | import { Ticket } from "../../model/ticket"; 4 | import { natsWrapper } from "../../nats-wrapper"; 5 | 6 | it('has a route handlerlistening to /api/tickets for post requests', async () => { 7 | const response = await request(app) 8 | .post('/api/tickets') 9 | .send({}); 10 | expect(response.status).not.toEqual(404); 11 | }); 12 | 13 | it('can only be accessed if the user is signed in', async () => { 14 | await request(app) 15 | .post('/api/tickets') 16 | .send({}) 17 | .expect(401); 18 | }); 19 | 20 | it('returns a status other than 401 if the user is signed in', async () => { 21 | const response = await request(app) 22 | .post('/api/tickets') 23 | .set('Cookie', global.signin()) 24 | .send({}); 25 | expect(response.status).not.toEqual(401); 26 | }); 27 | 28 | it('return an error if an invalid title is provided', async () => { 29 | await request(app) 30 | .post('/api/tickets') 31 | .set('Cookie', global.signin()) 32 | .send({ 33 | title: '', 34 | price: 10 35 | }) 36 | .expect(400); 37 | 38 | await request(app) 39 | .post('/api/tickets') 40 | .set('Cookie', global.signin()) 41 | .send({ 42 | price: 10 43 | }) 44 | .expect(400); 45 | 46 | }); 47 | 48 | it('return an error if an invalid price is provided', async () => { 49 | await request(app) 50 | .post('/api/tickets') 51 | .set('Cookie', global.signin()) 52 | .send({ 53 | title: 'lala lan', 54 | price: 0 55 | }) 56 | .expect(400); 57 | 58 | await request(app) 59 | .post('/api/tickets') 60 | .set('Cookie', global.signin()) 61 | .send({ 62 | title: 'lala lan', 63 | price: -10 64 | }) 65 | .expect(400); 66 | 67 | await request(app) 68 | .post('/api/tickets') 69 | .set('Cookie', global.signin()) 70 | .send({ 71 | title: 'lala lan' 72 | }) 73 | .expect(400); 74 | }); 75 | 76 | it('create a ticket with valid input', async () => { 77 | // add in a check to make syre a ticket was saved 78 | let tickets = await Ticket.find({}); 79 | expect(tickets.length).toEqual(0); 80 | 81 | await request(app) 82 | .post('/api/tickets') 83 | .set('Cookie', global.signin()) 84 | .send({ 85 | title: 'lala lan', 86 | price: 10 87 | }) 88 | .expect(201); 89 | 90 | tickets = await Ticket.find({}); 91 | expect(tickets.length).toEqual(1); 92 | expect(tickets[0].title).toEqual('lala lan'); 93 | expect(tickets[0].price).toEqual(10); 94 | }); 95 | 96 | it('publishes an event', async () => { 97 | const title = 'la la land'; 98 | await request(app) 99 | .post('/api/tickets') 100 | .set('Cookie', global.signin()) 101 | .send({ 102 | title: title, 103 | price: 10 104 | }) 105 | .expect(201); 106 | 107 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 108 | }); -------------------------------------------------------------------------------- /tickets/src/routes/__test__/show.test.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import request from "supertest"; 3 | import app from '../../app'; 4 | 5 | it('return a 404 if the ticket is not found', async () => { 6 | const id = new mongoose.Types.ObjectId().toHexString(); 7 | await request(app) 8 | .post(`/api/tickets/${id}`) 9 | .send() 10 | .expect(404); 11 | }); 12 | 13 | it('return the ticket if the ticket is found', async () => { 14 | const title = 'concert'; 15 | const price = 20; 16 | 17 | const response = await request(app) 18 | .post('/api/tickets/') 19 | .set('Cookie', global.signin()) 20 | .send({ 21 | title, price 22 | }) 23 | expect(201); 24 | 25 | const ticketResponse = await request(app) 26 | .get(`/api/tickets/${response.body.id}`) 27 | .send() 28 | .expect(200); 29 | 30 | expect(ticketResponse.body.title).toEqual(title); 31 | expect(ticketResponse.body.price).toEqual(price); 32 | }); -------------------------------------------------------------------------------- /tickets/src/routes/__test__/update.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../../app'; 3 | import mongoose from 'mongoose'; 4 | import { natsWrapper } from '../../nats-wrapper'; 5 | import { Ticket } from '../../model/ticket'; 6 | 7 | it('return 404 if the provided id does not exist', async () => { 8 | const id = new mongoose.Types.ObjectId().toHexString(); 9 | await request(app) 10 | .put(`/api/tickets/${id}`) 11 | .set('Cookie', global.signin()) 12 | .send({ 13 | title: 'asdf', 14 | price: 20 15 | }) 16 | .expect(404); 17 | }); 18 | 19 | it('return a 401 if the user is not authenticated', async () => { 20 | const id = new mongoose.Types.ObjectId().toHexString(); 21 | await request(app) 22 | .put(`/api/tickets/${id}`) 23 | .send({ 24 | title: 'asdf', 25 | price: 20 26 | }) 27 | .expect(401); 28 | }); 29 | 30 | it('return a 401 if the user does not own the ticket', async () => { 31 | const response = await request(app) 32 | .post('/api/tickets') 33 | .set('Cookie', global.signin()) 34 | .send({ 35 | title: 'lala lan', 36 | price: 10 37 | }) 38 | .expect(201); 39 | 40 | await request(app) 41 | .put(`/api/tickets/${response.body.id}`) 42 | .set('Cookie', global.signin()) 43 | .send({ 44 | title: 'asdf', 45 | price: 20 46 | }) 47 | .expect(401); 48 | }); 49 | 50 | it('return a 400 if the user provides an invalid title or price', async () => { 51 | const user = global.signin(); 52 | 53 | const response = await request(app) 54 | .post('/api/tickets') 55 | .set('Cookie', user) 56 | .send({ 57 | title: 'lala lan', 58 | price: 10 59 | }) 60 | .expect(201); 61 | 62 | await request(app) 63 | .put(`/api/tickets/${response.body.id}`) 64 | .set('Cookie', user) 65 | .send({ 66 | price: -10 67 | }) 68 | .expect(400); 69 | 70 | await request(app) 71 | .put(`/api/tickets/${response.body.id}`) 72 | .set('Cookie', user) 73 | .send({ 74 | title: 'asdf' 75 | }) 76 | .expect(400); 77 | 78 | await request(app) 79 | .put(`/api/tickets/${response.body.id}`) 80 | .set('Cookie', user) 81 | .send({ 82 | }) 83 | .expect(400); 84 | }); 85 | 86 | 87 | it('update the ticket provided valid inputs', async () => { 88 | const user = global.signin(); 89 | 90 | const response = await request(app) 91 | .post('/api/tickets') 92 | .set('Cookie', user) 93 | .send({ 94 | title: 'lala lan', 95 | price: 10 96 | }) 97 | .expect(201); 98 | 99 | const newTitle = 'new lalalan'; 100 | const newPrice = 300; 101 | 102 | await request(app) 103 | .put(`/api/tickets/${response.body.id}`) 104 | .set('Cookie', user) 105 | .send({ 106 | title: newTitle, 107 | price: newPrice 108 | }) 109 | .expect(200); 110 | 111 | const ticketResponse = await request(app) 112 | .get(`/api/tickets/${response.body.id}`) 113 | .send(); 114 | 115 | expect(ticketResponse.body?.title).toEqual(newTitle); 116 | expect(ticketResponse.body?.price).toEqual(newPrice); 117 | 118 | }); 119 | 120 | it('publishes an event', async () => { 121 | const user = global.signin(); 122 | 123 | const response = await request(app) 124 | .post('/api/tickets') 125 | .set('Cookie', user) 126 | .send({ 127 | title: 'lala lan', 128 | price: 10 129 | }) 130 | .expect(201); 131 | 132 | const newTitle = 'new lalalan'; 133 | const newPrice = 300; 134 | 135 | await request(app) 136 | .put(`/api/tickets/${response.body.id}`) 137 | .set('Cookie', user) 138 | .send({ 139 | title: newTitle, 140 | price: newPrice 141 | }) 142 | .expect(200); 143 | 144 | expect(natsWrapper.client.publish).toHaveBeenCalled(); 145 | }); 146 | 147 | 148 | it('rejects updates if the ticket is reserved', async () => { 149 | const cookie = global.signin(); 150 | 151 | const response = await request(app) 152 | .post('/api/tickets') 153 | .set('Cookie', cookie) 154 | .send({ 155 | title: 'asldkfj', 156 | price: 20, 157 | }); 158 | 159 | const ticket = await Ticket.findById(response.body.id); 160 | ticket!.set({ 161 | orderId: new mongoose.Types.ObjectId().toHexString() 162 | }); 163 | await ticket!.save(); 164 | 165 | await request(app) 166 | .put(`/api/tickets/${response.body.id}`) 167 | .set('Cookie', cookie) 168 | .send({ 169 | title: 'new title', 170 | price: 100, 171 | }) 172 | .expect(400); 173 | }); 174 | -------------------------------------------------------------------------------- /tickets/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { Ticket } from '../model/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 | res.send(tickets); 11 | }) 12 | 13 | export { router as indexTicketRouter } -------------------------------------------------------------------------------- /tickets/src/routes/new.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseConnetionError, requireAuth, validateRequest } from '@black_sheep/common'; 2 | import { body } from 'express-validator'; 3 | import express, { Request, Response } from 'express'; 4 | import { Ticket } from '../model/ticket'; 5 | import { TicketCreatedPublisher } from '../events/publishers/ticket-created-publisher'; 6 | import { natsWrapper } from '../nats-wrapper'; 7 | 8 | const router = express.Router(); 9 | 10 | router.post('/api/tickets', requireAuth, [ 11 | body('title') 12 | .not() 13 | .isEmpty() 14 | .withMessage('Title is required'), 15 | body('price') 16 | .isFloat({ gt: 0 }) 17 | .withMessage('Price must be greater than 0') 18 | ], validateRequest, async (req: Request, res: Response) => { 19 | try { 20 | const { title, price } = req.body; 21 | const ticket = Ticket.build({ 22 | title, 23 | price, 24 | userId: req.currentUser!.id 25 | }); 26 | await ticket.save(); 27 | 28 | await new TicketCreatedPublisher(natsWrapper.client).publish({ 29 | id: ticket.id, 30 | title: ticket.title, 31 | price: ticket.price, 32 | userId: ticket.userId, 33 | version: ticket.version, 34 | }); 35 | 36 | res.status(201).send(ticket); 37 | } catch (error) { 38 | throw new DatabaseConnetionError(); 39 | } 40 | }); 41 | 42 | export { router as createTicketRouter } -------------------------------------------------------------------------------- /tickets/src/routes/show.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError } from '@black_sheep/common'; 2 | import express, { Request, Response } from 'express'; 3 | import { Ticket } from '../model/ticket'; 4 | 5 | 6 | const router = express.Router(); 7 | 8 | router.get('/api/tickets/:id', async (req: Request, res: Response) => { 9 | const ticket = await Ticket.findById(req.params.id); 10 | if (!ticket) { 11 | throw new NotFoundError(); 12 | } 13 | res.status(200).send(ticket); 14 | }); 15 | 16 | export { router as showTicketRouter } -------------------------------------------------------------------------------- /tickets/src/routes/update.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { body } from 'express-validator'; 3 | import { BadRequestError, NotAuthorizedError, NotFoundError, requireAuth, validateRequest } from '@black_sheep/common'; 4 | import { Ticket } from '../model/ticket'; 5 | import { TicketUpdatedPublisher } from '../events/publishers/ticket-updated-publisher'; 6 | import { natsWrapper } from '../nats-wrapper'; 7 | 8 | const router = express.Router(); 9 | 10 | router.put('/api/tickets/:id', requireAuth, [ 11 | body('title') 12 | .not() 13 | .isEmpty() 14 | .withMessage('Title is required'), 15 | body('price') 16 | .isFloat({ gt: 0 }) 17 | .withMessage('Price must be greater than 0') 18 | ], validateRequest, async (req: Request, res: Response) => { 19 | const ticket = await Ticket.findById(req.params.id); 20 | if (!ticket) { 21 | throw new NotFoundError(); 22 | } 23 | 24 | if (ticket.orderId) { 25 | throw new BadRequestError('Cannot edit a reserved ticket'); 26 | } 27 | 28 | if (ticket.userId !== req.currentUser!.id) { 29 | throw new NotAuthorizedError(); 30 | } 31 | 32 | const { title, price } = req.body; 33 | 34 | ticket.set({ 35 | title, 36 | price 37 | }); 38 | await ticket.save(); 39 | 40 | await new TicketUpdatedPublisher(natsWrapper.client).publish({ 41 | id: ticket.id, 42 | title: ticket.title, 43 | price: ticket.price, 44 | userId: ticket.userId, 45 | version: ticket.version, 46 | }); 47 | 48 | res.status(200).send(ticket); 49 | }); 50 | 51 | export { router as updateTicketRouter } -------------------------------------------------------------------------------- /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 | var signin: () => string[]; 7 | } 8 | 9 | jest.mock('../nats-wrapper.ts'); 10 | 11 | let mongo: any; 12 | 13 | beforeAll(async () => { 14 | process.env.JWT_KEY = 'asdf'; 15 | 16 | mongo = await MongoMemoryServer.create(); 17 | const mongoUri = await mongo.getUri(); 18 | 19 | await mongoose.connect(mongoUri, {}); 20 | }); 21 | 22 | beforeEach(async () => { 23 | jest.clearAllMocks(); 24 | 25 | const collections = await mongoose.connection.db.collections(); 26 | for (let collection of collections) { 27 | await collection.deleteMany({}); 28 | } 29 | }); 30 | 31 | afterAll(async () => { 32 | await mongo.stop(); 33 | await mongoose.connection.close(); 34 | }); 35 | 36 | global.signin = () => { 37 | // Build a JWT payload. { id, email } 38 | const payload = { 39 | id: new mongoose.Types.ObjectId().toHexString(), 40 | email: 'test@test.com', 41 | }; 42 | 43 | // Create the JWT! 44 | const token = jwt.sign(payload, process.env.JWT_KEY!); 45 | 46 | // Build session Object. { jwt: MY_JWT } 47 | const session = { jwt: token }; 48 | 49 | // Turn that session into JSON 50 | const sessionJSON = JSON.stringify(session); 51 | 52 | // Take JSON and encode it as base64 53 | const base64 = Buffer.from(sessionJSON).toString('base64'); 54 | 55 | // return a string thats the cookie with the encoded data 56 | return [`session=${base64}`]; 57 | } 58 | 59 | 60 | -------------------------------------------------------------------------------- /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 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | --------------------------------------------------------------------------------