diff --git a/endpoints.md b/endpoints.md index 31df86e..46ea497 100644 --- a/endpoints.md +++ b/endpoints.md @@ -1,33 +1,33 @@ # Endpoints -| Done | Method | Path | Description | Authentication | -|------|----------|--------------------------|---------------------------------------------------|----------------| -| [x] | POST | /login | Login | | -| [x] | POST | /register | Create account | | -| [x] | GET | /center | Get list of center | | -| [x] | POST | /center | Admin can create center | Admin | -| [x] | PUT | /center/:id | Admin can change center | Admin | -| [ ] | DELETE | /center | Admin can delete center | Admin | -| [x] | GET | /trainer | Get list of trainers | | -| [x] | POST | /trainer | Admin can create trainer | Admin | -| [ ] | PUT | /trainer | Trainer can change profile information | Trainer | -| [x] | PUT | /trainer/:id | Admin can change trainer information | Admin | -| [ ] | DELETE | /trainer/:id | Admin can delete trainer | Admin | -| [x] | GET | /timeslot | Filter for available timeslots | | -| [x] | GET | /trainer/timeslot | Trainer can get weekly timeslots | Trainer | -| [x] | POST | /trainer/timeslot | Trainer can create weekly timeslots | Trainer | -| [x] | DELETE | /trainer/timeslot/:id | Trainer can delete weekly timeslots | Trainer | -| [ ] | GET | /trainer/order | Trainer can get reserved timeslots | Trainer | -| [ ] | PUT | /trainer/order/:id | Trainer can change reserved timeslot | Trainer | -| [ ] | DELTE | /trainer/order/:id | Trainer can delete reserved timeslot | Trainer | -| [ ] | GET | /order | User can get list of orders | User | -| [x] | POST | /order | User can request an order | User | -| [ ] | GET | /order/:id | User can get order details | User | -| [ ] | POST | /order/:id/confirm | User can confirm the order | User | -| [ ] | PUT | /order/:id | User can move order | User | -| [ ] | DELETE | /order/:id | User can cancel order | User | -| [ ] | GET | /user | User can get profile information | User | -| [ ] | PUT | /user | User can change profile information | User | -| [ ] | GET | /verify_email | Verify email | | -| [ ] | POST | /reset_password | Request password reset | | -| [ ] | POST | /new_password | Set new password | | +| Done | Method | Path | Description | Authentication | +|------|----------|---------------------------------|---------------------------------------------------|----------------| +| [x] | POST | /login | Login | | +| [x] | POST | /register | Create account | | +| [x] | GET | /center | Get list of center | | +| [x] | POST | /center | Admin can create center | Admin | +| [x] | PUT | /center/:id | Admin can change center | Admin | +| [ ] | DELETE | /center | Admin can delete center | Admin | +| [x] | GET | /trainer | Get list of trainers | | +| [x] | POST | /trainer | Admin can create trainer | Admin | +| [ ] | PUT | /trainer | Trainer can change profile information | Trainer | +| [x] | PUT | /trainer/:id | Admin can change trainer information | Admin | +| [ ] | DELETE | /trainer/:id | Admin can delete trainer | Admin | +| [x] | GET | /timeslot | Filter for available timeslots | | +| [x] | GET | /trainer/timeslot | Trainer can get weekly timeslots | Trainer | +| [x] | POST | /trainer/timeslot | Trainer can create weekly timeslots | Trainer | +| [x] | DELETE | /trainer/timeslot/:id | Trainer can delete weekly timeslots | Trainer | +| [ ] | GET | /trainer/order | Trainer can get reserved timeslots | Trainer | +| [ ] | PUT | /trainer/order/:id | Trainer can change reserved timeslot | Trainer | +| [x] | GET | /trainer/order/:id/newtimeslots | Trainer can change reserved timeslot | Trainer | +| [ ] | DELTE | /trainer/order/:id | Trainer can delete reserved timeslot | Trainer | +| [x] | GET | /order | User can get list of orders | User | +| [x] | POST | /order | User can request an order | User | +| [x] | PUT | /order/:id | User can propose new time | User | +| [x] | GET | /order/:id/newtimeslots | User can can get list of new timeslots | User | +| [x] | POST | /order/:id/cancel | User can cancel order | User | +| [x] | GET | /user | User can get profile information | User | +| [ ] | PUT | /user | User can change profile information | User | +| [ ] | GET | /verify_email | Verify email | | +| [ ] | POST | /reset_password | Request password reset | | +| [ ] | POST | /new_password | Set new password | | diff --git a/server/src/interfaces/order.ts b/server/src/interfaces/order.ts new file mode 100644 index 0000000..8e337f1 --- /dev/null +++ b/server/src/interfaces/order.ts @@ -0,0 +1,16 @@ +import Trainer from "./trainer"; + +export type OrderStatus = "Created" | "Confirmed" | "Failed" | "CancelledByTrainer" | "CancelledByUser"; +export type PaymentIntentStatus = "Created" | "Successful" | "Failed"; + +export interface OrderObject { + id: number + trainer: Trainer + startDate: string + endDate: string + status: OrderObjectStatus + price: number + created_at: Date +} + +export type OrderObjectStatus = "Successful" | "Processing" | "CancelledByTrainer" | "CancelledByUser"; \ No newline at end of file diff --git a/server/src/interfaces/timeslot.ts b/server/src/interfaces/timeslot.ts index f4c08fa..933a726 100644 --- a/server/src/interfaces/timeslot.ts +++ b/server/src/interfaces/timeslot.ts @@ -12,6 +12,6 @@ export interface WeeklyTimeslot { export interface ReservedTimeslots { id: number - start_time: string | Date - end_time: string | Date + start_time: Date + end_time: Date } \ No newline at end of file diff --git a/server/src/migrations/populate.ts b/server/src/migrations/populate.ts index 4e66ff5..3989082 100644 --- a/server/src/migrations/populate.ts +++ b/server/src/migrations/populate.ts @@ -63,7 +63,7 @@ RETURNING id; INSERT INTO reserved_timeslots (trainer_id, start_time, end_time) VALUES ($1, '2023-04-24 11:00:00+2', '2023-04-24 12:00:00+2'), ($1, '2023-04-24 12:00:00+2', '2023-04-24 13:00:00+2'), -($1, '2023-05-01 11:00:00+2', '2023-05-01 12:00:00+2'), +($1, '2023-04-01 11:00:00+2', '2023-04-01 12:00:00+2'), ($2, '2023-04-17 11:00:00+2', '2023-04-17 12:00:00+2'), ($2, '2023-04-10 12:00:00+2', '2023-04-10 13:00:00+2') RETURNING id, trainer_id; diff --git a/server/src/routes/createOrder.ts b/server/src/routes/createOrder.ts new file mode 100644 index 0000000..9d0e869 --- /dev/null +++ b/server/src/routes/createOrder.ts @@ -0,0 +1,214 @@ +import express, { Router, Response } from "express"; +import Joi from "joi" +import dayjs, { Dayjs } from "dayjs" +import isoWeek from "dayjs/plugin/isoWeek" +import utc from "dayjs/plugin/utc" +import LocalizedFormat from "dayjs/plugin/localizedFormat" +import { } from "dayjs/locale/da"; + +import { client as pool } from "../db"; +import { DatabaseError } from "pg"; +import { idSchema } from "../schemas"; +import { UserAuth } from "../middlewares/auth"; +import { AuthedRequest } from "../interfaces/auth"; +import Stripe from 'stripe'; +import { stripe } from "../stripe"; +import Trainer from "../interfaces/trainer"; + +dayjs.extend(isoWeek) +dayjs.extend(utc) +dayjs.extend(LocalizedFormat); + +const router: Router = express.Router(); + +const orderSchema = Joi.object({ + trainer: idSchema.required(), + startDate: Joi.date().min("now").required(), + endDate: Joi.date().min(Joi.ref("startDate")).required() +}); + +interface OrderBody { + trainer: number + startDate: Date + endDate: Date +} + +interface TimeslotValidQueryResult { + weekly_timeslot_available: boolean + time_already_reserved: boolean +} + +function formatDate(date: Dayjs): string { + let output: string = dayjs(date).locale("da").format("dddd D. MMM YYYY"); + let outputStringArray = output.split(""); + outputStringArray[0] = outputStringArray[0].toLocaleUpperCase(); + output = outputStringArray.join(""); + + return output; +} + +function formatTime(date: Dayjs): string { + let output: string = dayjs(date).locale("da").format("HH:mm"); + + return output; +} + +router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => { + try { + const client = await pool.connect(); + try { + const validation = orderSchema.validate(req.body, { abortEarly: false }); + if (validation.error !== undefined) { + return res.status(400).send(validation.error.details); + } + + const orderBody: OrderBody = validation.value; + + const startDate: dayjs.Dayjs = dayjs(orderBody.startDate).utcOffset(0); + const endDate: dayjs.Dayjs = dayjs(orderBody.endDate).utcOffset(0); + + const startTime: string = `${startDate.hour()}:${startDate.minute()}`; + const endTime: string = `${endDate.hour()}:${endDate.minute()}`; + + const weekday = startDate.isoWeekday(); + + await client.query("BEGIN"); + + const trainerLookup = await client.query(` + SELECT trainers.id, first_name, last_name, center_id, centers.name as center_name FROM trainers + JOIN users ON trainers.user_id = users.id + JOIN centers on trainers.center_id = centers.id + WHERE trainers.id = $1; + `, [ + orderBody.trainer + ]); + + if (trainerLookup.rows.length !== 1) + return res.status(400).send([{ + message: "\"trainer\" was not found", + path: [ + "trainer" + ], + type: "trainer.not_found" + }]); + + const trainer: Trainer = trainerLookup.rows[0]; + + const timeslotValidQuery = await client.query(` + select + EXISTS( + SELECT 1 FROM weekly_timeslots + WHERE + trainer_id = $1 + AND day_of_week = $2 + AND start_time = $3 + AND end_time = $4 + ) as weekly_timeslot_available, + EXISTS( + SELECT 1 FROM public.reserved_timeslots + WHERE + ((start_time >= $5 AND start_time < $6) + OR (end_time > $5 AND end_time <= $6)) + AND trainer_id = $1 + ) as time_already_reserved; + `, [ + orderBody.trainer, + weekday, + startTime, + endTime, + startDate.toISOString(), + endDate.toISOString() + ]); + + const timeslotValidQueryResult: TimeslotValidQueryResult = timeslotValidQuery.rows[0]; + + if (timeslotValidQueryResult.time_already_reserved || !timeslotValidQueryResult.weekly_timeslot_available) + return res.status(400).send([{ + message: "timeslot was not found", + path: [ + "startDate" + ], + type: "startDate.invalid" + }]); + + const priceQuery = await client.query(` + SELECT hourly_price FROM trainers WHERE id = $1; + `, [ + orderBody.trainer + ]); + + const hourlyPrice = priceQuery.rows[0].hourly_price; + + const hours = endDate.diff(startDate, "hours", true); + const price = Math.ceil(hours * hourlyPrice); + + const productName: string = `Personlig træningstime ${trainer.center_name}`; + const productDescription: string = `Personlig træningstime med ${trainer.first_name} ${trainer.last_name} - ${formatDate(startDate)} ${formatTime(startDate)} - ${formatTime(endDate)}`; + + const checkoutSession: Stripe.Checkout.Session = await stripe.checkout.sessions.create({ + line_items: [ + { + price_data: { + currency: "DKK", + unit_amount: price, + product_data: { + name: productName, + description: productDescription, + } + }, + quantity: 1 + } + ], + expires_at: Math.floor(Date.now()/1000 + 60*31), + mode: 'payment', + success_url: `http://localhost:5173/orders`, + cancel_url: `http://localhost:5173` + }); + + const insertQuery = await client.query(` + WITH inserted_reserved_timeslot AS ( + INSERT INTO reserved_timeslots (trainer_id, start_time, end_time) + VALUES ($1, $2, $3) + RETURNING * + ) + + INSERT INTO orders (timeslot_id, user_id, price, checkout_session) + select id, $4, $5, $6 + FROM inserted_reserved_timeslot + RETURNING id; + `, [ + orderBody.trainer, + startDate, + endDate, + req.user?.userId, + price, + checkoutSession.id + ]); + + const insertedData = insertQuery.rows[0]; + + await client.query("COMMIT"); + + return res.status(200).send({ + id: insertedData.id, + trainerId: orderBody.trainer, + startDate: orderBody.startDate, + endDate: orderBody.endDate, + price, + url: checkoutSession.url + }); + } catch (error: DatabaseError | Error | any) { + await client.query("ROLLBACK"); + console.error(error); + return res.sendStatus(500); + } + finally { + client.release(); + } + } catch (error: DatabaseError | Error | any) { + console.error(error); + return res.sendStatus(500); + } +}) + +export default router; \ No newline at end of file diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index eee0ce6..d1115a7 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -7,6 +7,9 @@ import register from "./register" import center from "./center"; import trainer from "./trainer/index"; import timeslot from "./timeslot"; +import createOrder from "./createOrder"; +import orderNewTimeProposal from "./orderNewTimeProposal"; +import orderNewTime from "./orderNewTime"; import order from "./order"; import stripeWebhook from "./stripeWebhook"; import { bodyParserErrorHandler } from "../middlewares/bodyParserErrorHandler"; @@ -18,6 +21,9 @@ router.use(register); router.use(center); router.use(trainer); router.use(timeslot); +router.use(createOrder); +router.use(orderNewTimeProposal); +router.use(orderNewTime); router.use(order); export default router; \ No newline at end of file diff --git a/server/src/routes/order.ts b/server/src/routes/order.ts index f55fb26..8cd393e 100644 --- a/server/src/routes/order.ts +++ b/server/src/routes/order.ts @@ -1,235 +1,123 @@ -import express, { Router, Request, Response, NextFunction } from "express"; -import Joi from "joi" -import dayjs, { Dayjs } from "dayjs" -import isoWeek from "dayjs/plugin/isoWeek" -import utc from "dayjs/plugin/utc" -import LocalizedFormat from "dayjs/plugin/localizedFormat" -import { } from "dayjs/locale/da"; +import express, { Router, Request, Response } from "express"; +import Joi from "joi"; +import dayjs from "dayjs"; -import { client as pool } from "../db"; +import { client } from "../db"; import { DatabaseError } from "pg"; -import { idSchema } from "../schemas"; import { UserAuth } from "../middlewares/auth"; import { AuthedRequest } from "../interfaces/auth"; -import Stripe from 'stripe'; -import { stripe } from "../stripe"; -import { stripeWebhookSecret } from "../environment"; -import { bodyParserErrorHandler } from "../middlewares/bodyParserErrorHandler"; import Trainer from "../interfaces/trainer"; - -dayjs.extend(isoWeek) -dayjs.extend(utc) -dayjs.extend(LocalizedFormat); +import { OrderObject, OrderObjectStatus, OrderStatus, PaymentIntentStatus } from "../interfaces/order"; const router: Router = express.Router(); -const orderSchema = Joi.object({ - trainer: idSchema.required(), - startDate: Joi.date().required(), - endDate: Joi.date().required() -}); - -interface OrderBody { - trainer: number - startDate: Date - endDate: Date -} - -interface TimeslotValidQueryResult { - weekly_timeslot_available: boolean - time_already_reserved: boolean +interface CancelOrderLookup { + order_status: OrderStatus + payment_intent_status: PaymentIntentStatus + start_time: Date } -router.post("/createTestOrder", async (req: AuthedRequest, res: Response) => { - const checkoutSession: Stripe.Checkout.Session = await stripe.checkout.sessions.create({ - line_items: [ - { - price_data: { - currency: "DKK", - unit_amount: 1000, - product_data: { - name: "Træningstime", - description: "Personlig træningstime med ...", - } - }, - quantity: 1 - } - ], - mode: 'payment', - success_url: `http://localhost:5173/success`, - cancel_url: `http://localhost:5173/cancel` - }); - - res.send(checkoutSession); -}); +router.post("/order/:id/cancel", UserAuth, async (req: AuthedRequest, res: Response) => { + try { + const lookupResult = await client.query(` + SELECT + order_status, + payment_intents.status as payment_intent_status, + start_time + FROM orders + LEFT JOIN payment_intents ON payment_intents.id = orders.payment_intent + LEFT JOIN reserved_timeslots ON reserved_timeslots.id = orders.timeslot_id + WHERE orders.id = $1 + AND user_id = $2 + AND order_status = 'Confirmed'; + `, [ + req.params.id, + req.user?.userId + ]); + + if (lookupResult.rows.length !== 1) { + return res.sendStatus(404); + } -function formatDate(date: Dayjs): string { - let output: string = dayjs(date).locale("da").format("dddd D. MMM YYYY"); - let outputStringArray = output.split(""); - outputStringArray[0] = outputStringArray[0].toLocaleUpperCase(); - output = outputStringArray.join(""); + const order: CancelOrderLookup = lookupResult.rows[0]; - return output; -} + const dateValidation = Joi.date().min(Date.now()).validate(order.start_time) + if (dateValidation.error !== undefined) { + return res.status(400).send({ message: "Timeslot has already occurred" }); + } -function formatTime(date: Dayjs): string { - let output: string = dayjs(date).locale("da").format("HH:mm"); + await client.query(` + UPDATE orders SET + order_status = 'CancelledByUser' + WHERE id = $1 + RETURNING *; + `, [ + req.params.id + ]); - return output; -} + return res.sendStatus(204); + } catch (error: DatabaseError | Error | any) { + console.error(error); + return res.sendStatus(500); + } +}); -router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => { +router.get("/order", UserAuth, async (req: AuthedRequest, res: Response) => { try { - const client = await pool.connect(); - try { - const validation = orderSchema.validate(req.body, { abortEarly: false }); - if (validation.error !== undefined) { - return res.status(400).send(validation.error.details); + const databaseResult = await client.query(` + SELECT + orders.id, + order_status, + price, + created_at, + payment_intents.status as payment_intent_status, + start_time, + end_time, + json_build_object( + 'id',trainers.id, + 'first_name',users.first_name, + 'last_name',users.last_name, + 'center_id',trainers.center_id, + 'center_name',centers.name + ) as trainer + FROM orders + LEFT JOIN reserved_timeslots ON reserved_timeslots.id = orders.timeslot_id + LEFT JOIN payment_intents ON payment_intents.id = orders.payment_intent + LEFT JOIN trainers ON trainers.id = reserved_timeslots.trainer_id + LEFT JOIN centers on trainers.center_id = centers.id + LEFT JOIN users on users.id = trainers.user_id + WHERE + orders.user_id = $1 + AND ( + orders.order_status = 'Confirmed' + OR orders.order_status = 'CancelledByTrainer' + OR orders.order_status = 'CancelledByUser' + ); + `, [ + req.user?.userId + ]); + + const orders: OrderObject[] = databaseResult.rows.map((order): OrderObject => { + const paymentStatus = order.payment_intent_status === "Successful" ? "Successful" : "Processing" + + let status: OrderObjectStatus = paymentStatus; + if (order.order_status === "CancelledByTrainer") + status = "CancelledByTrainer" + else if (order.order_status === "CancelledByUser") + status = "CancelledByUser" + + return { + id: order.id, + trainer: order.trainer, + startDate: dayjs(order.start_time).format('YYYY-MM-DDTHH:mm:ss[Z]'), + endDate: dayjs(order.end_time).format('YYYY-MM-DDTHH:mm:ss[Z]'), + status: status, + price: order.price, + created_at: order.created_at, } + }); - const orderBody: OrderBody = validation.value; - - const startDate: dayjs.Dayjs = dayjs(orderBody.startDate).utcOffset(0); - const endDate: dayjs.Dayjs = dayjs(orderBody.endDate).utcOffset(0); - - const startTime: string = `${startDate.hour()}:${startDate.minute()}`; - const endTime: string = `${endDate.hour()}:${endDate.minute()}`; - - const weekday = startDate.isoWeekday(); - - await client.query("BEGIN"); - - const trainerLookup = await client.query(` - SELECT trainers.id, first_name, last_name, center_id, centers.name as center_name FROM trainers - JOIN users ON trainers.user_id = users.id - JOIN centers on trainers.center_id = centers.id - WHERE trainers.id = $1; - `, [ - orderBody.trainer - ]); - - if (trainerLookup.rows.length !== 1) - return res.status(400).send([{ - message: "\"trainer\" was not found", - path: [ - "trainer" - ], - type: "trainer.not_found" - }]); - - const trainer: Trainer = trainerLookup.rows[0]; - - const timeslotValidQuery = await client.query(` - select - EXISTS( - SELECT 1 FROM weekly_timeslots - WHERE - trainer_id = $1 - AND day_of_week = $2 - AND start_time = $3 - AND end_time = $4 - ) as weekly_timeslot_available, - EXISTS( - SELECT 1 FROM public.reserved_timeslots - WHERE - ((start_time >= $5 AND start_time < $6) - OR (end_time > $5 AND end_time <= $6)) - AND trainer_id = $1 - ) as time_already_reserved; - `, [ - orderBody.trainer, - weekday, - startTime, - endTime, - startDate.toISOString(), - endDate.toISOString() - ]); - - const timeslotValidQueryResult: TimeslotValidQueryResult = timeslotValidQuery.rows[0]; - - if (timeslotValidQueryResult.time_already_reserved || !timeslotValidQueryResult.weekly_timeslot_available) - return res.status(400).send([{ - message: "timeslot was not found", - path: [ - "startDate" - ], - type: "startDate.invalid" - }]); - - const priceQuery = await client.query(` - SELECT hourly_price FROM trainers WHERE id = $1; - `, [ - orderBody.trainer - ]); - - const hourlyPrice = priceQuery.rows[0].hourly_price; - - const hours = endDate.diff(startDate, "hours", true); - const price = Math.ceil(hours * hourlyPrice); - - const productName: string = `Personlig træningstime ${trainer.center_name}`; - const productDescription: string = `Personlig træningstime med ${trainer.first_name} ${trainer.last_name} - ${formatDate(startDate)} ${formatTime(startDate)} - ${formatTime(endDate)}`; - - const checkoutSession: Stripe.Checkout.Session = await stripe.checkout.sessions.create({ - line_items: [ - { - price_data: { - currency: "DKK", - unit_amount: price, - product_data: { - name: productName, - description: productDescription, - } - }, - quantity: 1 - } - ], - expires_at: Math.floor(Date.now()/1000 + 60*31), - mode: 'payment', - success_url: `http://localhost:5173/success`, - cancel_url: `http://localhost:5173/cancel` - }); - - const insertQuery = await client.query(` - WITH inserted_reserved_timeslot AS ( - INSERT INTO reserved_timeslots (trainer_id, start_time, end_time) - VALUES ($1, $2, $3) - RETURNING * - ) - - INSERT INTO orders (timeslot_id, user_id, price, checkout_session) - select id, $4, $5, $6 - FROM inserted_reserved_timeslot - RETURNING id; - `, [ - orderBody.trainer, - startDate, - endDate, - req.user?.userId, - price, - checkoutSession.id - ]); - - const insertedData = insertQuery.rows[0]; - - await client.query("COMMIT"); - - return res.status(200).send({ - id: insertedData.id, - trainerId: orderBody.trainer, - startDate: orderBody.startDate, - endDate: orderBody.endDate, - price, - url: checkoutSession.url - }); - } catch (error: DatabaseError | Error | any) { - await client.query("ROLLBACK"); - console.error(error); - return res.sendStatus(500); - } - finally { - client.release(); - } + return res.status(200).send(orders); } catch (error: DatabaseError | Error | any) { console.error(error); return res.sendStatus(500); diff --git a/server/src/routes/orderNewTime.ts b/server/src/routes/orderNewTime.ts new file mode 100644 index 0000000..336af5b --- /dev/null +++ b/server/src/routes/orderNewTime.ts @@ -0,0 +1,162 @@ +import express, { Router, Response } from "express"; +import Joi from "joi" +import dayjs, { Dayjs } from "dayjs" +import isoWeek from "dayjs/plugin/isoWeek" +import utc from "dayjs/plugin/utc" +import LocalizedFormat from "dayjs/plugin/localizedFormat" +import { } from "dayjs/locale/da"; + +import { client } from "../db"; +import { DatabaseError } from "pg"; +import { idSchema } from "../schemas"; +import { UserAuth } from "../middlewares/auth"; +import { AuthedRequest } from "../interfaces/auth"; +import Stripe from 'stripe'; +import { stripe } from "../stripe"; +import Trainer from "../interfaces/trainer"; +import { OrderStatus, PaymentIntentStatus } from "../interfaces/order"; + +dayjs.extend(isoWeek) +dayjs.extend(utc) +dayjs.extend(LocalizedFormat); + +const router: Router = express.Router(); + +const orderSchema = Joi.object({ + startDate: Joi.date().min("now").required(), + endDate: Joi.date().min(Joi.ref("startDate")).required() +}); + +interface OrderBody { + trainer: number + startDate: Date + endDate: Date +} + +interface TimeslotValidQueryResult { + weekly_timeslot_available: boolean + time_already_reserved: boolean +} + +interface OrderLookup { + order_status: OrderStatus + start_time: Date + end_time: Date + trainer_id: number + timeslot_id: number +} + +router.put("/order/:id", UserAuth, async (req: AuthedRequest, res: Response) => { + try { + const lookupResult = await client.query(` + SELECT + order_status, + start_time, + end_time, + trainer_id, + timeslot_id + FROM orders + LEFT JOIN payment_intents ON payment_intents.id = orders.payment_intent + LEFT JOIN reserved_timeslots ON reserved_timeslots.id = orders.timeslot_id + WHERE orders.id = $1 + AND user_id = $2 + AND order_status = 'Confirmed'; + `, [ + req.params.id, + req.user?.userId + ]); + + if (lookupResult.rows.length !== 1) { + return res.sendStatus(404); + } + + const order: OrderLookup = lookupResult.rows[0]; + + const originalStartDate: dayjs.Dayjs = dayjs(order.start_time).utcOffset(0); + const originalEndDate: dayjs.Dayjs = dayjs(order.end_time).utcOffset(0); + + /** The duration of the original timeslot in miliseconds */ + const originalDuration: number = originalEndDate.diff(originalStartDate); + + const validation = orderSchema.validate(req.body, { abortEarly: false }); + if (validation.error !== undefined) { + return res.status(400).send(validation.error.details); + } + + const orderBody: OrderBody = validation.value; + + const startDate: dayjs.Dayjs = dayjs(orderBody.startDate).utcOffset(0); + const endDate: dayjs.Dayjs = dayjs(orderBody.endDate).utcOffset(0); + + const startTime: string = `${startDate.hour()}:${startDate.minute()}`; + const endTime: string = `${endDate.hour()}:${endDate.minute()}`; + + const weekday = startDate.isoWeekday(); + + const timeslotValidQuery = await client.query(` + select + EXISTS( + SELECT 1 FROM weekly_timeslots + WHERE + trainer_id = $1 + AND day_of_week = $2 + AND start_time = $3 + AND end_time = $4 + ) as weekly_timeslot_available, + EXISTS( + SELECT 1 FROM public.reserved_timeslots + WHERE + ((start_time >= $5 AND start_time < $6) + OR (end_time > $5 AND end_time <= $6)) + AND trainer_id = $1 + ) as time_already_reserved; + `, [ + order.trainer_id, + weekday, + startTime, + endTime, + startDate.toISOString(), + endDate.toISOString() + ]); + + const timeslotValidQueryResult: TimeslotValidQueryResult = timeslotValidQuery.rows[0]; + + if (timeslotValidQueryResult.time_already_reserved || !timeslotValidQueryResult.weekly_timeslot_available) + return res.status(400).send([{ + message: "timeslot was not found", + path: [ + "startDate" + ], + type: "startDate.invalid" + }]); + + const duration = endDate.diff(startDate); + + if (duration !== originalDuration) + return res.status(400).send([{ + message: "timeslot is not same length as original", + path: [ + "startDate" + ], + type: "startDate.invalid" + }]); + + await client.query(` + UPDATE reserved_timeslots SET + start_time = $1, + end_time = $2 + WHERE id = $3; + `, [ + startDate, + endDate, + order.timeslot_id + ]); + + return res.sendStatus(204); + } catch (error: DatabaseError | Error | any) { + console.error(error); + return res.sendStatus(500); + } +}) + +export default router; \ No newline at end of file diff --git a/server/src/routes/orderNewTimeProposal.ts b/server/src/routes/orderNewTimeProposal.ts new file mode 100644 index 0000000..4450e78 --- /dev/null +++ b/server/src/routes/orderNewTimeProposal.ts @@ -0,0 +1,178 @@ +import express, { Router, Request, Response } from "express"; +import Joi from "joi"; +import dayjs from "dayjs"; + +import { client } from "../db"; +import { DatabaseError } from "pg"; +import { UserAuth } from "../middlewares/auth"; +import { AuthedRequest } from "../interfaces/auth"; +import Trainer from "../interfaces/trainer"; +import { OrderObject, OrderObjectStatus, OrderStatus, PaymentIntentStatus } from "../interfaces/order"; +import { ReservedTimeslots, Timeslot, WeeklyTimeslot } from "../interfaces/timeslot"; + +const router: Router = express.Router(); + +interface OrderLookup { + order_status: OrderStatus + start_time: Date + end_time: Date + trainer_id: number + timeslot_id: number +} + +interface TimeProposal { + startDate: string + endDate: string +} + +interface NewTimeslotPropsalFilter { + startDate: Date + endDate: Date +} + +const newTimeslotPropsalFilterSchema = Joi.object({ + startDate: Joi.date().default(Date.now()), + endDate: Joi.date().default(Date.now()) +}); + +router.get("/order/:id/newtimeslots", UserAuth, async (req: AuthedRequest, res: Response) => { + try { + const lookupResult = await client.query(` + SELECT + order_status, + start_time, + end_time, + trainer_id, + timeslot_id + FROM orders + LEFT JOIN payment_intents ON payment_intents.id = orders.payment_intent + LEFT JOIN reserved_timeslots ON reserved_timeslots.id = orders.timeslot_id + WHERE orders.id = $1 + AND user_id = $2 + AND order_status = 'Confirmed'; + `, [ + req.params.id, + req.user?.userId + ]); + + if (lookupResult.rows.length !== 1) { + return res.sendStatus(404); + } + + const order: OrderLookup = lookupResult.rows[0]; + + const dateValiadtion = Joi.date().min(Date.now()).validate(order.start_time) + if (dateValiadtion.error !== undefined) { + return res.status(400).send({ message: "Timeslot has already occurred" }); + } + + const filterValidation = newTimeslotPropsalFilterSchema.validate(req.query, { abortEarly: false, stripUnknown: true }); + if (filterValidation.error !== undefined) { + return res.status(400).send(filterValidation.error.details); + } + + const newTimeslotPropsalFilter: NewTimeslotPropsalFilter = filterValidation.value; + + const originalStartDate: dayjs.Dayjs = dayjs(order.start_time).utcOffset(0); + const originalEndDate: dayjs.Dayjs = dayjs(order.end_time).utcOffset(0); + + /** The duration of the original timeslot in miliseconds */ + const originalDuration: number = originalStartDate.diff(originalEndDate); + + const latestAllowedFilterEndDate: dayjs.Dayjs = originalStartDate.add(1, "month"); + + const nowDate: dayjs.Dayjs = dayjs().utcOffset(0).startOf("date"); + + let filterStartDate: dayjs.Dayjs = dayjs(newTimeslotPropsalFilter.startDate).utcOffset(0).startOf("date"); + let filterEndDate: dayjs.Dayjs = dayjs(newTimeslotPropsalFilter.endDate).utcOffset(0).startOf("date"); + + if (filterStartDate.isBefore(nowDate)) { + filterStartDate = nowDate; + } + + if (filterEndDate.isAfter(latestAllowedFilterEndDate)) { + filterEndDate = latestAllowedFilterEndDate; + } + + let weekdays: number[] = []; + for (let day: dayjs.Dayjs = filterStartDate; !day.isAfter(filterEndDate); day = day.add(1, "day")) { + if (!weekdays.includes(day.isoWeekday())) + weekdays.push(day.isoWeekday()); + } + + const weeklyTimeslotLookup = await client.query(` + SELECT + id, + day_of_week, + start_time, + end_time + FROM weekly_timeslots + WHERE trainer_id = $1 + AND day_of_week = ANY($2); + `, [ + order.trainer_id, + weekdays + ]); + + const weeklyTimeslots: WeeklyTimeslot[] = weeklyTimeslotLookup.rows; + + const reservedTimeslotLookup = await client.query(` + SELECT + start_time, + end_time + FROM public.reserved_timeslots + JOIN orders ON reserved_timeslots.id = orders.timeslot_id + WHERE + (start_time >= $1 AND end_time <= $2) + AND reserved_timeslots.trainer_id = $3 + AND ( + orders.order_status = 'Confirmed' + OR orders.order_status = 'Created' + ); + `, [ + filterStartDate, + filterEndDate.add(1, "day"), + order.trainer_id + ]); + + const reservedTimeslots: ReservedTimeslots[] = reservedTimeslotLookup.rows; + + const timeProposals: TimeProposal[] = []; + + for (let day: dayjs.Dayjs = filterStartDate; !day.isAfter(filterEndDate); day = day.add(1, "day")){ + const weekDay = day.isoWeekday(); + + weeklyTimeslots: for (const weeklyTimeslot of weeklyTimeslots) { + if (weeklyTimeslot.day_of_week !== weekDay) + continue; + + const startTime = dayjs(`${day.toISOString().split("T")[0]}T${weeklyTimeslot.start_time}`); + const endTime = dayjs(`${day.toISOString().split("T")[0]}T${weeklyTimeslot.end_time}`); + for (const reservedTimeslot of reservedTimeslots) { + const reservedTimeStart = dayjs(reservedTimeslot.start_time); + const reservedTimeEnd = dayjs(reservedTimeslot.end_time); + if ((!reservedTimeStart.isBefore(startTime) && reservedTimeStart.isBefore(endTime)) || + (reservedTimeEnd.isAfter(startTime) && !reservedTimeEnd.isAfter(endTime))) { + continue weeklyTimeslots; + } + } + + const proposedDuration: number = startTime.diff(endTime); + if (proposedDuration !== originalDuration) + continue; + + timeProposals.push({ + startDate: `${day.toISOString().split("T")[0]}T${weeklyTimeslot.start_time}Z`, + endDate: `${day.toISOString().split("T")[0]}T${weeklyTimeslot.end_time}Z` + }); + } + } + + return res.status(200).send(timeProposals); + } catch (error: DatabaseError | Error | any) { + console.error(error); + return res.sendStatus(500); + } +}); + +export default router; \ No newline at end of file diff --git a/server/src/routes/timeslot.ts b/server/src/routes/timeslot.ts index 3489eef..12caf78 100644 --- a/server/src/routes/timeslot.ts +++ b/server/src/routes/timeslot.ts @@ -69,12 +69,12 @@ router.get("/timeslot", async (req: Request, res: Response) => { const startDate = dayjs(timeslotFilters.startDate).utcOffset(0); const endDate = dayjs(timeslotFilters.endDate).utcOffset(0); - + let filterStartDate: dayjs.Dayjs = dayjs(timeslotFilters.startDate).utcOffset(0).startOf("date"); const filterEndDate: dayjs.Dayjs = dayjs(timeslotFilters.endDate).utcOffset(0).startOf("date"); const nowDate: dayjs.Dayjs = dayjs().utcOffset(0).startOf("date"); - + if (filterStartDate.isBefore(nowDate)) { filterStartDate = nowDate; } @@ -109,10 +109,7 @@ router.get("/timeslot", async (req: Request, res: Response) => { )) FROM public.reserved_timeslots JOIN orders ON reserved_timeslots.id = orders.timeslot_id WHERE - ( - (start_time >= $1 AND start_time < $2) - OR (end_time > $1 AND end_time <= $2) - ) + (start_time >= $1 AND end_time <= $2) AND weekly_timeslots.trainer_id = reserved_timeslots.trainer_id AND ( orders.order_status = 'Confirmed' @@ -139,8 +136,8 @@ router.get("/timeslot", async (req: Request, res: Response) => { ORDER BY weekly_timeslots.trainer_id ASC; `, [ - startDate.toISOString(), - endDate.toISOString(), + filterStartDate.toISOString(), + filterEndDate.add(1, "day").toISOString(), timeslotFilters.trainer !== undefined ? timeslotFilters.trainer : [], timeslotFilters.trainer === undefined, weekdays, @@ -194,10 +191,10 @@ router.get("/timeslot", async (req: Request, res: Response) => { } } const hourlyPrice = trainer.trainer.hourly_price; - + const hours = dayjs(endTime).diff(dayjs(startTime), "hours", true); const price = Math.ceil(hours * hourlyPrice); - + trainerWithAvailableTimeslots.timeslots.push({ startDate: `${day.toISOString().split("T")[0]}T${timeslot.start_time}Z`, endDate: `${day.toISOString().split("T")[0]}T${timeslot.end_time}Z`, @@ -206,7 +203,7 @@ router.get("/timeslot", async (req: Request, res: Response) => { } } - trainerWithAvailableTimeslots.timeslots.sort((a,b)=>a.startDate.localeCompare(b.startDate)); + trainerWithAvailableTimeslots.timeslots.sort((a, b) => a.startDate.localeCompare(b.startDate)); trainers.push(trainerWithAvailableTimeslots); }