diff --git a/endpoints.md b/endpoints.md index 9956c83..6309c70 100644 --- a/endpoints.md +++ b/endpoints.md @@ -13,12 +13,14 @@ | [ ] | 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 | -| [ ] | GET | /trainer/:id/time_slot | Get available timeslots for specific trainer | | -| [ ] | GET | /trainer/time_slot | Trainer can get reserved timeslots | Trainer | -| [ ] | PUT | /trainer/time_slot/:id | Trainer can change reserved timeslot | Trainer | -| [ ] | DELTE | /trainer/time_slot/:id | Trainer can delete reserved timeslot | Trainer | +| [x] | GET | /timeslot | Filter for available timeslots | | +| [ ] | GET | /trainer/timeslot | Trainer can get reserved timeslots | Trainer | +| [ ] | PUT | /trainer/timeslot/:id | Trainer can change reserved timeslot | Trainer | +| [ ] | DELTE | /trainer/timeslot/:id | Trainer can delete reserved timeslot | Trainer | | [ ] | GET | /order | User can get list of orders | User | | [ ] | 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 | diff --git a/server/package-lock.json b/server/package-lock.json index 240b158..feaf1a3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "bcrypt": "^5.1.0", "cookie-parser": "^1.4.6", + "dayjs": "^1.11.7", "dotenv": "^16.0.3", "express": "^4.18.2", "joi": "^17.9.1", @@ -732,6 +733,11 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/dayjs": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", + "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/server/package.json b/server/package.json index b24dda8..07ed150 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "build": "tsc", "start": "node dist/index.js", - "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\"", + "dev": "concurrently \"tsc --watch\" \"nodemon -q dist/index.js\"", "migrate": "ts-node src/migrations/index.ts", "populate": "ts-node src/migrations/populate.ts" }, @@ -16,6 +16,7 @@ "dependencies": { "bcrypt": "^5.1.0", "cookie-parser": "^1.4.6", + "dayjs": "^1.11.7", "dotenv": "^16.0.3", "express": "^4.18.2", "joi": "^17.9.1", diff --git a/server/src/interfaces/timeslot.ts b/server/src/interfaces/timeslot.ts new file mode 100644 index 0000000..7326bd5 --- /dev/null +++ b/server/src/interfaces/timeslot.ts @@ -0,0 +1,18 @@ +import Trainer from "./trainer" + +export interface Timeslot { + startTime: Date + endTime: Date +} + +export interface WeeklyTimeslot { + day_of_week: number + start_time: string + end_time: string +} + +export interface ReservedTimeslots { + id: number + startTime: string | Date + endTime: string | Date +} \ No newline at end of file diff --git a/server/src/middlewares/trainer.ts b/server/src/middlewares/trainer.ts new file mode 100644 index 0000000..650f7be --- /dev/null +++ b/server/src/middlewares/trainer.ts @@ -0,0 +1,15 @@ +import { Request, Response, NextFunction } from "express"; +import { client } from "../db"; + +export async function trainerExists(req: Request, res: Response, next: NextFunction) { + const trainerExistsResult = await client.query(` + SELECT 1 FROM trainers where id = $1; + `, [ + req.params.trainer_id + ]); + + if (trainerExistsResult.rowCount < 1) { + return res.sendStatus(404); + } + return next(); +} \ No newline at end of file diff --git a/server/src/migrations/populate.ts b/server/src/migrations/populate.ts index cf7326c..5c87148 100644 --- a/server/src/migrations/populate.ts +++ b/server/src/migrations/populate.ts @@ -48,12 +48,12 @@ RETURNING id, user_id; const weekly_timeslots = await client.query(` INSERT INTO weekly_timeslots (trainer_id, day_of_week, start_time, end_time) VALUES -($1, 0, '11:00:00', '12:00:00'), -($1, 1, '10:00:00', '13:00:00'), -($1, 2, '10:00:00', '15:00:00'), -($2, 0, '10:00:00', '11:00:00'), -($2, 0, '11:00:00', '12:00:00'), -($2, 0, '12:00:00', '13:00:00') +($1, 1, '11:00:00', '12:00:00'), +($1, 2, '10:00:00', '13:00:00'), +($1, 3, '10:00:00', '15:00:00'), +($2, 1, '10:00:00', '11:00:00'), +($2, 1, '11:00:00', '12:00:00'), +($2, 1, '12:00:00', '13:00:00') RETURNING id; `, [ trainers.rows[0].id, diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 2db7986..af0db15 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -6,10 +6,12 @@ import login from "./login" import register from "./register" import center from "./center"; import trainer from "./trainer"; +import timeslot from "./timeslot"; router.use(login); router.use(register); router.use(center); router.use(trainer); +router.use(timeslot); export default router; \ No newline at end of file diff --git a/server/src/routes/timeslot.ts b/server/src/routes/timeslot.ts new file mode 100644 index 0000000..5b42b35 --- /dev/null +++ b/server/src/routes/timeslot.ts @@ -0,0 +1,167 @@ +import express, { Router, Request, Response, NextFunction } from "express"; +import Joi from "joi" +import dayjs from "dayjs" +import isoWeek from "dayjs/plugin/isoWeek" +import utc from "dayjs/plugin/utc" + +import { client } from "../db"; +import { DatabaseError } from "pg"; +import Trainer from "../interfaces/trainer"; +import { ReservedTimeslots, Timeslot, TrainerWeeklyTimeslot, WeeklyTimeslot } from "../interfaces/timeslot"; +import { TrainerAuth } from "../middlewares/auth"; +import { trainerExists } from "../middlewares/trainer"; +import { idSchema } from "../schemas"; + +dayjs.extend(isoWeek) +dayjs.extend(utc) + +const router: Router = express.Router(); + +const timeSchema = Joi.string().regex(/^\d{1,2}:\d{1,2}$/); + +const timeslotFiltersSchema = Joi.object({ + trainer: Joi.array().single().items( + idSchema.required() + ), + center: idSchema, + startDate: Joi.date().default(Date.now()), + endDate: Joi.date().default(Date.now()), + startTime: timeSchema, + endTime: timeSchema +}); + +interface TimeslotFilters { + trainer?: number[] + center?: number + startDate: Date + endDate: Date + startTime?: string + endTime?: string +} + +interface DatabaseResult { + trainer: Trainer + timeslots: WeeklyTimeslot[] + reserved_timeslots: ReservedTimeslots[] +} + +interface TrainerWithAvailableTimeslots extends Trainer { + timeslots: Timeslot[] +} + +router.get("/timeslot", async (req: Request, res: Response) => { + try { + + const validation = timeslotFiltersSchema.validate(req.query, { abortEarly: false, stripUnknown: true }); + if (validation.error !== undefined) { + return res.status(400).send(validation.error.details); + } + + const timeslotFilters: TimeslotFilters = validation.value; + + const filterStartDate: dayjs.Dayjs = dayjs(timeslotFilters.startDate).utcOffset(0).startOf("date"); + const filterEndDate: dayjs.Dayjs = dayjs(timeslotFilters.endDate).utcOffset(0).startOf("date"); + + let weekdays = []; + for (let day: dayjs.Dayjs = filterStartDate; !day.isAfter(filterEndDate); day = day.add(1, "day")) { + weekdays.push(day.isoWeekday()); + } + + const queryResult = await client.query(` + SELECT + weekly_timeslots.trainer_id, + json_build_object( + 'id',weekly_timeslots.trainer_id, + 'first_name',users.first_name, + 'last_name',users.last_name, + 'center_id',trainers.center_id, + 'center_name',centers.name + ) as trainer, + json_agg(json_build_object( + 'day_of_week',day_of_week, + 'start_time',start_time, + 'end_time',end_time) + ) as timeslots, + ( + SELECT json_agg(json_build_object( + 'id', id, + 'start_time', start_time, + 'end_time', end_time + )) FROM public.reserved_timeslots + WHERE + (start_time between '2023-04-16' AND '2023-04-20' + OR end_time between '2023-04-16' AND '2023-04-20') + AND weekly_timeslots.trainer_id = reserved_timeslots.trainer_id + ) as reserved_timeslots + FROM + weekly_timeslots + JOIN trainers ON weekly_timeslots.trainer_id = trainers.id + JOIN users ON trainers.user_id = users.id + JOIN centers ON trainers.center_id = centers.id + WHERE + ((trainer_id = ANY($1)) OR $2) + AND (day_of_week = ANY($3)) + GROUP BY + weekly_timeslots.trainer_id, + users.first_name, + users.last_name, + trainers.center_id, + centers.name, + trainer_id; + `, [ + timeslotFilters.trainer !== undefined ? timeslotFilters.trainer : [1], + timeslotFilters.trainer === undefined, + weekdays + ]); + + const databaseResult: DatabaseResult[] = queryResult.rows; + + const trainers: TrainerWithAvailableTimeslots[] = []; + + for (const trainer of databaseResult) { + const trainerWithAvailableTimeslots: TrainerWithAvailableTimeslots = { + ...trainer.trainer, + timeslots: [] + } + + for (let day: dayjs.Dayjs = filterStartDate; !day.isAfter(filterEndDate); day = day.add(1, "day")) { + const weekDay = day.isoWeekday(); + const reservedTimeslots = trainer.reserved_timeslots; + + timeslots: for (const timeslot of trainer.timeslots) { + if (timeslot.day_of_week !== weekDay) + continue timeslots; + const startTime = day.clone() + .hour(parseInt(timeslot.start_time.split(":")[0])) + .minute(parseInt(timeslot.start_time.split(":")[1])) + .second(parseInt(timeslot.start_time.split(":")[2])); + const endTime = day.clone() + .hour(parseInt(timeslot.end_time.split(":")[0])) + .minute(parseInt(timeslot.end_time.split(":")[1])) + .second(parseInt(timeslot.end_time.split(":")[2])); + for (const reservedTimeslot of reservedTimeslots) { + const reservedTimeStart = dayjs(reservedTimeslot.startTime); + const reservedTimeEnd = dayjs(reservedTimeslot.endTime); + if ((!reservedTimeStart.isBefore(startTime) && !reservedTimeStart.isAfter(endTime)) || + (!reservedTimeEnd.isBefore(startTime) && !reservedTimeStart.isAfter(endTime))) { + continue timeslots; + } + } + trainerWithAvailableTimeslots.timeslots.push({ + startTime: startTime.toDate(), + endTime: endTime.toDate() + }); + } + } + + trainers.push(trainerWithAvailableTimeslots); + } + + return res.status(200).send(trainers); + } 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/trainer.ts b/server/src/routes/trainer.ts index 2d9a87a..3659b12 100644 --- a/server/src/routes/trainer.ts +++ b/server/src/routes/trainer.ts @@ -4,7 +4,8 @@ import Joi from "joi" import { client } from "../db"; import { DatabaseError } from "pg"; import Trainer from "../interfaces/trainer"; -import { AdminAuth, UserAuth } from "../middlewares/auth"; +import { AdminAuth } from "../middlewares/auth"; +import { trainerExists } from "../middlewares/trainer"; const router: Router = express.Router(); @@ -85,18 +86,8 @@ const updateTrainerSchema = Joi.object({ hourly_price: Joi.number().integer().positive().required() }); -router.put("/trainer/:id", AdminAuth, async (req: Request, res: Response) => { +router.put("/trainer/:trainer_id", AdminAuth, trainerExists, async (req: Request, res: Response) => { try { - const lookupResult = await client.query(` - SELECT 1 FROM trainers where id = $1; - `, [ - req.params.id - ]); - - if (lookupResult.rowCount < 1) { - return res.sendStatus(404); - } - const validation = updateTrainerSchema.validate(req.body, { abortEarly: false }); if (validation.error !== undefined) { return res.status(400).send(validation.error.details); diff --git a/server/src/schemas/index.ts b/server/src/schemas/index.ts new file mode 100644 index 0000000..5f72f91 --- /dev/null +++ b/server/src/schemas/index.ts @@ -0,0 +1,3 @@ +import Joi from "joi" + +export const idSchema = Joi.number().integer().positive(); \ No newline at end of file