Added trainer order handling views and endpoints

main
Filip Borum Poulsen 3 years ago
parent 720fe60821
commit 0713b98baf

@ -0,0 +1,123 @@
<template>
<div class="popup">
<div class="TrainerNewTime" @click.stop>
<VDatePicker class="dateSelector" v-model.range="range" mode="datetime" :rules="datePickerRules" is24hr
timezone="utc" :input-debounce="50" />
<div class="submitButton" @click="selectTimeslot()">
Vælg
</div>
<div class="closeButton" @click="$emit('closePopup')">x</div>
</div>
</div>
</template>
<script lang="ts">
import type { Timeslot } from '@/interfaces/timeslot';
import type { Order } from '@/interfaces/order';
import type { PropType } from 'vue';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import utc from "dayjs/plugin/utc"
import { } from "dayjs/locale/da";
dayjs.extend(LocalizedFormat);
dayjs.extend(utc);
export default {
name: "Home",
emits: ["closePopup", "refreshList"],
props: {
order: {
type: Object as PropType<Order>,
required: true
}
},
data() {
return {
range: {
start: dayjs().set("minute", 0).toDate(),
end: dayjs().set("minute", 0).toDate()
},
datePickerRules: {
seconds: 0,
milliseconds: 0
},
timeslots: [] as Timeslot[]
}
},
methods: {
async selectTimeslot() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer/order/${this.order.id}`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "PUT",
body: JSON.stringify({ startDate: this.range.start, endDate: this.range.end }),
headers: {
"Content-Type": "Application/json"
}
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 400) {
const data = await res.json();
alert(data[0].message)
}
else if (res.status === 204) {
this.$emit("refreshList");
this.$emit("closePopup");
}
}
}
}
</script>
<style scoped>
.popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #000a;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.TrainerNewTime {
background-color: white;
border-radius: 0.5rem;
padding: 50px;
margin: 50px;
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
}
.closeButton {
position: absolute;
top: 10px;
right: 20px;
font-size: 2em;
cursor: pointer;
}
.closeButton:hover {
text-decoration: underline;
}
.submitButton {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
}
.submitButton:hover {
text-decoration: underline;
border-color: black;
}
</style>

@ -0,0 +1,204 @@
<template>
<div class="popup" @click="$emit('closePopup')">
<div class="Order" @click.stop v-show="!showNewTimes">
<div class="trainer">
{{ order.trainer.first_name }} {{ order.trainer.last_name }}
</div>
<div class="center">
{{ order.trainer.center_name }}
</div>
<div class="date">
{{ formatDate(order.startDate) }}
</div>
<div class="time">
{{ formatTime(order.startDate) }} -
{{ formatTime(order.endDate) }}
</div>
<div class="price">
{{ formatPrice(order.price) }}
</div>
<div class="buttons">
<div class="moveButton" @click="moveOrder()" v-if="showButtons">
Flyt tid
</div>
<div class="cancelButton" @click="cancelOrder(order.id)" v-if="showButtons">
Aflys
</div>
</div>
<div class="closeButton" @click="$emit('closePopup')">x</div>
</div>
<TrainerNewTime :order="order" @closePopup="$emit('closePopup')" @refreshList="$emit('refreshList')" v-if="showNewTimes"></TrainerNewTime>
</div>
</template>
<script lang="ts">
import TrainerNewTime from '@/components/TrainerNewTime.vue'
import type { PropType } from 'vue';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import utc from "dayjs/plugin/utc"
import { } from "dayjs/locale/da";
import type { Order } from '@/interfaces/order';
dayjs.extend(LocalizedFormat);
dayjs.extend(utc);
export default {
name: "UserOrderPopup",
emits: ["closePopup", "refreshList"],
components: {
TrainerNewTime
},
props: {
order: {
type: Object as PropType<Order>,
required: true
}
},
data() {
return {
showNewTimes: false
};
},
computed: {
showButtons(): boolean {
console.log(this.order.status !== 'CancelledByTrainer' , this.order.status !== 'CancelledByUser' , (new Date(this.order.startDate.split("Z")[0])).getTime() > (new Date()).getTime());
console.log(this.order.startDate.split("Z")[0]);
console.log(1, new Date(this.order.startDate), new Date(this.order.startDate.split("Z")[0]));
console.log(new Date(this.order.startDate.split("Z")[0]), new Date());
return this.order.status !== 'CancelledByTrainer' && this.order.status !== 'CancelledByUser' && (new Date(this.order.startDate.split("Z")[0])).getTime() > (new Date()).getTime();
}
},
methods: {
moveOrder() {
this.showNewTimes = true;
},
async cancelOrder(id: number) {
if (id === undefined) return;
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer/order/${id}/cancel`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "POST"
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 204) {
this.$emit('refreshList');
this.$emit('closePopup');
}
},
formatDate(date: string): 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;
},
formatTime(date: string): string {
let output: string = dayjs(date).utcOffset(0).locale("da").format("HH:mm");
return output;
},
formatPrice(price: number): string {
const DanishKrone = new Intl.NumberFormat('dk', {
style: 'currency',
currency: 'DKK',
});
return DanishKrone.format(price / 100);
}
}
}
</script>
<style scoped>
.popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #000a;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.Order {
background-color: white;
border-radius: 0.5rem;
padding: 30px;
display: flex;
flex-direction: column;
min-width: 50%;
position: relative;
}
.trainer {
font-size: 1.8em;
}
.center {
opacity: 0.8;
font-size: 1.25em;
}
.date {
font-size: 1em;
}
.time {
font-size: 1em;
}
.price {
font-size: 1.25em;
}
.buttons {
align-self: flex-end;
display: flex;
gap: 20px;
}
.cancelButton {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
}
.cancelButton:hover {
text-decoration: underline;
border-color: black;
}
.moveButton {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
}
.moveButton:hover {
text-decoration: underline;
border-color: black;
}
.closeButton {
position: absolute;
top: 10px;
right: 20px;
font-size: 2em;
cursor: pointer;
}
.closeButton:hover {
text-decoration: underline;
}
</style>

