diff --git a/.github/workflows/CI - Tests - Auth Service.yml b/.github/workflows/CI - Tests - Auth Service.yml new file mode 100644 index 0000000..c33ab5f --- /dev/null +++ b/.github/workflows/CI - Tests - Auth Service.yml @@ -0,0 +1,13 @@ +# ========================================= Main Branch ::: CI - Tests - Auth Service ========================================= + +name: CI - Tests - Auth Service + +on: pull_request + +jobs: + Production-Branch-Pre-Integration-Tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - run: cd auth && npm install && npm run test:ci diff --git a/auth/package.json b/auth/package.json index 604068e..b1cfa63 100644 --- a/auth/package.json +++ b/auth/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "start": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts", - "test": "jest --watchAll --no-cache" + "test": "jest --watchAll --no-cache", + "test:ci": "jest" }, "jest": { "preset": "ts-jest", diff --git a/client/components/header.js b/client/components/header.js index b9dac96..77471a3 100644 --- a/client/components/header.js +++ b/client/components/header.js @@ -2,9 +2,11 @@ import Link from "next/link"; export default ({ currentUser }) => { const Links = [ - !currentUser && { label: "Sign Up", href: "auth/signup" }, - !currentUser && { label: "Sign In", href: "auth/signin" }, - currentUser && { label: "Sign Out", href: "auth/signout" }, + !currentUser && { label: "Sign Up", href: "/auth/signup" }, + !currentUser && { label: "Sign In", href: "/auth/signin" }, + currentUser && { label: "Sell Tickets", href: "/tickets/new" }, + currentUser && { label: "My Orders", href: "/orders" }, + currentUser && { label: "Sign Out", href: "/auth/signout" }, ] .filter((validLinks) => validLinks) .map(({ label, href }) => { diff --git a/client/hooks/use-request.js b/client/hooks/use-request.js index 8d528d4..ed28ccb 100644 --- a/client/hooks/use-request.js +++ b/client/hooks/use-request.js @@ -4,11 +4,11 @@ import axios from "axios"; export default ({ url, method, body, onSuccess }) => { const [errors, setErrors] = useState(null); - const makeRequest = async () => { + const makeRequest = async (props={}) => { try { setErrors(null); // Setting error to null initially to prevent errors from being displayed always. - const response = await axios[method](url, body); + const response = await axios[method](url, {...body, ...props}); // If the call back exist, then return the call back with response data. if (onSuccess) { diff --git a/client/package-lock.json b/client/package-lock.json index 950faf0..d8288fc 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,8 +12,10 @@ "axios": "^1.5.1", "bootstrap": "^5.3.2", "next": "^13.5.4", + "prop-types": "^15.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-stripe-checkout": "^2.6.3" } }, "node_modules/@next/env": { @@ -400,6 +402,14 @@ } } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -432,6 +442,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -460,6 +480,16 @@ "react": "^18.2.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-stripe-checkout": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-stripe-checkout/-/react-stripe-checkout-2.6.3.tgz", + "integrity": "sha512-lnsCaAdlmwPGGMbQoI8FXtQUgEm+ktzPZ/ipAw4j0HYf80kef7CivGx6QitmgEn99/aa5hI/dmVXwfVZW/Mzfg==" + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", diff --git a/client/package.json b/client/package.json index af83111..4ff3d98 100644 --- a/client/package.json +++ b/client/package.json @@ -13,7 +13,9 @@ "axios": "^1.5.1", "bootstrap": "^5.3.2", "next": "^13.5.4", + "prop-types": "^15.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-stripe-checkout": "^2.6.3" } } diff --git a/client/pages/_app.js b/client/pages/_app.js index 187ad3d..c7c8c7c 100644 --- a/client/pages/_app.js +++ b/client/pages/_app.js @@ -7,7 +7,9 @@ const AppComponent = ({ Component, pageProps, currentUser }) => { return (
- +
+ +
); }; @@ -20,7 +22,11 @@ AppComponent.getInitialProps = async (appContext) => { // If the child component has getInitialProps method, then execute it manually from here. let pageProps = {}; if (appContext.Component.getInitialProps) { - pageProps = await appContext.Component.getInitialProps(appContext.ctx); + pageProps = await appContext.Component.getInitialProps( + appContext.ctx, + client, + response.data.currentUser + ); } return { pageProps, currentUser: response.data.currentUser }; diff --git a/client/pages/index.js b/client/pages/index.js index 6163abf..2401514 100644 --- a/client/pages/index.js +++ b/client/pages/index.js @@ -1,34 +1,56 @@ -import buildClient from "../api/build-client"; +import Link from 'next/link'; -const IndexPage = ({ currentUser }) => { +const IndexPage = ({ currentUser, tickets }) => { if (!currentUser) { return ( <>

Landing Page

-
+
Signed Out
); } + let ticketList; + if (currentUser) { + ticketList = tickets.map((ticket) => { + return ( + + {ticket.title} + {ticket.price} + + + View + + + + ); + }); + } + return ( <> -

Landing Page

-
Welcome {currentUser.email}
+ +

Tickets

+ + + + + + + + + + {currentUser && ticketList} +
TitlePriceAction
); }; -IndexPage.getInitialProps = async (context) => { - try { - const client = buildClient(context); - const response = await client.get("/api/users/currentuser"); - return response.data; - } catch (err) { - console.error("Error in making request to get current user:", err.message); - return { currentUser: null }; - } +IndexPage.getInitialProps = async (context, client, currentUser) => { + const { data } = await client.get("/api/tickets"); + return { tickets: data }; }; export default IndexPage; diff --git a/client/pages/orders/[orderId].js b/client/pages/orders/[orderId].js new file mode 100644 index 0000000..de0588a --- /dev/null +++ b/client/pages/orders/[orderId].js @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import Router from "next/router"; +import StripeCheckout from "react-stripe-checkout"; +import useRequest from "../../hooks/use-request"; + +const OrderShow = ({ order, currentUser }) => { + const [timeLeft, setTimeLeft] = useState(0); + const [makeRequest, errors] = useRequest({ + url: "/api/payments", + method: "post", + body: { + orderId: order.id, + }, + onSuccess: (payment) => { + Router.push("/orders"); + }, + }); + + useEffect(() => { + const findTimeLeft = () => { + const milliSecondsLeft = new Date(order.expiresAt) - new Date(); + const secondsLeft = Math.round(milliSecondsLeft / 1000); + + setTimeLeft(secondsLeft); + }; + + findTimeLeft(); + const timer = setInterval(findTimeLeft, 1000); + + return () => { + clearInterval(timer); + }; + }, [order]); + + if (timeLeft < 0) { + return ( +
+

Order Expired !!!

+
+ ); + } + + return ( +
+ Time left to complete payment: {timeLeft} seconds. + { + makeRequest({ token: token.id }); + }} + stripeKey="pk_test_51O53zRSJtuYafghXhYNZzaJqYAh6afqRduQ3UAMs6Wm4vkv30ayq09gBPgU3jYkQPXrofQa9aRbIlb4uuCp3FC6O000J86xaKc" + amount={order.ticket.price * 100} + email={currentUser.email} + /> + {errors} +
+ ); +}; + +OrderShow.getInitialProps = async (context, client) => { + const { orderId } = context.query; + const { data } = await client.get(`/api/orders/${orderId}`); + + return { order: data }; +}; + +export default OrderShow; diff --git a/client/pages/orders/index.js b/client/pages/orders/index.js new file mode 100644 index 0000000..22f886b --- /dev/null +++ b/client/pages/orders/index.js @@ -0,0 +1,23 @@ +const OrderIndex = ({ orders }) => { + return ( + <> + + + ); +}; + +OrderIndex.getInitialProps = async (context, client) => { + const response = await client.get("/api/orders"); + + return { orders: response.data }; +}; + +export default OrderIndex; diff --git a/client/pages/tickets/[ticketId].js b/client/pages/tickets/[ticketId].js new file mode 100644 index 0000000..212f31f --- /dev/null +++ b/client/pages/tickets/[ticketId].js @@ -0,0 +1,40 @@ +import Router from "next/router"; +import useRequest from "../../hooks/use-request"; + +const TicketShow = ({ ticket }) => { + const [makeRequest, errors] = useRequest({ + url: "/api/orders", + method: "post", + body: { + ticketId: ticket.id, + }, + onSuccess: (orderData) => { + Router.push("/orders/[orderId]", `/orders/${orderData.id}`); + }, + }); + + const handleClick = async () => { + makeRequest(); + }; + + return ( +
+

{ticket.title}

+

Price: {ticket.price} INR

+ {errors} + +
+ ); +}; + +TicketShow.getInitialProps = async (context, client) => { + const { ticketId } = context.query; + + const { data } = await client.get(`/api/tickets/${ticketId}`); + + return { ticket: data }; +}; + +export default TicketShow; diff --git a/client/pages/tickets/new.js b/client/pages/tickets/new.js new file mode 100644 index 0000000..1a2a197 --- /dev/null +++ b/client/pages/tickets/new.js @@ -0,0 +1,70 @@ +import { useState } from "react"; +import Router from "next/router"; +import useRequest from "../../hooks/use-request"; + +const NewTicket = () => { + const [title, setTitle] = useState(""); + const [price, setPrice] = useState(""); + const [makeRequest, errors] = useRequest({ + url: "/api/tickets", + method: "post", + body: { + title: title, + price: price, + }, + onSuccess: () => { + Router.push("/"); + }, + }); + + const handleSubmit = (event) => { + event.preventDefault(); + + if (!title || !price) { + return; + } + + makeRequest(); + }; + + const roundOff = () => { + const roundedPrice = parseFloat(price); + + if (isNaN(roundedPrice)) { + // If price is not a number + return; + } + + setPrice(roundedPrice.toFixed(2)); + }; + return ( +
+

Create A Ticket

+
+
+ + setTitle(e.target.value)} + /> +
+
+ + setPrice(e.target.value)} + /> +
+ {errors} + +
+
+ ); +}; + +export default NewTicket; diff --git a/orders/package-lock.json b/orders/package-lock.json index 7ef1b4f..0f6c764 100644 --- a/orders/package-lock.json +++ b/orders/package-lock.json @@ -1368,9 +1368,9 @@ "dev": true }, "node_modules/@bookmyseat/common": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@bookmyseat/common/-/common-1.0.11.tgz", - "integrity": "sha512-1DI2vS8fTTnsvsl5N1IfMvGdVJs5WGEVNCTuyzISaf3B1UoE/qLHKrvCrallawXk4bTOLyr6MTo1DcWyS0xYfQ==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@bookmyseat/common/-/common-1.0.12.tgz", + "integrity": "sha512-HuKJrOIAQagz/ax8ZeV5og7ms8qhTUH5jXVW9B29e7h3izP91DUSmh9KBrxNQpTyI93RUgh6DhZ31InuIREViw==", "dependencies": { "@types/cookie-session": "^2.0.45", "@types/express": "^4.17.18", diff --git a/orders/package.json b/orders/package.json index 010e589..0860052 100644 --- a/orders/package.json +++ b/orders/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "start": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts", - "test": "jest --watchAll --no-cache" + "test": "jest --watchAll --no-cache", + "test:ci": "jest" }, "jest": { "preset": "ts-jest", diff --git a/orders/src/events/listeners/expiration-complete-listener.ts b/orders/src/events/listeners/expiration-complete-listener.ts index d1e6ab8..09e48da 100644 --- a/orders/src/events/listeners/expiration-complete-listener.ts +++ b/orders/src/events/listeners/expiration-complete-listener.ts @@ -25,6 +25,11 @@ export class OrderExpirationListener extends Listener { throw new Error("Order not found !!!"); } + // If the order is already paid and marked complete, return early and acknowledge the event to NATS. + if (order.status === OrderStatus.Complete) { + return msg.ack(); + } + order.set({ status: OrderStatus.Cancelled }); await order.save(); diff --git a/orders/src/events/listeners/payment-created-listener.ts b/orders/src/events/listeners/payment-created-listener.ts new file mode 100644 index 0000000..76ff3ae --- /dev/null +++ b/orders/src/events/listeners/payment-created-listener.ts @@ -0,0 +1,39 @@ +import { Message } from "node-nats-streaming"; +import { + Listener, + EventSubjects, + PaymentCreatedEvent, + NotFoundError, + OrderStatus, +} from "@bookmyseat/common"; + +import { queueGroupName } from "../order-service-queue-group-name"; +import { Order } from "../../models/order"; + +export class PaymentCreatedListener extends Listener { + readonly subject = EventSubjects.PaymentCreated; + + queueGroupName = queueGroupName; + + async onMessage(data: PaymentCreatedEvent["data"], msg: Message) { + try { + // Destructure the ticket id, title, and price from data argument. + const { id, version, orderId, stripeId } = data; + + const order = await Order.findById(data.orderId); + + if (!order) { + throw new NotFoundError(); + } + + order.set({ status: OrderStatus.Complete }); + + await order.save(); + + // Acknowledge the Payment Created event to NATS server. + msg.ack(); + } catch (error) { + console.error("Error processing PaymentCreatedEvent", error); + } + } +} diff --git a/orders/src/index.ts b/orders/src/index.ts index 3aaf385..dc9303d 100644 --- a/orders/src/index.ts +++ b/orders/src/index.ts @@ -7,6 +7,7 @@ import { natsClient } from "./nats-client"; import { TicketCreatedListener } from "./events/listeners/ticket-created-listener"; import { TicketUpdatedListener } from "./events/listeners/ticket-updated-listener"; import { OrderExpirationListener } from "./events/listeners/expiration-complete-listener"; +import { PaymentCreatedListener } from "./events/listeners/payment-created-listener"; const startServer = async () => { // Server Configuration @@ -84,6 +85,7 @@ const startServer = async () => { new TicketCreatedListener(natsClient.client).listen(); new TicketUpdatedListener(natsClient.client).listen(); new OrderExpirationListener(natsClient.client).listen(); + new PaymentCreatedListener(natsClient.client).listen(); } catch (err) { console.error( `Error Connecting ${SERVICE_NAME} Service to NATS CLUSTER: ${NATS_CLUSTER_ID}:`, diff --git a/tickets/package-lock.json b/tickets/package-lock.json index 2bf402a..400690f 100644 --- a/tickets/package-lock.json +++ b/tickets/package-lock.json @@ -1368,9 +1368,9 @@ "dev": true }, "node_modules/@bookmyseat/common": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@bookmyseat/common/-/common-1.0.10.tgz", - "integrity": "sha512-+uQeZIU6BcJ5YDOCiWoVY2xB2Qc0oa3EZgHRSZt9guUHq0oSE7nKA3OX/iE+R9vee7ffOvQUWWjNfSTuRTwYlA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@bookmyseat/common/-/common-1.0.12.tgz", + "integrity": "sha512-HuKJrOIAQagz/ax8ZeV5og7ms8qhTUH5jXVW9B29e7h3izP91DUSmh9KBrxNQpTyI93RUgh6DhZ31InuIREViw==", "dependencies": { "@types/cookie-session": "^2.0.45", "@types/express": "^4.17.18", diff --git a/tickets/src/routes/index.ts b/tickets/src/routes/index.ts index 600d265..525b07b 100644 --- a/tickets/src/routes/index.ts +++ b/tickets/src/routes/index.ts @@ -4,7 +4,9 @@ import { Ticket } from "../models/ticket"; const router = express.Router(); router.get("/api/tickets", async (req: Request, res: Response) => { - const tickets = await Ticket.find({}); + + // Fetch all Tickets that are available to make a booking (ie, which are not reserved by any order) + const tickets = await Ticket.find({orderId: undefined}); res.send(tickets); });