Added payment gateway, create order and updated timeslot overview
continuous-integration/drone/push Build is passing Details

main
Filip Borum Poulsen 3 years ago
parent 0f8d73b4cc
commit 810f80151d

@ -0,0 +1,143 @@
<template>
<div class="popup" @click="$emit('closePopup')">
<div class="Order" @click.stop>
<div class="trainer">
{{ trainer?.first_name }} {{ trainer?.last_name }}
</div>
<div class="center">
{{ trainer?.center_name }}
</div>
<div class="date">
{{ formatDate(timeslot?.startDate) }}
</div>
<div class="time">
{{ formatTime(timeslot?.startDate) }} -
{{ formatTime(timeslot?.endDate) }}
</div>
<div class="price">
{{ formatPrice(timeslot?.price) }}
</div>
<div class="orderButton" @click="order">
Bestil
</div>
</div>
</div>
</template>
<script lang="ts">
import type { Timeslot } from '@/interfaces/timeslot';
import type { Trainer } from '@/interfaces/trainer';
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: "OrderPopup",
emits: ["closePopup"],
props: {
trainer: Object as PropType<Trainer>,
timeslot: Object as PropType<Timeslot>
},
methods: {
async order() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/order`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "POST",
body: JSON.stringify({ trainer: this.trainer?.id, startDate: this.timeslot?.startDate, endDate: this.timeslot?.endDate }),
headers: {
"Content-Type": "Application/json"
}
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 200) {
const data = await res.json();
window.location.href = data.url;
}
},
formatDate(date: Date | undefined): string {
if (date === undefined) return "";
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: Date | undefined): string {
if (date === undefined) return "";
let output: string = dayjs(date).utcOffset(0).locale("da").format("HH:mm");
return output;
},
formatPrice(price: number | undefined): string {
if (price === undefined) return "";
const DanishKrone = new Intl.NumberFormat('dk', {
style: 'currency',
currency: 'DKK',
});
return DanishKrone.format(price / 100);
}
}
}
</script>
<style scoped>
.popup {
position: absolute;
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%;
}
.trainer {
font-size: 1.8em;
}
.center {
opacity: 0.8;
font-size: 1.25em;
}
.price {
margin-top: 2em;
font-size: 1.5em;
}
.orderButton {
align-self: flex-end;
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
}
.orderButton:hover {
text-decoration: underline;
border-color: black;
}
</style>

@ -23,7 +23,6 @@ export default {
},
async mounted() {
const filters = this.centers?.map(c => `center=${c}`).join("&");
console.log(filters)
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer?${filters}`);
if (res.status === 200) {
this.trainers = await res.json();

@ -1,6 +1,7 @@
export interface Timeslot {
startDate: Date
endDate: Date
price: number
}
export interface WeeklyTimeslot {

@ -14,8 +14,7 @@
<div class="date" v-for="date in trainer.dates">
{{ date.date }}
<div class="timeslots">
<div class="timeslot" v-for="timeslot in date.timeslots"
@click="createOrder(trainer, timeslot)">
<div class="timeslot" v-for="timeslot in date.timeslots" @click="openPopup(trainer, timeslot)">
{{ formatTime(timeslot.startDate) }} - {{ formatTime(timeslot.endDate) }}
</div>
</div>
@ -24,6 +23,7 @@
</div>
</div>
<!-- {{ trainersWithTimeslots }} -->
<OrderPopup @closePopup="closePopup" :trainer="selectedTrainer" :timeslot="selectedTimeslot" v-if="showPopup" ></OrderPopup>
</div>
</template>
@ -92,13 +92,14 @@
<script lang="ts">
import CenterSelector from '@/components/CenterSelector.vue';
import TrainerSelector from '@/components/TrainerSelector.vue';
import OrderPopup from '@/components/OrderPopup.vue';
import type { Timeslot } from '@/interfaces/timeslot';
import type { DateGroupedTimeslots, DateGroupedTimeslotsList, TrainerWithDateGroupedTimeslots, TrainerWithTimeslots } from '@/interfaces/trainerWithTimeslots';
import type { Trainer } from '@/interfaces/trainer';
import dayjs from 'dayjs';
import LocalizedFormat from "dayjs/plugin/localizedFormat"
import utc from "dayjs/plugin/utc"
import { } from "dayjs/locale/da";
import type { Trainer } from '@/interfaces/trainer';
dayjs.extend(LocalizedFormat);
dayjs.extend(utc);
@ -107,13 +108,14 @@ export default {
name: "Home",
components: {
CenterSelector,
TrainerSelector
TrainerSelector,
OrderPopup
},
data() {
return {
range: {
start: new Date(),
end: new Date()
end: dayjs().add(7, "days").toDate()
},
datePickerRules: {
hours: 0,
@ -123,7 +125,10 @@ export default {
},
centers: [] as number[],
trainers: [] as number[],
trainersWithTimeslots: [] as TrainerWithTimeslots[]
trainersWithTimeslots: [] as TrainerWithTimeslots[],
showPopup: false,
selectedTrainer: undefined as Trainer | undefined,
selectedTimeslot: undefined as Timeslot | undefined
}
},
computed: {
@ -166,6 +171,14 @@ export default {
}
},
methods: {
closePopup() {
this.showPopup = false;
},
openPopup(trainer: Trainer, timeslot: Timeslot) {
this.selectedTrainer = trainer;
this.selectedTimeslot = timeslot;
this.showPopup = true;
},
createOrder(trainer: Trainer, timeslot: Timeslot) {
this.$router.push({
path: "/createOrder", query: {

@ -14,11 +14,14 @@
| [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 | |
| [ ] | 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 |
| [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 |
| [ ] | POST | /order | User can request an order | 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 |

@ -1,17 +1,17 @@
<mxfile host="Electron" modified="2023-04-16T16:47:21.807Z" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/21.1.2 Chrome/106.0.5249.199 Electron/21.4.3 Safari/537.36" version="21.1.2" etag="Q3gk7VdNL8tA41-1b-5R" type="device">
<mxfile host="Electron" modified="2023-04-21T09:38:25.493Z" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/21.1.2 Chrome/106.0.5249.199 Electron/21.4.3 Safari/537.36" version="21.1.2" etag="UVKyFefs5c6TEwaoSrjs" type="device">
<diagram id="R2lEEEUBdFMjLlhIrx00" name="Page-1">
<mxGraphModel dx="1434" dy="2013" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0" extFonts="Permanent Marker^https://fonts.googleapis.com/css?family=Permanent+Marker">
<mxGraphModel dx="1434" dy="844" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0" extFonts="Permanent Marker^https://fonts.googleapis.com/css?family=Permanent+Marker">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="C-vyLk0tnHw3VtMMgP7b-1" value="" style="edgeStyle=entityRelationEdgeStyle;endArrow=ERzeroToMany;startArrow=ERone;endFill=1;startFill=0;" parent="1" source="44" target="C-vyLk0tnHw3VtMMgP7b-6" edge="1">
<mxCell id="C-vyLk0tnHw3VtMMgP7b-1" value="" style="edgeStyle=entityRelationEdgeStyle;endArrow=ERzeroToOne;startArrow=ERone;endFill=0;startFill=0;" parent="1" source="44" target="C-vyLk0tnHw3VtMMgP7b-6" edge="1">
<mxGeometry width="100" height="100" relative="1" as="geometry">
<mxPoint x="340" y="830" as="sourcePoint" />
<mxPoint x="440" y="730" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-2" value="orders" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" parent="1" vertex="1">
<mxGeometry x="560" y="200" width="250" height="220" as="geometry" />
<mxGeometry x="560" y="200" width="250" height="280" as="geometry" />
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-3" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="C-vyLk0tnHw3VtMMgP7b-2" vertex="1">
<mxGeometry y="30" width="250" height="30" as="geometry" />
@ -21,7 +21,7 @@
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-5" value="booking_id serial" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=1;" parent="C-vyLk0tnHw3VtMMgP7b-3" vertex="1">
<mxCell id="C-vyLk0tnHw3VtMMgP7b-5" value="id serial" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=1;" parent="C-vyLk0tnHw3VtMMgP7b-3" vertex="1">
<mxGeometry x="30" width="220" height="30" as="geometry">
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
@ -91,8 +91,34 @@
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-199" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="C-vyLk0tnHw3VtMMgP7b-2">
<mxGeometry y="210" width="250" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-200" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-199">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-201" value="checkout_session text" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-199">
<mxGeometry x="30" width="220" height="30" as="geometry">
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-202" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="C-vyLk0tnHw3VtMMgP7b-2">
<mxGeometry y="240" width="250" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-203" value="FK" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-202">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-204" value="payment_intent int" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-202">
<mxGeometry x="30" width="220" height="30" as="geometry">
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-23" value="users" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" parent="1" vertex="1">
<mxGeometry x="130" y="200" width="250" height="220" as="geometry" />
<mxGeometry x="160" y="200" width="250" height="250" as="geometry" />
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-24" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="C-vyLk0tnHw3VtMMgP7b-23" vertex="1">
<mxGeometry y="30" width="250" height="30" as="geometry" />
@ -172,6 +198,19 @@
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-205" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="C-vyLk0tnHw3VtMMgP7b-23">
<mxGeometry y="210" width="250" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-206" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-205">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-207" value="is_admin boolean" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-205">
<mxGeometry x="30" width="220" height="30" as="geometry">
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="VXEZj9PDGR0_IzT7xE4M-14" value="" style="edgeStyle=entityRelationEdgeStyle;endArrow=ERzeroToMany;startArrow=ERone;endFill=1;startFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="C-vyLk0tnHw3VtMMgP7b-24" target="C-vyLk0tnHw3VtMMgP7b-9" edge="1">
<mxGeometry width="100" height="100" relative="1" as="geometry">
<mxPoint x="440" y="390" as="sourcePoint" />
@ -179,7 +218,7 @@
</mxGeometry>
</mxCell>
<mxCell id="2" value="trainers" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" parent="1" vertex="1">
<mxGeometry x="560" y="670" width="250" height="160" as="geometry" />
<mxGeometry x="560" y="720" width="250" height="160" as="geometry" />
</mxCell>
<mxCell id="3" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
<mxGeometry y="30" width="250" height="30" as="geometry" />
@ -240,7 +279,7 @@
</mxGeometry>
</mxCell>
<mxCell id="19" value="centers" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" parent="1" vertex="1">
<mxGeometry x="130" y="670" width="250" height="190" as="geometry" />
<mxGeometry x="160" y="780" width="250" height="190" as="geometry" />
</mxCell>
<mxCell id="20" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="19" vertex="1">
<mxGeometry y="30" width="250" height="30" as="geometry" />
@ -314,7 +353,7 @@
</mxGeometry>
</mxCell>
<mxCell id="43" value="reserved_timeslots" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" parent="1" vertex="1">
<mxGeometry x="560" y="460" width="250" height="160" as="geometry" />
<mxGeometry x="560" y="510" width="250" height="160" as="geometry" />
</mxCell>
<mxCell id="44" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="43" vertex="1">
<mxGeometry y="30" width="250" height="30" as="geometry" />
@ -369,7 +408,7 @@
</mxGeometry>
</mxCell>
<mxCell id="67" value="weekly_timeslots" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" parent="1" vertex="1">
<mxGeometry x="920" y="640" width="250" height="190" as="geometry" />
<mxGeometry x="920" y="690" width="250" height="190" as="geometry" />
</mxCell>
<mxCell id="68" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="67" vertex="1">
<mxGeometry y="30" width="250" height="30" as="geometry" />
@ -438,50 +477,94 @@
</mxCell>
<mxCell id="83" value="" style="edgeStyle=entityRelationEdgeStyle;endArrow=ERzeroToMany;startArrow=ERone;endFill=1;startFill=0;" parent="1" source="3" target="71" edge="1">
<mxGeometry width="100" height="100" relative="1" as="geometry">
<mxPoint x="780" y="455" as="sourcePoint" />
<mxPoint x="780" y="755" as="targetPoint" />
<mxPoint x="780" y="505" as="sourcePoint" />
<mxPoint x="780" y="805" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="65" value="" style="edgeStyle=entityRelationEdgeStyle;endArrow=ERzeroToMany;startArrow=ERone;endFill=1;startFill=0;labelPosition=center;verticalLabelPosition=middle;align=center;verticalAlign=middle;" parent="1" source="3" target="47" edge="1">
<mxGeometry width="100" height="100" relative="1" as="geometry">
<mxPoint x="290" y="1044.4099999999999" as="sourcePoint" />
<mxPoint x="430" y="1044.4099999999999" as="targetPoint" />
<mxPoint x="290" y="1094.4099999999999" as="sourcePoint" />
<mxPoint x="430" y="1094.4099999999999" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="94" value="OrderStatus" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="130" y="-140" width="140" height="120" as="geometry" />
<mxGeometry x="920" y="200" width="140" height="180" as="geometry" />
</mxCell>
<mxCell id="95" value="Processing" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="94" vertex="1">
<mxCell id="axY-a4Oc4OsDCfNMIFES-157" value="Created" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" vertex="1" parent="94">
<mxGeometry y="30" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="96" value="Payed" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="94" vertex="1">
<mxCell id="96" value="Confirmed" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="94" vertex="1">
<mxGeometry y="60" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="97" value="Cancelled" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="94" vertex="1">
<mxCell id="axY-a4Oc4OsDCfNMIFES-218" value="Failed" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" vertex="1" parent="94">
<mxGeometry y="90" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-133" value="admins" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" vertex="1" parent="1">
<mxGeometry x="130" y="460" width="250" height="70" as="geometry" />
<mxCell id="97" value="CancelledByTrainer" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="94" vertex="1">
<mxGeometry y="120" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-219" value="CancelledByUser" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" vertex="1" parent="94">
<mxGeometry y="150" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-134" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-133">
<mxCell id="axY-a4Oc4OsDCfNMIFES-179" value="payment_intents" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;" vertex="1" parent="1">
<mxGeometry x="920" y="410" width="250" height="130" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-180" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-179">
<mxGeometry y="30" width="250" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-135" value="PK" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-134">
<mxCell id="axY-a4Oc4OsDCfNMIFES-181" value="PK" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-180">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-182" value="id serial" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-180">
<mxGeometry x="30" width="220" height="30" as="geometry">
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-183" value="" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-179">
<mxGeometry y="60" width="250" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-184" value="" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-183">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-185" value="external_id text" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-183">
<mxGeometry x="30" width="220" height="30" as="geometry">
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-208" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-179">
<mxGeometry y="90" width="250" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-209" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-208">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-136" value="user_id serial" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-134">
<mxCell id="axY-a4Oc4OsDCfNMIFES-210" value="status PaymentIntentStatus" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-208">
<mxGeometry x="30" width="220" height="30" as="geometry">
<mxRectangle width="220" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-155" value="" style="edgeStyle=entityRelationEdgeStyle;endArrow=ERzeroToMany;startArrow=ERone;endFill=1;startFill=0;" edge="1" parent="1" source="C-vyLk0tnHw3VtMMgP7b-24" target="axY-a4Oc4OsDCfNMIFES-134">
<mxCell id="axY-a4Oc4OsDCfNMIFES-198" value="" style="edgeStyle=entityRelationEdgeStyle;endArrow=ERzeroToOne;startArrow=ERone;endFill=0;startFill=0;" edge="1" parent="1" source="axY-a4Oc4OsDCfNMIFES-180" target="axY-a4Oc4OsDCfNMIFES-202">
<mxGeometry width="100" height="100" relative="1" as="geometry">
<mxPoint x="390" y="255" as="sourcePoint" />
<mxPoint x="570" y="835" as="targetPoint" />
<mxPoint x="820" y="515" as="sourcePoint" />
<mxPoint x="820" y="285" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-211" value="PaymentIntentStatus" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="1090" y="200" width="140" height="120" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-212" value="Created" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-211">
<mxGeometry y="30" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-215" value="Successful" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-211">
<mxGeometry y="60" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="axY-a4Oc4OsDCfNMIFES-216" value="Failed" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" vertex="1" parent="axY-a4Oc4OsDCfNMIFES-211">
<mxGeometry y="90" width="140" height="30" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>

@ -5,4 +5,6 @@ PGPASSWORD=secretpassword
PGDATABASE=mydb
PGPORT=5432
PUBLIC_KEY_LOCATION=/data/cert/public.pem
PRIVATE_KEY_LOCATION=/data/cert/private.pem
PRIVATE_KEY_LOCATION=/data/cert/private.pem
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...

@ -16,7 +16,8 @@
"express": "^4.18.2",
"joi": "^17.9.1",
"jsonwebtoken": "^9.0.0",
"pg": "^8.10.0"
"pg": "^8.10.0",
"stripe": "^12.1.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
@ -2047,6 +2048,18 @@
"node": ">=8"
}
},
"node_modules/stripe": {
"version": "12.1.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-12.1.1.tgz",
"integrity": "sha512-vn74vXtZeJx18oGzA0AhL818euhLF/juCgkKrJfAS1Y0bp5/EzQKPuc/75qQUvY43nNGIkgOVb3kUBuyoeqEkA==",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",

@ -21,7 +21,8 @@
"express": "^4.18.2",
"joi": "^17.9.1",
"jsonwebtoken": "^9.0.0",
"pg": "^8.10.0"
"pg": "^8.10.0",
"stripe": "^12.1.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",

@ -15,8 +15,24 @@ if (process.env.PRIVATE_KEY_LOCATION === undefined) {
throw ("The environment variable 'PRIVATE_KEY_LOCATION' has not been set");
}
export const public_key: string = fs.readFileSync(process.env.PUBLIC_KEY_LOCATION, {encoding: "utf-8"});
if (process.env.STRIPE_SECRET_KEY === undefined) {
throw ("The environment variable 'STRIPE_SECRET_KEY' has not been set");
}
if (process.env.STRIPE_PUBLIC_KEY === undefined) {
throw ("The environment variable 'STRIPE_PUBLIC_KEY' has not been set");
}
if (process.env.STRIPE_WEBHOOK_SECRET === undefined) {
throw ("The environment variable 'STRIPE_WEBHOOK_SECRET' has not been set");
}
export const public_key: string = fs.readFileSync(process.env.PUBLIC_KEY_LOCATION, { encoding: "utf-8" });
export const private_key: string = fs.readFileSync(process.env.PRIVATE_KEY_LOCATION, { encoding: "utf-8" });
export const private_key: string = fs.readFileSync(process.env.PRIVATE_KEY_LOCATION, {encoding: "utf-8"});
export const port: string = process.env.PORT;
export const port: string = process.env.PORT;
export const stripePrivateKey: string = process.env.STRIPE_SECRET_KEY;
export const stripePublicKey: string = process.env.STRIPE_PUBLIC_KEY;
export const stripeWebhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET;

@ -0,0 +1,34 @@
import { NextFunction, Request, Response } from "express"
function isBodyParserError(error: Error | any) {
const bodyParserCommonErrorsTypes = [
'encoding.unsupported',
'entity.parse.failed',
'entity.verify.failed',
'request.aborted',
'request.size.invalid',
'stream.encoding.set',
'parameters.too.many',
'charset.unsupported',
'entity.too.large'
]
return bodyParserCommonErrorsTypes.includes(error.type)
}
export function bodyParserErrorHandler(
{
// eslint-disable-next-line
onError = (err: Error | any, req: Request, res: Response) => {
},
errorMessage = (err: Error | any) => {
return `Failed to parse request --> ${err.message}`
}
} = {}) {
return (err: Error | any, req: Request, res: Response, next: NextFunction) => {
if (err && isBodyParserError(err)) {
onError(err, req, res)
res.status(err.status)
res.send({ message: errorMessage(err) })
} else next(err)
}
}

@ -6,19 +6,29 @@ import { client } from "../db";
client.query(`
DROP TABLE IF EXISTS public.orders;
DROP TABLE IF EXISTS public.payment_intents;
DROP TABLE IF EXISTS public.reserved_timeslots;
DROP TABLE IF EXISTS public.weekly_timeslots;
DROP TABLE IF EXISTS public.trainers;
DROP TABLE IF EXISTS public.centers;
DROP TABLE IF EXISTS public.admins;
DROP TABLE IF EXISTS public.users;
DROP TYPE IF EXISTS public."OrderStatus";
DROP TYPE IF EXISTS public."PaymentIntentStatus";
CREATE TYPE public."OrderStatus" AS ENUM
(
'Processing',
'Payed',
'Cancelled'
'Created',
'Confirmed',
'Failed',
'CancelledByTrainer',
'CancelledByUser'
);
CREATE TYPE public."PaymentIntentStatus" AS ENUM
(
'Created',
'Successful',
'Failed'
);
CREATE TABLE public.users
@ -28,12 +38,8 @@ CREATE TABLE public.users
last_name text NOT NULL,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
email_verified boolean NOT NULL DEFAULT false
);
CREATE TABLE public.admins
(
user_id int REFERENCES users(id) NOT NULL PRIMARY KEY
email_verified boolean NOT NULL DEFAULT false,
is_admin boolean NOT NULL DEFAULT false
);
CREATE TABLE public.centers
@ -70,14 +76,23 @@ CREATE TABLE public.reserved_timeslots
end_time timestamp NOT NULL
);
CREATE TABLE public.payment_intents
(
id serial NOT NULL PRIMARY KEY,
external_id text UNIQUE NOT NULL,
status "PaymentIntentStatus" NOT NULL
);
CREATE TABLE public.orders
(
id serial NOT NULL PRIMARY KEY,
timeslot_id int REFERENCES reserved_timeslots(id) NOT NULL,
user_id int REFERENCES users(id) NOT NULL,
order_status "OrderStatus" NOT NULL DEFAULT 'Processing',
order_status "OrderStatus" NOT NULL DEFAULT 'Created',
price int NOT NULL,
create_at timestamp NOT NULL DEFAULT NOW()
created_at timestamp NOT NULL DEFAULT NOW(),
checkout_session text NOT NULL,
payment_intent int REFERENCES payment_intents(id)
);
`)
.then(()=>{

@ -7,26 +7,17 @@ async function main() {
const users = await client.query(`
INSERT INTO users (first_name, last_name, email, password_hash, email_verified) VALUES
('Filip', 'B P', 'fbp@example.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true),
('User1', 'Lastname', 'u1@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true),
('User2', 'Lastname', 'u2@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true),
('User3', 'Lastname', 'u3@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true),
('Trainer1', 'Lastname', 't1@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true),
('Trainer2', 'Lastname', 't2@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true),
('Admin1', 'Lastname', 'a1@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true)
INSERT INTO users (first_name, last_name, email, password_hash, email_verified, is_admin) VALUES
('Filip', 'B P', 'fbp@gmail.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true, true),
('User1', 'Lastname', 'u1@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true, false),
('User2', 'Lastname', 'u2@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true, false),
('User3', 'Lastname', 'u3@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true, false),
('Trainer1', 'Lastname', 't1@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true, false),
('Trainer2', 'Lastname', 't2@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true, false),
('Admin1', 'Lastname', 'a1@test.com', '$2b$10$zxqwqZXo3DrLVTFx.hkQQ.uKeqhHMnok.G/4.Ivq1g647RaqYtgKC', true, true)
RETURNING id, email;
`);
const admins = await client.query(`
INSERT INTO admins (user_id) VALUES
($1),
($2);
`, [
users.rows.find(user => user.email === "a1@test.com").id,
users.rows.find(user => user.email === "fbp@example.com").id
]);
const centers = await client.query(`
INSERT INTO centers (name, city, zip_code, address) VALUES
('Herning Fitness', 'Herning', '7400', 'Vej 123'),
@ -70,9 +61,9 @@ RETURNING id;
const reserved_timeslots = await client.query(`
INSERT INTO reserved_timeslots (trainer_id, start_time, end_time) VALUES
($1, '2023-04-17 11:00:00+2', '2023-04-17 12:00:00+2'),
($1, '2023-04-18 12:00:00+2', '2023-04-17 13:00:00+2'),
($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'),
($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;
@ -82,12 +73,12 @@ RETURNING id, trainer_id;
]);
const orders = await client.query(`
INSERT INTO orders (timeslot_id, user_id, order_status, price) VALUES
($3, $1, 'Cancelled', 10000),
($4, $1, 'Payed', 20000),
($5, $2, 'Processing', 20000),
($6, $1, 'Payed', 20000),
($7, $1, 'Payed', 20000)
INSERT INTO orders (timeslot_id, user_id, order_status, price, checkout_session) VALUES
($3, $1, 'CancelledByUser', 10000, ''),
($4, $1, 'CancelledByTrainer', 20000, ''),
($5, $2, 'Created', 20000, ''),
($6, $1, 'Confirmed', 20000, ''),
($7, $1, 'Failed', 20000, '')
RETURNING id;
`, [
users.rows.find(user => user.email === "u1@test.com").id,

@ -8,7 +8,11 @@ import center from "./center";
import trainer from "./trainer/index";
import timeslot from "./timeslot";
import order from "./order";
import stripeWebhook from "./stripeWebhook";
import { bodyParserErrorHandler } from "../middlewares/bodyParserErrorHandler";
router.use(stripeWebhook);
router.use(express.json(), bodyParserErrorHandler());
router.use(login);
router.use(register);
router.use(center);

@ -34,9 +34,8 @@ router.post("/login", async (req: Request, res: Response) => {
try {
const databaseResult = await client.query(`
SELECT users.id, password_hash, admins.user_id IS NOT NULL as is_admin, trainers.user_id IS NOT NULL as is_trainer
SELECT users.id, password_hash, is_admin, trainers.user_id IS NOT NULL as is_trainer
FROM users
LEFT JOIN admins ON admins.user_id = users.id
LEFT JOIN trainers ON trainers.user_id = users.id
WHERE email = $1;
`, [userData.email]);

@ -1,19 +1,25 @@
import express, { Router, Request, Response, NextFunction } from "express";
import Joi from "joi"
import dayjs from "dayjs"
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, client as pool } from "../db";
import { client as pool } from "../db";
import { DatabaseError } from "pg";
import Trainer from "../interfaces/trainer";
import { ReservedTimeslots, Timeslot, WeeklyTimeslot } from "../interfaces/timeslot";
import { idSchema, timeSchema } from "../schemas";
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);
const router: Router = express.Router();
@ -23,12 +29,6 @@ const orderSchema = Joi.object({
endDate: Joi.date().required()
});
interface DatabaseResult {
trainer: Trainer
timeslots: WeeklyTimeslot[]
reserved_timeslots: ReservedTimeslots[]
}
interface OrderBody {
trainer: number
startDate: Date
@ -40,6 +40,44 @@ interface TimeslotValidQueryResult {
time_already_reserved: boolean
}
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);
});
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();
@ -61,6 +99,26 @@ router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => {
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(
@ -73,9 +131,9 @@ router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => {
) as weekly_timeslot_available,
EXISTS(
SELECT 1 FROM public.reserved_timeslots
WHERE (
(start_time between $5 AND $6
OR end_time between $5 AND $6))
WHERE
((start_time >= $5 AND start_time < $6)
OR (end_time > $5 AND end_time <= $6))
AND trainer_id = $1
) as time_already_reserved;
`, [
@ -83,8 +141,8 @@ router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => {
weekday,
startTime,
endTime,
orderBody.startDate,
orderBody.endDate
startDate.toISOString(),
endDate.toISOString()
]);
const timeslotValidQueryResult: TimeslotValidQueryResult = timeslotValidQuery.rows[0];
@ -109,6 +167,29 @@ router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => {
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)
@ -116,16 +197,17 @@ router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => {
RETURNING *
)
INSERT INTO orders (timeslot_id, user_id, order_status, price)
select id, $4, 'Processing', $5
INSERT INTO orders (timeslot_id, user_id, price, checkout_session)
select id, $4, $5, $6
FROM inserted_reserved_timeslot
RETURNING id;
`, [
orderBody.trainer,
orderBody.startDate,
orderBody.endDate,
startDate,
endDate,
req.user?.userId,
price
price,
checkoutSession.id
]);
const insertedData = insertQuery.rows[0];
@ -137,7 +219,8 @@ router.post("/order", UserAuth, async (req: AuthedRequest, res: Response) => {
trainerId: orderBody.trainer,
startDate: orderBody.startDate,
endDate: orderBody.endDate,
price
price,
url: checkoutSession.url
});
} catch (error: DatabaseError | Error | any) {
await client.query("ROLLBACK");

@ -51,7 +51,7 @@ RETURNING id;
res.cookie("auth-token", jwt, { httpOnly: true, maxAge: 60 * 60 * 4 });
return res.sendStatus(204);
return res.status(200).send({ ...userData, password: undefined });
} catch (error: DatabaseError | Error | any) {
if (error.constraint == "users_email_key") {
return res.status(400).send([{

@ -0,0 +1,63 @@
import express, { Router, Request, Response, NextFunction } from "express";
import Stripe from 'stripe';
import { stripe } from "../stripe";
import { stripeWebhookSecret } from "../environment";
import checkoutSessionCompleted from "../webhooks/checkoutSessionCompleted";
import paymentIntentSucceeded from "../webhooks/paymentIntentSucceeded";
import checkoutSessionExpired from "../webhooks/checkoutSessionExpired";
import paymentIntentCreated from "../webhooks/paymentIntentCreated";
import paymentIntentFailed from "../webhooks/paymentIntentFailed";
const router: Router = express.Router();
router.post("/stripeWebhook", express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
const payload = req.body;
const sig = req.headers['stripe-signature'];
let event: Stripe.Event;
try {
if (!sig)
throw new Error("No signature specified");
event = stripe.webhooks.constructEvent(payload, sig, stripeWebhookSecret);
} catch (err: Error | any) {
console.error(err);
return res.status(401).send(`Webhook Error: ${err.message}`);
}
try {
switch (event.type) {
case "checkout.session.completed": {
await checkoutSessionCompleted(event);
break;
}
case "checkout.session.expired": {
await checkoutSessionExpired(event);
break;
}
case "payment_intent.succeeded": {
await paymentIntentSucceeded(event);
break;
}
case "payment_intent.created": {
await paymentIntentCreated(event);
break;
}
case "payment_intent.payment_failed": {
await paymentIntentFailed(event);
break;
}
default:
break;
}
return res.sendStatus(200);
}
catch (err: Error | any) {
console.error(err);
return res.status(500).send(err.message);
}
});
export default router;

@ -35,14 +35,24 @@ interface TimeslotFilters {
endTime?: string
}
interface TrainerWithHourlyPrice extends Trainer {
hourly_price: number
}
interface DatabaseResult {
trainer: Trainer
trainer: TrainerWithHourlyPrice
timeslots: WeeklyTimeslot[]
reserved_timeslots: ReservedTimeslots[]
}
interface TrainerWithAvailableTimeslots extends Trainer {
timeslots: Timeslot[]
timeslots: TimeslotWithPrice[]
}
interface TimeslotWithPrice {
price: number
startDate: string
endDate: string
}
router.get("/timeslot", async (req: Request, res: Response) => {
@ -54,6 +64,9 @@ router.get("/timeslot", async (req: Request, res: Response) => {
}
const timeslotFilters: TimeslotFilters = validation.value;
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");
@ -77,7 +90,8 @@ router.get("/timeslot", async (req: Request, res: Response) => {
'first_name',users.first_name,
'last_name',users.last_name,
'center_id',trainers.center_id,
'center_name',centers.name
'center_name',centers.name,
'hourly_price',trainers.hourly_price
) as trainer,
json_agg(json_build_object(
'id',weekly_timeslots.id,
@ -87,14 +101,21 @@ router.get("/timeslot", async (req: Request, res: Response) => {
) as timeslots,
(
SELECT json_agg(json_build_object(
'id', id,
'id', reserved_timeslots.id,
'start_time', start_time,
'end_time', end_time
)) FROM public.reserved_timeslots
JOIN orders ON reserved_timeslots.id = orders.timeslot_id
WHERE
(start_time between $1 AND $2
OR end_time between $1 AND $2)
(
(start_time >= $1 AND start_time < $2)
OR (end_time > $1 AND end_time <= $2)
)
AND weekly_timeslots.trainer_id = reserved_timeslots.trainer_id
AND (
orders.order_status = 'Confirmed'
OR orders.order_status = 'Created'
)
) as reserved_timeslots
FROM
weekly_timeslots
@ -110,11 +131,14 @@ router.get("/timeslot", async (req: Request, res: Response) => {
users.first_name,
users.last_name,
trainers.center_id,
trainers.hourly_price,
centers.name,
trainer_id;
trainer_id
ORDER BY
weekly_timeslots.trainer_id ASC;
`, [
new Date(timeslotFilters.startDate).toISOString(),
new Date(timeslotFilters.endDate).toISOString(),
startDate.toISOString(),
endDate.toISOString(),
timeslotFilters.trainer !== undefined ? timeslotFilters.trainer : [],
timeslotFilters.trainer === undefined,
weekdays,
@ -128,7 +152,11 @@ router.get("/timeslot", async (req: Request, res: Response) => {
for (const trainer of databaseResult) {
const trainerWithAvailableTimeslots: TrainerWithAvailableTimeslots = {
...trainer.trainer,
id: trainer.trainer.id,
center_id: trainer.trainer.id,
center_name: trainer.trainer.center_name,
first_name: trainer.trainer.first_name,
last_name: trainer.trainer.last_name,
timeslots: []
}
@ -153,25 +181,25 @@ router.get("/timeslot", async (req: Request, res: Response) => {
parseInt(timeslotFilters.endTime.split(":")[1]) < parseInt(timeslot.end_time.split(":")[1]))
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]));
const startTime = dayjs(`${day.toISOString().split("T")[0]}T${timeslot.start_time}`);
const endTime = dayjs(`${day.toISOString().split("T")[0]}T${timeslot.end_time}`);
for (const reservedTimeslot of reservedTimeslots) {
const reservedTimeStart = dayjs(reservedTimeslot.start_time);
const reservedTimeEnd = dayjs(reservedTimeslot.end_time);
if ((!reservedTimeStart.isBefore(startTime) && !reservedTimeStart.isAfter(endTime)) ||
(!reservedTimeEnd.isBefore(startTime) && !reservedTimeStart.isAfter(endTime))) {
if ((!reservedTimeStart.isBefore(startTime) && reservedTimeStart.isBefore(endTime)) ||
(reservedTimeEnd.isAfter(startTime) && !reservedTimeEnd.isAfter(endTime))) {
continue timeslots;
}
}
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: startTime.toDate(),
endDate: endTime.toDate()
startDate: `${day.toISOString().split("T")[0]}T${timeslot.start_time}Z`,
endDate: `${day.toISOString().split("T")[0]}T${timeslot.end_time}Z`,
price
});
}
}

@ -0,0 +1,7 @@
import Stripe from 'stripe';
import { stripePrivateKey } from './environment';
export const stripe = new Stripe(stripePrivateKey, {
apiVersion: '2022-11-15',
typescript: true
});

@ -0,0 +1,50 @@
import { client } from "../db";
import Stripe from 'stripe';
const PaymentIntentStatusMap = {
"paid": "Successful",
"unpaid": "Created",
"no_payment_required": "Created"
}
export default async function checkoutSessionCompleted(event: Stripe.Event) {
const session: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session;
const insertPaymentIntentQuery = await client.query(`
INSERT INTO payment_intents (external_id, status)
VALUES ($1, $2)
ON CONFLICT ON CONSTRAINT payment_intents_external_id_key DO NOTHING;
`, [
session.payment_intent,
PaymentIntentStatusMap[session.payment_status]
]);
const getPaymentIntentQuery = await client.query(`
SELECT id FROM payment_intents
WHERE external_id = $1;
`, [
session.payment_intent
]);
const paymentIntentId: number = getPaymentIntentQuery.rows[0].id;
await client.query(`
UPDATE orders SET
payment_intent = $1
WHERE
checkout_session = $2;
`, [
paymentIntentId,
session.id
]);
await client.query(`
UPDATE orders SET
order_status = 'Confirmed'
WHERE
checkout_session = $1
AND order_status = 'Created';
`, [
session.id
]);
}

@ -0,0 +1,16 @@
import { client } from "../db";
import Stripe from 'stripe';
export default async function checkoutSessionExpired(event: Stripe.Event) {
const session: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session;
const updateOrderQuery = await client.query(`
UPDATE orders SET
order_status = 'Failed'
WHERE
checkout_session = $1;
`, [
session.id
]);
}

@ -0,0 +1,15 @@
import { client } from "../db";
import Stripe from 'stripe';
export default async function paymentIntentCreated(event: Stripe.Event) {
const intent: Stripe.PaymentIntent = event.data.object as Stripe.PaymentIntent;
const insertPaymentIntentQuery = await client.query(`
INSERT INTO payment_intents (external_id, status)
VALUES ($1, 'Created')
ON CONFLICT ON CONSTRAINT payment_intents_external_id_key DO NOTHING;
`, [
intent.id
]);
}

@ -0,0 +1,16 @@
import { client } from "../db";
import Stripe from 'stripe';
export default async function paymentIntentFailed(event: Stripe.Event) {
const intent: Stripe.PaymentIntent = event.data.object as Stripe.PaymentIntent;
await client.query(`
INSERT INTO payment_intents (external_id, status)
VALUES ($1, 'Failed')
ON CONFLICT ON CONSTRAINT payment_intents_external_id_key DO UPDATE SET
status = 'Failed';
`, [
intent.id
]);
}

@ -0,0 +1,16 @@
import { client } from "../db";
import Stripe from 'stripe';
export default async function paymentIntentSucceeded(event: Stripe.Event) {
const intent: Stripe.PaymentIntent = event.data.object as Stripe.PaymentIntent;
const insertPaymentIntentQuery = await client.query(`
INSERT INTO payment_intents (external_id, status)
VALUES ($1, 'Successful')
ON CONFLICT ON CONSTRAINT payment_intents_external_id_key DO UPDATE SET
status = 'Successful';
`, [
intent.id
]);
}
Loading…
Cancel
Save