@ -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')
}
]
})

@ -0,0 +1,134 @@
<template>
<div class="Orders">
<div class="order" v-for="order of orders" :key="order.id" @click="openPopup(order)"
:class="{ cancelled: order.status === 'CancelledByTrainer' || order.status === 'CancelledByUser' }">
<div class="time">
{{ formatDate(order.startDate) }} {{ formatTime(order.startDate) }} - {{ formatTime(order.endDate) }}
</div>
<div class="status">
{{ formatStatus(order.status) }}
</div>
</div>
<TrainerOrderPopup @closePopup="closePopup" @refreshList="fetchOrders" :order="selectedOrder" v-if="showPopup">
</TrainerOrderPopup>
</div>
</template>
<script lang="ts">
import TrainerOrderPopup from '@/components/TrainerOrderPopup.vue'
import type { Order } from '@/interfaces/order';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import utc from "dayjs/plugin/utc"
import { } from "dayjs/locale/da";
dayjs.extend(LocalizedFormat);
dayjs.extend(utc);
export default {
name: "TrainerOrdersView",
data() {
return {
orders: [] as Order[],
showPopup: false,
selectedOrder: {} as Order
};
},
components: {
TrainerOrderPopup
},
methods: {
closePopup() {
this.showPopup = false;
},
openPopup(order: Order) {
this.selectedOrder = order;
this.showPopup = true;
},
async fetchOrders() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer/order`, {
credentials: import.meta.env.DEV ? "include" : undefined
});
if (res.status === 401 || res.status === 403) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
if (res.status === 200) {
this.orders = await res.json();
}
},
formatDate(date: string): 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;
},
formatTime(date: string): string {
let output: string = dayjs(date).utcOffset(0).locale("da").format("HH:mm");
return output;
},
formatStatus(status: string): string {
switch (status) {
case "Successful":
return "Bestilt"
case "Processing":
return "Afventer betaling"
case "CancelledByTrainer":
return "Aflyst af dig"
case "CancelledByUser":
return "Aflyst af brugeren"
default:
return "";
}
}
},
created() {
this.fetchOrders();
},
}
</script>
<style scoped>
.Orders {
display: flex;
/* flex-direction: column; */
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
width: 100%;
}
.order {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 30px;
display: flex;
flex-direction: column;
cursor: pointer;
}
.order:hover {
border: 1px solid black;
}
.trainer {
font-size: 1.8em;
}
.center {
opacity: 0.8;
font-size: 1.25em;
}
.order.cancelled .status {
color: lightcoral;
}
</style>

@ -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 |

@ -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;

@ -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;
Loading…
Cancel
Save