diff --git a/client/src/components/TrainerNewTime.vue b/client/src/components/TrainerNewTime.vue new file mode 100644 index 0000000..fbacfec --- /dev/null +++ b/client/src/components/TrainerNewTime.vue @@ -0,0 +1,123 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/TrainerOrderPopup.vue b/client/src/components/TrainerOrderPopup.vue new file mode 100644 index 0000000..e82b0bc --- /dev/null +++ b/client/src/components/TrainerOrderPopup.vue @@ -0,0 +1,204 @@ + + + + + \ No newline at end of file diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 7d2dc9d..dd285fc 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -40,6 +40,14 @@ const router = createRouter({ // this generates a separate chunk (About.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import('../views/user/UserOrdersView.vue') + }, + { + path: '/trainer/orders', + name: 'TrainerOrdersView', + // route level code-splitting + // this generates a separate chunk (About.[hash].js) for this route + // which is lazy-loaded when the route is visited. + component: () => import('../views/trainer/TrainerOrdersView.vue') } ] }) diff --git a/client/src/views/trainer/TrainerOrdersView.vue b/client/src/views/trainer/TrainerOrdersView.vue new file mode 100644 index 0000000..e9da998 --- /dev/null +++ b/client/src/views/trainer/TrainerOrdersView.vue @@ -0,0 +1,134 @@ + + + + + \ No newline at end of file diff --git a/endpoints.md b/endpoints.md index 46ea497..ea897d1 100644 --- a/endpoints.md +++ b/endpoints.md @@ -17,10 +17,9 @@ | [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 | /trainer/order | Trainer can get reserved timeslots | Trainer | +| [x] | PUT | /trainer/order/:id | Trainer can change reserved timeslot | Trainer | +| [x] | POST | /trainer/order/:id/cancel | Trainer can cancel 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 | @@ -31,3 +30,4 @@ | [ ] | GET | /verify_email | Verify email | | | [ ] | POST | /reset_password | Request password reset | | | [ ] | POST | /new_password | Set new password | | +| [x] | POST | /stripeWebhook | Strip webhook | Stripe | diff --git a/server/src/routes/trainer/index.ts b/server/src/routes/trainer/index.ts index 0cc6904..73638f9 100644 --- a/server/src/routes/trainer/index.ts +++ b/server/src/routes/trainer/index.ts @@ -2,9 +2,11 @@ import express, { Express, Router } from "express"; const router: Router = express.Router(); import trainer from "./trainer"; +import order from "./order"; import weeklyTimeslot from "./weeklyTimeslot"; router.use(weeklyTimeslot); router.use(trainer); +router.use(order); export default router; \ No newline at end of file diff --git a/server/src/routes/trainer/order.ts b/server/src/routes/trainer/order.ts new file mode 100644 index 0000000..5607465 --- /dev/null +++ b/server/src/routes/trainer/order.ts @@ -0,0 +1,258 @@ +import express, { Router, Request, Response } from "express"; +import Joi from "joi"; +import dayjs from "dayjs"; + +import { client } from "../../db"; +import { DatabaseError } from "pg"; +import { AuthedRequest } from "../../interfaces/auth"; +import Trainer from "../../interfaces/trainer"; +import { OrderObject, OrderObjectStatus, OrderStatus, PaymentIntentStatus } from "../../interfaces/order"; +import { TrainerAuth } from "../../middlewares/auth"; + +const router: Router = express.Router(); + +interface CancelOrderLookup { + order_status: OrderStatus + payment_intent_status: PaymentIntentStatus + start_time: Date +} + +router.get("/trainer/order", TrainerAuth, async (req: AuthedRequest, res: Response) => { + try { + 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 + trainers.user_id = $1 + AND ( + orders.order_status = 'Confirmed' + OR orders.order_status = 'CancelledByTrainer' + OR orders.order_status = 'CancelledByUser' + ) + ORDER BY + reserved_timeslots.start_time DESC; + `, [ + 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, + } + }); + + return res.status(200).send(orders); + } catch (error: DatabaseError | Error | any) { + console.error(error); + return res.sendStatus(500); + } +}); + +interface ChangeOrderLookup { + order_status: OrderStatus + start_time: Date + end_time: Date + trainer_id: number + timeslot_id: number +} + +interface OrderBody { + startDate: Date + endDate: Date +} + +const orderSchema = Joi.object({ + startDate: Joi.date().min("now").required(), + endDate: Joi.date().min(Joi.ref("startDate")).required() +}); + +router.put("/trainer/order/:id", TrainerAuth, 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 + LEFT JOIN trainers ON trainers.id = reserved_timeslots.trainer_id + WHERE orders.id = $1 + AND trainers.user_id = $2 + AND order_status = 'Confirmed'; + `, [ + req.params.id, + req.user?.userId + ]); + + if (lookupResult.rows.length !== 1) { + return res.sendStatus(404); + } + + const order: ChangeOrderLookup = 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 public.reserved_timeslots + JOIN orders ON reserved_timeslots.id = orders.timeslot_id + WHERE + ((start_time >= $2 AND start_time < $3) + OR (end_time > $2 AND end_time <= $3)) + AND trainer_id = $1 + AND ( + orders.order_status = 'Confirmed' + OR orders.order_status = 'Created' + ) + AND reserved_timeslots.id != $4 + ) as time_already_reserved; + `, [ + order.trainer_id, + startDate.toISOString(), + endDate.toISOString(), + order.timeslot_id + ]); + + const time_already_reserved: boolean = timeslotValidQuery.rows[0].time_already_reserved; + + if (time_already_reserved) + return res.status(400).send([{ + message: "timeslot is not available", + 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.toISOString(), + endDate.toISOString(), + order.timeslot_id + ]); + + return res.sendStatus(204); + } catch (error: DatabaseError | Error | any) { + console.error(error); + return res.sendStatus(500); + } +}); + +router.post("/trainer/order/:id/cancel", TrainerAuth, 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 + LEFT JOIN trainers ON trainers.id = reserved_timeslots.trainer_id + WHERE orders.id = $1 + AND trainers.user_id = $2 + AND order_status = 'Confirmed'; + `, [ + req.params.id, + req.user?.userId + ]); + + if (lookupResult.rows.length !== 1) { + return res.sendStatus(404); + } + + const order: CancelOrderLookup = lookupResult.rows[0]; + + 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" }); + } + + await client.query(` + UPDATE orders SET + order_status = 'CancelledByTrainer' + WHERE id = $1 + RETURNING *; + `, [ + req.params.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