Initial commit

master
Jan Umbach 2024-02-24 08:49:30 +01:00
commit d01175bc30
52 changed files with 8376 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
.env

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
.env

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:20
ENV TZ=Europe/Berlin
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npx tsc
EXPOSE 3000
CMD ["node", "dist/main.js"]

22
build-docker.sh Executable file
View File

@ -0,0 +1,22 @@
DOCKER_REGISTRY_URL="dockreg.ex.umbach.dev"
DOCKER_IMAGE_NAME="zeitadler-terminplaner-backend"
# only allow to run this script as root
if [ "$EUID" -ne 0 ]
then echo "Please run as root"
exit
fi
# rm images
docker image rm $DOCKER_IMAGE_NAME
# build images
docker build -t $DOCKER_IMAGE_NAME .
# tag images
docker image tag $DOCKER_IMAGE_NAME:latest $DOCKER_REGISTRY_URL/$DOCKER_IMAGE_NAME:latest
# push to self-hosted docker registry
docker push $DOCKER_REGISTRY_URL/$DOCKER_IMAGE_NAME
echo "Uploaded $DOCKER_IMAGE_NAME to $DOCKER_REGISTRY_URL"

2152
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"dependencies": {
"@types/amqplib": "^0.10.4",
"amqplib": "^0.10.3",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"email-validator": "^2.0.4",
"express": "^4.18.2",
"googleapis": "^130.0.0",
"mariadb": "^3.2.3",
"redis": "^4.6.12",
"typescript": "^5.3.3",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/node": "^20.10.7",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2"
},
"scripts": {
"start": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/main.ts"
}
}

75
public/404.html Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html>
<head>
<title>Join Button</title>
<style>
.joinButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.joinButton p {
background-color: #f32409;
border: none;
color: #fff;
padding: 15px 32px;
text-align: center;
font-size: 16px;
border-radius: 12px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
background-color: #f32409;
}
50% {
transform: scale(1.1);
background-color: #f54f3d;
}
100% {
transform: scale(1);
background-color: #f32409;
}
}
</style>
</head>
<body>
<div class="joinButton">
<p>404</p>
</div>
<script>
/*// Function to extract URL parameters
function getUrlParameters() {
var queryParams = window.location.search.substring(1);
var params = queryParams.split('&');
var paramsObject = {};
for (var i = 0; i < params.length; i++) {
var pair = params[i].split('=');
var key = decodeURIComponent(pair[0]);
var value = decodeURIComponent(pair[1]);
paramsObject[key] = value;
}
return paramsObject;
}
// Display URL parameters on the page
function displayUrlParameters() {
var params = getUrlParameters();
var paramsHtml = '<pre>' + JSON.stringify(params, null, 2) + '</pre>';
document.body.innerHTML += paramsHtml;
}
// Call the function to display URL parameters
displayUrlParameters();*/
</script>
</body>
</html>

BIN
public/bild.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@ -0,0 +1,86 @@
let KKInnovation = {
openPopup: async function ({ url }) {
if (this.isPopupOpen) return;
this.isPopupOpen = true;
try {
await this.loadStyles('%PUBLIC_URL%/embedPopup/style.css');
let popup = document.createElement('div');
popup.id = 'KKInnovation-embed-popup';
popup.innerHTML = `
<div id="KKInnovation-embed-popup-content">
<div id="KKInnovation-embed-popup-loading"></div>
<iframe src="${url}" frameborder="0"></iframe>
</div>
<div id="KKInnovation-embed-popup-close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 10.586l-3.293-3.293-1.414 1.414 3.293 3.293-3.293 3.293 1.414 1.414 3.293-3.293 3.293 3.293 1.414-1.414-3.293-3.293 3.293-3.293-1.414-1.414z"/>
</svg>
</div>
`;
document.body.appendChild(popup);
setTimeout(() => {
popup.classList.add('KKInnovation-embed-popup-open');
// disable scrolling
document.body.style.overflow = 'hidden';
}, 0);
// Add click event listener to the background div
document.getElementById('KKInnovation-embed-popup').addEventListener('click', (event) => {
// Check if the clicked element is the background div
if (event.target.id === 'KKInnovation-embed-popup') {
// enable scrolling
document.body.style.overflow = 'auto';
popup.classList.remove('KKInnovation-embed-popup-open');
setTimeout(() => {
this.isPopupOpen = false;
popup.remove();
}, 400);
}
});
} catch (error) {
this.isPopupOpen = false;
return;
}
},
loadStyles: async function (url) {
return new Promise((resolve, reject) => {
if (this.stylesLoaded) {
resolve();
return;
}
// load stylesheet
let link = document.createElement('link');
link.type = 'text/css';
link.rel = 'stylesheet';
link.href = url;
document.getElementsByTagName('head')[0].appendChild(link);
// wait for stylesheet to load
link.onload = function () {
stylesLoaded = true;
resolve();
};
link.onerror = function () {
reject();
};
})
},
isPopupOpen: false,
stylesLoaded: false,
}

View File

@ -0,0 +1,86 @@
@keyframes KKInnovatopn-embed-popup-spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
#KKInnovation-embed-popup {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background-color: rgb(0, 0, 0, 0.5);
opacity: 0;
backdrop-filter: blur(5px);
transition: opacity 300ms cubic-bezier(0.55, 0.085, 0.68, 0.53);
}
.KKInnovation-embed-popup-open {
opacity: 1 !important;
pointer-events: all !important;
}
#KKInnovation-embed-popup-content {
position: absolute;
top: calc(50% + 15px);
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% - 20px);
height: calc(100% - 30px);
max-width: 1200px;
max-height: 80vh;
border-radius: 20px;
overflow: show;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.5);
background-color: #fff;
}
#KKInnovation-embed-popup-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
border-radius: 50%;
border: 5px solid #0003;
border-top-color: #006eff;
animation: KKInnovatopn-embed-popup-spin 1s infinite ease-in-out;
}
#KKInnovation-embed-popup-content iframe {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% + 2px);
height: calc(100% + 2px);
border: none;
border-radius: 20px;
}
#KKInnovation-embed-popup-close {
position: absolute;
top: 10px;
right: 10px;
width: 30px;
height: 30px;
pointer-events: none;
fill: #fff;
}

75
public/error.html Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html>
<head>
<title>Join Button</title>
<style>
.joinButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.joinButton p {
background-color: #f32409;
border: none;
color: #fff;
padding: 15px 32px;
text-align: center;
font-size: 16px;
border-radius: 12px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
background-color: #f32409;
}
50% {
transform: scale(1.1);
background-color: #f54f3d;
}
100% {
transform: scale(1);
background-color: #f32409;
}
}
</style>
</head>
<body>
<div class="joinButton">
<p>Error :(</p>
</div>
<script>
/*// Function to extract URL parameters
function getUrlParameters() {
var queryParams = window.location.search.substring(1);
var params = queryParams.split('&');
var paramsObject = {};
for (var i = 0; i < params.length; i++) {
var pair = params[i].split('=');
var key = decodeURIComponent(pair[0]);
var value = decodeURIComponent(pair[1]);
paramsObject[key] = value;
}
return paramsObject;
}
// Display URL parameters on the page
function displayUrlParameters() {
var params = getUrlParameters();
var paramsHtml = '<pre>' + JSON.stringify(params, null, 2) + '</pre>';
document.body.innerHTML += paramsHtml;
}
// Call the function to display URL parameters
displayUrlParameters();*/
</script>
</body>
</html>

75
public/finish.html Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html>
<head>
<title>Join Button</title>
<style>
.joinButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.joinButton p {
background-color: #72f309;
border: none;
color: #000;
padding: 15px 32px;
text-align: center;
font-size: 16px;
border-radius: 12px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
background-color: #72f309;
}
50% {
transform: scale(1.1);
background-color: #9fd50c;
}
100% {
transform: scale(1);
background-color: #72f309;
}
}
</style>
</head>
<body>
<div class="joinButton">
<p>Thank you for joining! :)</p>
</div>
<script>
/*// Function to extract URL parameters
function getUrlParameters() {
var queryParams = window.location.search.substring(1);
var params = queryParams.split('&');
var paramsObject = {};
for (var i = 0; i < params.length; i++) {
var pair = params[i].split('=');
var key = decodeURIComponent(pair[0]);
var value = decodeURIComponent(pair[1]);
paramsObject[key] = value;
}
return paramsObject;
}
// Display URL parameters on the page
function displayUrlParameters() {
var params = getUrlParameters();
var paramsHtml = '<pre>' + JSON.stringify(params, null, 2) + '</pre>';
document.body.innerHTML += paramsHtml;
}
// Call the function to display URL parameters
displayUrlParameters();*/
</script>
</body>
</html>

2
public/hello.all.min.js vendored Normal file

File diff suppressed because one or more lines are too long

67
public/index.html Normal file
View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<title>OAuth Umach</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
.joinButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.joinButton button {
background-color: #09b1f3;
border: none;
color: white;
padding: 15px 32px;
text-align: center;
font-size: 16px;
cursor: pointer;
border-radius: 12px;
transition: all 200ms;
}
.joinButton button:hover {
background-color: #0c7cd5;
transform: scale(1.1);
}
.genToken {
padding: 20px;
background-color: #f33;
border: none;
color: #fff;
border-radius: 10px;
transition: all 200ms;
margin: 20px;
}
.genToken:hover {
background-color: #d00;
transform: scale(1.1);
}
body {
background-image: url('bild.jpg');
background-repeat: no-repeat;
background-size: cover;
height: 100vh;
}
</style>
<script src="/hello.all.min.js"></script>
</head>
<body>
<div class="joinButton">
<button onclick="KKInnovation.openPopup({url: 'https://calendar.ex.umbach.dev/embed/?id=643573ed-988b-4dac-8084-6958efcc56d4'});">Termin Buchen</button>
</div>
<script src="https://calendar.ex.umbach.dev/embedPopup/script.js" type="text/javascript" async></script>
</body>
</html>

459
src/calendar/getFreeTime.ts Normal file
View File

@ -0,0 +1,459 @@
import { calendar_v3, google } from 'googleapis';
import { getAccessToken, getOAuthClient } from './oauthAccess';
import calendar_ids from '../database/calendar_ids';
import user_google_tokens from '../database/user_google_tokens';
import { getUser } from '../database/user';
import { cacheEventsTime, defaultUserSettings } from '../config';
import { OpenHoursItem, getClosedHours, getEndOfDay, getOpenHours, getStartOfDayOfUser } from './openHoursList';
import { MasterUser, User, UserID } from '../user/types';
import { Store, StoreID, getStore } from '../database/store';
import { getAllBusyEventsFromDatabase } from '../calendarSync/getAllEvents';
import redisClient from '../redis/init';
import MyRedisKeys from '../redis/keys';
import { storeLogger, userLogger } from '../logger/logger';
type CalendarID = string;
type timestamp = number;
type EventType = calendar_v3.Schema$Event;
type SimpleEventType = {
start: Date;
end: Date;
};
type SimpleUserEventType = {
userId: string;
events: SimpleEventType[];
};
async function getEvents(calendarID: string, earliestBookingTime: Date, endDate: Date): Promise<EventType[]> {
try {
const _myEvents = await getAllBusyEventsFromDatabase(calendarID, earliestBookingTime, endDate);
// convert _myEvents to google calendar events
let myEvents: EventType[] = [];
for (const event of _myEvents) {
myEvents.push({
id: event.event_id,
start: event.start_time ? { dateTime: event.start_time.toISOString() } : undefined,
end: event.end_time ? { dateTime: event.end_time.toISOString() } : undefined,
status: event.status_val,
updated: event.updated_time ? event.updated_time.toISOString() : undefined,
transparency: event.transparency,
});
}
return myEvents;
} catch (error) {
console.error(error);
throw error;
}
}
async function getBookedTimeFromOneUser(store: Store, user: User, day: Date, time?: Date): Promise<{ start: Date; end: Date }[]> {
return new Promise(async (resolve, reject) => {
try {
let openHours: OpenHoursItem[];
let endDate: Date;
let earliestBookingTime: Date;
try {
openHours = await getOpenHours(store.store_id); /* eg [
{ start: 2024-01-10T04:30:00.000Z, end: 2024-01-10T14:00:00.000Z },
{ start: 2024-01-11T04:30:00.000Z, end: 2024-01-11T14:00:00.000Z },
{ start: 2024-01-12T04:30:00.000Z, end: 2024-01-12T14:00:00.000Z },
]*/
// day is the day to check
endDate = await getEndOfDay(openHours, day);
earliestBookingTime = await getStartOfDayOfUser(user, openHours, day, time !== undefined);
} catch (error) {
reject(error);
return;
}
// check if time is set
if (time !== undefined) {
if (time.getTime() < earliestBookingTime.getTime()) {
reject('no free time found');
return;
}
}
if (endDate < earliestBookingTime) {
reject('no free time found');
return;
}
let masterCalendarIDs: string[] = [];
let myCalendarIDs: string[] = [];
if (user.settings.calendar_using_primary_calendar === true) {
try {
const primaryCalendarID = (await calendar_ids.getPrimaryCalendar(user.user_id)).calendar_id;
myCalendarIDs.push(primaryCalendarID);
userLogger.debug(user.user_id, 'using primary calendar for user', user.username);
} catch (error) {}
}
masterCalendarIDs.push(await calendar_ids.getEventCalendarID(user));
async function getAllEvents(calendarIDs: string[]) {
let events: EventType[] = [];
for (const calendarID of calendarIDs) {
const calRes = await getEvents(calendarID, earliestBookingTime, endDate);
events.push(...calRes);
}
return events;
}
let masterEvents = await getAllEvents(masterCalendarIDs);
let myEvents = myCalendarIDs.length >= 1 ? await getAllEvents(myCalendarIDs) : [];
// invert openHours and only keep closed hours that are between earliestBookingTime and endTime
const closedHours = getClosedHours(openHours, earliestBookingTime, endDate);
// merge myEvents and masterEvents into one array and merge overlapping events into one
let _events: EventType[] = [...masterEvents, ...myEvents];
// change events to only contain start and end time
let __events: (SimpleEventType | undefined)[] = _events.map((event) => {
const start = event.start?.dateTime || event.start?.date;
const end = event.end?.dateTime || event.end?.date;
if (!start || !end) return;
return { start: new Date(start), end: new Date(end) };
});
__events = [...__events, ...closedHours];
// remove undefined
let events: SimpleEventType[] = __events.filter((event) => event !== undefined) as { start: Date; end: Date }[];
// sort events by start time
events.sort((a, b) => a.start.getTime() - b.start.getTime());
// merge overlapping events
let mergedEvents: SimpleEventType[] = [];
for (let i = 0; i < events.length; i++) {
const event = events[i];
let found = false;
for (let j = 0; j < mergedEvents.length; j++) {
const mergedEvent = mergedEvents[j];
if (event.start >= mergedEvent.start && event.start <= mergedEvent.end) {
if (event.end > mergedEvent.end) {
mergedEvent.end = event.end;
}
found = true;
break;
} else if (event.end >= mergedEvent.start && event.end <= mergedEvent.end) {
if (event.start < mergedEvent.start) {
mergedEvent.start = event.start;
}
found = true;
break;
} else if (event.start <= mergedEvent.start && event.end >= mergedEvent.end) {
mergedEvent.start = event.start;
mergedEvent.end = event.end;
found = true;
break;
}
}
if (!found) {
mergedEvents.push(event);
}
}
//console.log('mergedEvents: ', mergedEvents);
userLogger.debug(user.user_id, 'booked time: ', JSON.stringify(mergedEvents, null, 2));
resolve(mergedEvents); // return booked time
} catch (error) {
userLogger.error(user.user_id, (error as Error).toString());
reject(error);
}
});
}
async function getFreeTimeFromOneUser(user: User, bookTimeLength: number, day: Date): Promise<SimpleEventType[]> {
return new Promise(async (resolve, reject) => {
try {
//check if user is in redis
let redisFreeTime = await redisClient.get(MyRedisKeys.freeTimeFromOneUserCache(user.user_id, bookTimeLength, day));
if (redisFreeTime) {
let freeTime: SimpleEventType[] = JSON.parse(redisFreeTime);
// change start and end to Date
for (const time of freeTime) {
time.start = new Date(time.start);
time.end = new Date(time.end);
}
resolve(freeTime);
return;
}
} catch (error) {
userLogger.error(user.user_id, (error as Error).toString());
}
try {
userLogger.debug(user.user_id, 'calc freetime: ', day.toLocaleDateString());
const store = await getStore(user.store_id);
const openHours = await getOpenHours(store.store_id);
// check if "day" is in user.settings.calendar_max_future_booking_days range
const maxFutureBookingDays = user.settings.calendar_max_future_booking_days;
const maxFutureBookingDaysDate = new Date(new Date().getTime() + maxFutureBookingDays * 24 * 60 * 60 * 1000);
if (day.getTime() > maxFutureBookingDaysDate.getTime()) {
reject('day is out of range');
return;
}
let earliestBookingTime = await getStartOfDayOfUser(user, openHours, day);
let bookedTime = await getBookedTimeFromOneUser(store, user, day);
let endTime = await getEndOfDay(openHours, day);
// create freeTime from bookedTime
let freeTime: SimpleEventType[] = [];
for (let i = 0; i < bookedTime.length; i++) {
const time = bookedTime[i];
if (i == 0) {
if (earliestBookingTime < time.start) {
freeTime.push({ start: earliestBookingTime, end: time.start });
}
} else {
freeTime.push({ start: bookedTime[i - 1].end, end: time.start });
}
}
if (bookedTime.length == 0) {
freeTime.push({ start: earliestBookingTime, end: endTime });
} else {
freeTime.push({ start: bookedTime[bookedTime.length - 1].end, end: endTime });
}
// remove freeTime that is too short
let _freeTime: SimpleEventType[] = [];
for (const time of freeTime) {
if (time.end.getTime() - time.start.getTime() + 1000 /*1000ms: just for better fitting*/ >= bookTimeLength * 60 * 1000) {
// set time.end to time.end - bookTimeLength
time.end = new Date(time.end.getTime() - bookTimeLength * 60 * 1000);
// check if time.end is before time.start
if (time.end.getTime() < time.start.getTime()) {
// if so, set time.end to time.start
time.end = new Date(time.start.getTime());
}
_freeTime.push(time);
}
}
freeTime = _freeTime;
// to redis
try {
await redisClient.set(MyRedisKeys.freeTimeFromOneUserCache(user.user_id, bookTimeLength, day), JSON.stringify(freeTime), { EX: 24 * 60 * 60 });
} catch (error) {}
resolve(freeTime);
} catch (error) {
try {
await redisClient.set(MyRedisKeys.freeTimeFromOneUserCache(user.user_id, bookTimeLength, day), JSON.stringify([]), { EX: 24 * 60 * 60 });
} catch (error) {}
reject(error);
}
});
}
async function getFreeTimeOfDay(storeID: StoreID, userIDs: string[], bookTimeLength: number, day: Date): Promise<SimpleUserEventType[]> {
return new Promise(async (resolve, reject) => {
try {
const redisKey = MyRedisKeys.freeTimeOfDayCache(storeID, userIDs, bookTimeLength, day);
try {
//check if user is in redis
let redisFreeTime = await redisClient.get(redisKey);
if (redisFreeTime) {
let freeTime: SimpleUserEventType[] = JSON.parse(redisFreeTime);
// change start and end to Date
for (const user of freeTime) {
for (const time of user.events) {
time.start = new Date(time.start);
time.end = new Date(time.end);
}
}
resolve(freeTime);
return;
}
} catch (error) {}
try {
const store = await getStore(storeID);
if (store === undefined) {
reject('store not found');
return;
}
let users: User[] = [];
for (const userID of userIDs) {
try {
const user = await getUser(userID);
if (user === undefined) {
reject('user not found');
return;
}
if (user.store_id !== storeID) {
//reject('user.store_id !== storeID');
//return;
continue;
}
users.push(user);
} catch (error) {
userLogger.error(userID, (error as Error).toString());
}
}
const openHours = await getOpenHours(storeID);
// day is the day to check
let freeTime: SimpleUserEventType[] = [];
let endTime = await getEndOfDay(openHours, day); // this also checks if day is in openHours
for (const user of users) {
try {
const _freeTime = await getFreeTimeFromOneUser(user, bookTimeLength, day);
freeTime.push({ userId: user.user_id, events: _freeTime });
} catch (error) {
userLogger.debug(user.user_id, '@reject', (error as Error).toString());
}
}
// cache to redis
try {
await redisClient.set(redisKey, JSON.stringify(freeTime), { EX: 24 * 60 * 60 });
} catch (error) {}
resolve(freeTime);
} catch (error) {
reject(error);
}
} catch (error) {
reject(error);
}
});
}
async function getFreeNextDays(store_id: StoreID, _users: UserID[], serviceDuration: number, _date: Date): Promise<Date[]> {
return new Promise(async (resolve, reject) => {
try {
// get next days until end of month
const nextDays = [];
const date = new Date(_date); // 11.01.2024 11:17:19
let currentDate = date.getDate(); // 1-31
date.setHours(0, 0, 0, 0); // 11.01.2024 00:00:00
date.setDate(1); // 01.01.2024 00:00:00
date.setMonth(date.getMonth() + 1); // 01.02.2024 00:00:00
date.setDate(0); // 31.01.2024 00:00:00
const lastDayOfMonth = date.getDate();
// set currentDate to new Date() when month of _date is the current month
if (new Date().getMonth() === _date.getMonth() && new Date().getFullYear() === _date.getFullYear()) {
_date = new Date();
currentDate = _date.getDate();
}
for (let i = currentDate - 1; i < lastDayOfMonth; i++) {
const day = new Date(_date);
if (currentDate !== i + 1) {
day.setHours(0, 0, 0, 0);
day.setDate(i + 1);
}
nextDays.push(day);
}
let freeDays: Date[] = [];
let users: UserID[] = [];
for (const user_id of _users) {
try {
const user = await getUser(user_id);
await calendar_ids.getEventCalendarID(user);
users.push(user.user_id);
userLogger.debug(user.user_id, 'calc getFreeNextDays:');
} catch (error) {}
}
if (users.length > 0) {
for (let i = 0; i < nextDays.length; i++) {
const day = nextDays[i];
try {
//const measureTime = new Date().getTime();
const freeTime = await getFreeTimeOfDay(store_id, users, serviceDuration, day);
let isDayFree = false;
for (const key in freeTime) {
const time = freeTime[key].events;
if (time.length > 0) {
isDayFree = true;
break;
}
}
if (isDayFree) {
freeDays.push(day);
}
//console.log('getFreeNextDays took ', new Date().getTime() - measureTime, 'ms');
} catch (error) {
//console.error(error);
}
}
}
resolve(freeDays);
} catch (error) {
console.error(error);
storeLogger.error(store_id, (error as Error).toString());
reject(error);
}
});
}
function printDate(str: string, date: Date) {
console.log(
str,
date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
);
}
export { getBookedTimeFromOneUser, getFreeTimeOfDay, getFreeNextDays, EventType };

View File

@ -0,0 +1,239 @@
import { OAuth2Client } from 'google-auth-library';
import { google } from 'googleapis';
import { clientId, clientSecret, defaultPrimaryCalendarId } from '../config';
import { getAccessToken, getOAuthClient } from './oauthAccess';
import lang from '../lang/default';
import calendar_ids, { CalendarType } from '../database/calendar_ids';
import { getAllUsersIDsByStoreID, getUser } from '../database/user';
import { Store, getStoreByUser } from '../database/store';
import { User } from '../user/types';
import { enableCalendarSync } from '../calendarSync/refreshSync';
import { userLogger } from '../logger/logger';
async function getPrefix(store: Store) {
return new Promise(async (resolve, reject) => {
try {
resolve(store.name);
} catch (error) {
console.log(error);
reject(error);
}
});
}
async function initCalendar(userId: string) {
return new Promise(async (resolve, reject) => {
function noPermission() {
userLogger.error(userId, 'No permission to access calendar');
reject('403');
}
try {
// wait 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
userLogger.info(userId, 'initCalendar...');
const user = await getUser(userId);
const store = await getStoreByUser(user);
const ownerUser = await getUser(store.owner_user_id);
const accessToken = await getAccessToken(ownerUser.user_id);
const calendar = google.calendar({ version: 'v3', auth: await getOAuthClient(accessToken) });
let calendars = await calendar_ids.getCalendars(ownerUser.user_id);
console.log('calendars', calendars);
userLogger.debug(userId, 'calendars', JSON.stringify(calendars, null, 2));
// delete calendar if it not exists in google
for (let i = 0; i < calendars.length; i++) {
const cal = calendars[i];
if (cal.type === 'primary') {
if (user.isOwner !== true) continue;
await enableCalendarSync(cal.calendar_id);
userLogger.info(userId, 'primary calendar enabled', cal.calendar_id);
continue;
}
if (user.isOwner !== true && cal.worker_user_id !== user.user_id) continue;
try {
/*const googleCalendar = await calendar.calendarList.get({
calendarId: cal.calendar_id,
});*/
const googleCalendar = await calendar.calendars.get({
calendarId: cal.calendar_id,
});
await enableCalendarSync(cal.calendar_id);
// check if i own the googleCalendar
//console.log(googleCalendar);
} catch (error) {
console.error('initCalendar:', (error as any).response?.status);
if ((error as any).response?.status == 404) {
delete calendars[i];
await calendar_ids.deleteCalendar(user, cal.calendar_id);
userLogger.warn(userId, 'deleted calendar ' + cal.calendar_id);
} else if ((error as any).response?.status == 403) {
noPermission();
return;
} else {
userLogger.error(userId, 'initCalendar:', JSON.stringify(error, null, 2));
}
}
}
// delete all undefined values in calendars
calendars = calendars.filter((value) => value);
const requiredCalendars: CalendarType[] = [];
if (user.isOwner === true) {
requiredCalendars.push('myEvents');
requiredCalendars.push('openHours');
} else {
requiredCalendars.push('workerEvents');
}
requiredCalendars.push('primary');
for (let i = 0; i < requiredCalendars.length; i++) {
const requiredCalendar = requiredCalendars[i];
let found = false;
let foundId = '';
for (let j = 0; j < calendars.length; j++) {
const cal = calendars[j];
if (cal.type == requiredCalendar) {
if (requiredCalendar === 'workerEvents' && cal.worker_user_id !== user.user_id) continue;
found = true;
foundId = cal.calendar_id;
break;
}
}
async function insertCalendar() {
console.log('inserting calendar ' + requiredCalendar);
userLogger.info(userId, 'inserting calendar ' + requiredCalendar);
let prefix = user.username;
if (requiredCalendar === 'primary') {
await calendar_ids.addCalendar(userId, defaultPrimaryCalendarId + '#' + userId, requiredCalendar);
return;
}
if (requiredCalendar === 'myEvents' || requiredCalendar === 'openHours') prefix = lang.calendar[requiredCalendar];
const cal = await calendar.calendars.insert({
requestBody: {
summary: prefix + ' - ' + (await getPrefix(store)),
description: prefix,
},
});
if (!cal.data.id) {
console.error('cal.data.id is undefined');
userLogger.error(userId, 'cal.data.id is undefined');
reject('cal.data.id is undefined');
return;
}
await calendar_ids.addCalendar(ownerUser.user_id, cal.data.id, requiredCalendar, user.isOwner !== true ? user.user_id : undefined);
}
if (!found || (requiredCalendar === 'primary' && user.isOwner !== true)) {
await insertCalendar();
} else {
try {
if (requiredCalendar === 'primary') continue;
const caltest = await calendar.calendars.get({
calendarId: foundId,
});
//console.log('found: ', caltest.data);
} catch (error) {
console.log(error);
console.log('calendar not found in init');
userLogger.warn(userId, 'initCalendar: calendar not found, creating...');
await insertCalendar();
}
}
}
if (user.isOwner === true) {
//also initCalendar for all workers
try {
const workersIDs = await getAllUsersIDsByStoreID(store.store_id);
for (let i = 0; i < workersIDs.length; i++) {
if (workersIDs[i] === user.user_id) continue;
const workerID = workersIDs[i];
await initCalendar(workerID);
}
} catch (error) {
console.error(error);
}
}
resolve(true);
} catch (error) {
console.log(error);
if ((error as any).response?.status == 403) {
noPermission();
return;
}
reject(error);
}
});
}
async function removeCalendar(user: User) {
return new Promise(async (resolve, reject) => {
try {
const store = await getStoreByUser(user);
const ownerUser = await getUser(store.owner_user_id);
const accessToken = await getAccessToken(ownerUser.user_id);
const calendar = google.calendar({ version: 'v3', auth: await getOAuthClient(accessToken) });
const calendars = await calendar_ids.getCalendars(ownerUser.user_id);
for (let i = 0; i < calendars.length; i++) {
const cal = calendars[i];
if ((cal.type == 'workerEvents' && cal.worker_user_id === user.user_id) || user.isOwner === true) {
console.log('deleting calendar ' + cal.calendar_id);
console.log('From User:', user.user_id, ' (Owner:', ownerUser.user_id, ')');
userLogger.warn(user.user_id, 'deleting calendar ' + cal.calendar_id, 'From User:', user.user_id, ' (Owner:', ownerUser.user_id, ')');
/*await calendar.calendars.delete({
calendarId: cal.calendar_id,
});*/ // no permission to delete calendar
await calendar_ids.deleteCalendar(user, cal.calendar_id);
}
}
resolve(true);
} catch (error) {
console.log(error);
reject(error);
}
});
}
export default initCalendar;
export { removeCalendar };

174
src/calendar/insertEvent.ts Normal file
View File

@ -0,0 +1,174 @@
import { google } from 'googleapis';
import { CalendarEvent } from '../database/reservedCustomer';
import { Store, getStoreByUser } from '../database/store';
import { getUser } from '../database/user';
import { User } from '../user/types';
import { EventType, getBookedTimeFromOneUser } from './getFreeTime';
import { getAccessToken, getOAuthClient } from './oauthAccess';
import { get } from 'http';
import { userInfo } from 'os';
import calendar_ids from '../database/calendar_ids';
import { getActivity } from '../database/services';
import { getConnection } from '../database/initDatabase';
import { insertEvent as insertEventGoogleMariadb, deleteEvent as deleteEventGoogleMariadb } from '../calendarSync/misc';
import clearRedisCache from '../redis/clearCache';
import { userLogger } from '../logger/logger';
async function checkIfTimeIsAvailable(store: Store, user: User, start: Date, end: Date): Promise<void> {
return new Promise(async (resolve, reject) => {
try {
// check if start is before end
if (start >= end) {
reject('Start time is after end time');
return;
}
// check if start and end is in the same day, month and year
if (start.getDate() !== end.getDate() || start.getMonth() !== end.getMonth() || start.getFullYear() !== end.getFullYear()) {
reject('Start and end is not in the same day, month and year');
return;
}
await clearRedisCache(store.store_id);
const bookedTime = await getBookedTimeFromOneUser(store, user, start, start /* time is important */);
console.log('bookedTime', bookedTime);
// check if start and end is not overlapping with other events in bookedTime
for (let i = 0; i < bookedTime.length; i++) {
if (start >= bookedTime[i].start && start < bookedTime[i].end) {
reject('Start time is overlapping with another event');
return;
}
if (end > bookedTime[i].start && end <= bookedTime[i].end) {
reject('End time is overlapping with another event');
return;
}
if (start <= bookedTime[i].start && end >= bookedTime[i].end) {
reject('New event overlaps an existing event');
return;
}
}
resolve();
} catch (error) {
reject(error);
}
});
}
async function insertEvent(event: CalendarEvent): Promise<{ event: CalendarEvent; googleEvent: EventType }> {
return new Promise(async (resolve, reject) => {
let conn;
try {
// insert event into google calendar
let calendarUser = await getUser(event.user_id);
if (calendarUser === undefined) {
reject('User not found');
return;
}
conn = await getConnection();
const store = await getStoreByUser(calendarUser);
const accessToken = await getAccessToken(store.owner_user_id);
const calendar = google.calendar({ version: 'v3', auth: await getOAuthClient(accessToken) });
let activity = await getActivity(event.store_id, event.activity_id, true);
let description = activity.name + '\n';
description += 'E-Mail: ' + event.customer_email + '\n';
description += '\n';
description += '----\n';
description += event.customer_message;
const calendarID = await calendar_ids.getEventCalendarID(calendarUser);
const requestBody = {
summary: event.customer_name,
description: description,
start: {
dateTime: event.time_start.toISOString(),
},
end: {
dateTime: event.time_end.toISOString(),
},
};
const calRes = await calendar.events.insert({
calendarId: calendarID,
requestBody,
});
if (calRes.data.id !== null) {
await insertEventGoogleMariadb(calRes.data, calendarID, conn);
event.calendar_event_id = calRes.data.id;
}
userLogger.info(event.user_id, 'Event inserted', JSON.stringify(event, null, 2));
resolve({ event: event, googleEvent: calRes.data });
} catch (error) {
console.log('insertEvent error', error);
reject(error);
} finally {
if (conn) conn.release();
await clearRedisCache(event.store_id);
}
});
}
async function deleteEvent(event: CalendarEvent, ignoreIfNotExist?: boolean): Promise<void> {
return new Promise(async (resolve, reject) => {
let conn;
try {
// delete event from google calendar
let calendarUser = await getUser(event.user_id);
if (calendarUser === undefined) {
reject('User not found');
return;
}
conn = await getConnection();
const store = await getStoreByUser(calendarUser);
const calendarID = await calendar_ids.getEventCalendarID(calendarUser);
if (event.calendar_event_id) await deleteEventGoogleMariadb(event.calendar_event_id, calendarID, conn);
const accessToken = await getAccessToken(store.owner_user_id);
const calendar = google.calendar({ version: 'v3', auth: await getOAuthClient(accessToken) });
await calendar.events.delete({
calendarId: calendarID,
eventId: event.calendar_event_id,
});
userLogger.info(event.user_id, 'Event deleted', JSON.stringify(event, null, 2));
resolve();
} catch (_error) {
const error = _error as any;
if (error.response && error.response.status === 410) {
// 410 means event is already deleted
resolve();
return;
}
if (ignoreIfNotExist === true && error.response && error.response.status === 404) {
resolve();
return;
}
reject(error);
} finally {
if (conn) conn.release();
await clearRedisCache(event.store_id);
}
});
}
export { checkIfTimeIsAvailable, insertEvent, deleteEvent };

125
src/calendar/oauthAccess.ts Normal file
View File

@ -0,0 +1,125 @@
import { clientId, clientSecret } from '../config';
import GoogleTokensDatabase from '../database/user_google_tokens';
import { OAuth2Client } from 'google-auth-library';
import initCalendar from './initCalendar';
import { userLogger } from '../logger/logger';
function initOauthAccess(userId: string, refreshToken: string, accessToken: string, expiryDate: Date, googleUuid: string) {
return new Promise(async (resolve, reject) => {
// get access token
const oauth2Client = new OAuth2Client({
clientId,
clientSecret,
});
oauth2Client.setCredentials({
access_token: accessToken,
refresh_token: refreshToken,
//expiry_date: tokens.expiry_date.getTime(),
});
try {
await GoogleTokensDatabase.initGoogleAccount(userId, accessToken, refreshToken, expiryDate, googleUuid);
userLogger.info(userId, 'Google account initialized');
try {
await initCalendar(userId);
await GoogleTokensDatabase.updateGoogleAccountStatus(userId, 'OK');
resolve(accessToken);
} catch (err) {
console.log(err);
if (err === '403') {
await GoogleTokensDatabase.updateGoogleAccountStatus(userId, 'NOPERM');
} else {
await GoogleTokensDatabase.updateGoogleAccountStatus(userId, 'ERROR');
}
reject(err);
}
} catch (err) {
console.log(err);
await GoogleTokensDatabase.updateGoogleAccountStatus(userId, 'ERROR');
reject(err);
}
});
}
async function getAccessToken(userId: string): Promise<string> {
return new Promise(async (resolve, reject) => {
GoogleTokensDatabase.getGoogleTokens(userId)
.then((tokens) => {
// check if token is expired
const expiryDate = tokens.expiry_date;
const now = new Date();
//console.log('token expires in ' + (expiryDate.getTime() - now.getTime()) / 1000 / 60 + ' minutes');
// refresh token if it expires in less than 1 minute
if (expiryDate.getTime() - now.getTime() < 1 * 60 * 1000) {
//console.log('token expired, refreshing...');
const oauth2Client = new OAuth2Client({
clientId,
clientSecret,
//redirectUri,
});
oauth2Client.setCredentials({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expiry_date: tokens.expiry_date.getTime(),
});
oauth2Client
.refreshAccessToken()
.then((refreshedTokens: any) => {
const accessToken = refreshedTokens.credentials.access_token;
const expireDate = new Date(refreshedTokens.credentials.expiry_date);
// save to database
GoogleTokensDatabase.updateAccessToken(userId, accessToken, expireDate)
.then(() => {
userLogger.info(userId, 'Google access token refreshed');
resolve(accessToken);
})
.catch((err) => {
console.log('updateAccessToken()', err);
reject(err);
});
})
.catch((err: any) => {
if (err?.response?.data?.error === 'invalid_grant') {
GoogleTokensDatabase.unlinkGoogleAccount(userId);
} else {
userLogger.error(userId, 'Failed to refresh Google access token', JSON.stringify(err, null, 2));
}
reject(err);
});
} else {
resolve(tokens.access_token);
}
})
.catch((err) => {
console.log('GetAccessToken', err);
reject(err);
});
});
}
async function getOAuthClient(accessToken: string) {
const auth = new OAuth2Client({
clientId,
clientSecret,
});
auth.setCredentials({ access_token: accessToken });
return auth;
}
export { initOauthAccess, getAccessToken, getOAuthClient };

View File

@ -0,0 +1,215 @@
import { google } from 'googleapis';
import { getAccessToken, getOAuthClient } from './oauthAccess';
import calendar_ids from '../database/calendar_ids';
import user_google_tokens from '../database/user_google_tokens';
import { getUser } from '../database/user';
import { User, UserID } from '../user/types';
import { StoreID, getAllStoreIDs, getStore } from '../database/store';
import { GLOBAL_calendar_min_earliest_booking_time } from '../config';
import dotenv from 'dotenv';
import { refreshOpenHours } from '../calendarSync/openHours';
import MyRedisKeys from '../redis/keys';
import redisClient from '../redis/init';
dotenv.config();
interface OpenHoursItem {
start: Date;
end: Date;
}
async function getOpenHours(storeID: string): Promise<OpenHoursItem[]> {
return new Promise(async (resolve, reject) => {
try {
let cachedOpenHours = await redisClient.get(MyRedisKeys.calendarOpenHours(storeID));
if (cachedOpenHours) {
let openHours = JSON.parse(cachedOpenHours) as OpenHoursItem[];
// change the date strings to Date objects
openHours = openHours.map((openHour) => {
return {
start: new Date(openHour.start),
end: new Date(openHour.end),
};
});
resolve(openHours);
return;
}
const openHours = await forceGetFreshOpenHours(storeID);
resolve(openHours);
} catch (error) {
console.error(error);
reject([]);
}
});
}
async function forceGetFreshOpenHours(storeID: string): Promise<OpenHoursItem[]> {
return new Promise(async (resolve, reject) => {
try {
console.log('forceGetFreshOpenHours', storeID);
const resp = await refreshOpenHours(storeID);
resolve(resp || []);
} catch (error) {
console.error(error);
reject([]);
}
});
}
function getClosedHours(openHours: OpenHoursItem[], earliestBookingTime: Date, endDate: Date) {
let closedHours: OpenHoursItem[] = [];
for (let i = 0; i < openHours.length; i++) {
const openHour = openHours[i];
// Skip the openHour if it's before the earliestBookingTime or after the endTime
if (openHour.end < earliestBookingTime || openHour.start > endDate) {
continue;
}
if (i == 0) {
if (earliestBookingTime < openHour.start) {
closedHours.push({ start: earliestBookingTime, end: openHour.start });
}
} else {
const previousEnd = openHours[i - 1].end;
// Only add to closedHours if the previous end time is within the booking window
if (previousEnd >= earliestBookingTime && previousEnd <= endDate) {
closedHours.push({ start: previousEnd, end: openHour.start });
}
}
}
return closedHours;
}
async function getStartOfDay(openHours: OpenHoursItem[], day: Date): Promise<Date> {
return new Promise((resolve, reject) => {
let startDate = null;
let startOfDay = new Date(day);
startOfDay.setHours(0, 0, 0, 0);
const currentTime = new Date();
for (let i = openHours.length - 1; i >= 0; i--) {
const openHour = openHours[i];
if (openHour.start <= startOfDay && openHour.end >= startOfDay) {
startDate = startOfDay;
break;
}
// find the first date in openHours that is after startOfDay
if (openHour.start >= startOfDay && openHour.start <= (startDate || Infinity)) {
startDate = openHour.start;
} else {
break;
}
}
if (!startDate) {
reject('The office is closed on this day');
return;
}
// if startDate is before currentTime, set startDate to currentTime
if (startDate < currentTime) {
startDate = currentTime;
}
resolve(startDate);
});
}
async function getStartOfDayOfUser(user: User, openHours: OpenHoursItem[], day: Date, doNotAddOffset?: boolean): Promise<Date> {
const timeShiftOffset = doNotAddOffset ? 0 : GLOBAL_calendar_min_earliest_booking_time;
let timeShift = (user.settings.calendar_min_earliest_booking_time + timeShiftOffset) * 60 * 1000;
let endOfDay = await getEndOfDay(openHours, day);
if (new Date().getTime() + timeShift > endOfDay.getTime()) {
console.error('no earliestBookingTime found');
throw new Error('The office is closed on this day');
}
const startOfDay = await getStartOfDay(openHours, day);
if (new Date().getTime() + timeShift < startOfDay.getTime()) {
timeShift = 0;
}
const earliestBookingTime = new Date(startOfDay.getTime() + timeShift);
if (earliestBookingTime > endOfDay) {
console.error('no earliestBookingTime found');
throw new Error('The office is closed on this day');
}
return earliestBookingTime;
}
async function getEndOfDay(openHours: OpenHoursItem[], day: Date): Promise<Date> {
return new Promise((resolve, reject) => {
let endDate = null;
let endOfDay = new Date(day);
endOfDay.setHours(23, 59, 59, 999);
const currentTime = new Date();
for (let i = 0; i < openHours.length; i++) {
const openHour = openHours[i];
if (openHour.start <= endOfDay && openHour.end >= endOfDay) {
endDate = endOfDay;
break;
}
// find the last date in openHours that is before endOfDay
if (openHour.end <= endOfDay && openHour.end >= (endDate || 0)) {
if (openHour.end >= currentTime) {
endDate = openHour.end;
}
} else {
break;
}
}
if (!endDate) {
console.error('no endDate found');
reject('The office is closed on this day');
return;
}
/*if (!endDate) {
// find the last date in openHours
for (let i = openHours.length - 1; i >= 0; i--) {
const openHour = openHours[i];
if (openHour.end >= (endDate || 0)) {
endDate = openHour.end;
} else {
break;
}
}
if (!endDate) {
console.error('no endDate found');
reject('The office is closed on this day');
return;
}
// if currentTime is before endDate, set endDate to endOfDay
if (currentTime < endDate) {
endDate = endOfDay;
} else {
console.error('no endDate found');
reject('The office is closed on this day');
return;
}
}*/
resolve(endDate);
});
}
export { forceGetFreshOpenHours, getOpenHours, getEndOfDay, getStartOfDayOfUser, getClosedHours, OpenHoursItem };

View File

@ -0,0 +1,70 @@
import calendar_ids from '../database/calendar_ids';
import { getConnection } from '../database/initDatabase';
interface CalendarEvent {
event_id: string;
calendar_id: string;
start_time: Date;
end_time: Date;
status_val: string;
updated_time: Date;
transparency: string;
}
async function getAllEventsFromDatabase(calendarId: string, start: Date, end: Date) {
return new Promise<CalendarEvent[]>(async (resolve, reject) => {
let conn;
try {
if ((await calendar_ids.checkIfCalendarExists(calendarId)) === false) {
reject('Calendar does not exist ' + calendarId);
return;
}
conn = await getConnection();
const query =
'SELECT * FROM `calendar_events` WHERE `calendar_id` = ? AND ((`start_time` >= ? AND `start_time` <= ?) OR (`end_time` >= ? AND `end_time` <= ?) OR (`start_time` <= ? AND `end_time` >= ?)) ORDER BY `start_time`';
const params = [calendarId, start, end, start, end, start, end];
const rows = await conn.query(query, params);
resolve(rows as CalendarEvent[]);
} catch (error) {
console.log('getAllEventsFromDatabase error', error);
reject(error);
} finally {
if (conn) {
conn.end();
}
}
});
}
async function getAllBusyEventsFromDatabase(calendarId: string, start: Date, end: Date) {
return new Promise<CalendarEvent[]>(async (resolve, reject) => {
let conn;
try {
if ((await calendar_ids.checkIfCalendarExists(calendarId)) === false) {
reject('Calendar does not exist ' + calendarId);
return;
}
conn = await getConnection();
const query =
'SELECT * FROM `calendar_events` WHERE `calendar_id` = ? AND `status_val` = "confirmed" AND `transparency` = "opaque" AND ((`start_time` >= ? AND `start_time` <= ?) OR (`end_time` >= ? AND `end_time` <= ?) OR (`start_time` <= ? AND `end_time` >= ?)) ORDER BY `start_time`';
const params = [calendarId, start, end, start, end, start, end];
const rows = await conn.query(query, params);
resolve(rows as CalendarEvent[]);
} catch (error) {
console.log('getAllEventsFromDatabase error', error);
reject(error);
} finally {
if (conn) {
conn.end();
}
}
});
}
export { getAllEventsFromDatabase, getAllBusyEventsFromDatabase };

95
src/calendarSync/misc.ts Normal file
View File

@ -0,0 +1,95 @@
import { log } from 'console';
import { calendar_v3 } from 'googleapis';
import { PoolConnection } from 'mariadb';
import logger from '../logger/logger';
async function deleteEvent(eventId: string, calendarId: string, conn: PoolConnection) {
return new Promise(async (resolve, reject) => {
try {
await conn.query('DELETE FROM `calendar_events` WHERE `event_id` = ? AND `calendar_id` = ?', [eventId, calendarId]);
resolve(undefined);
} catch (error) {
console.log('deleteEvent error', error);
logger.error('deleteEvent error', JSON.stringify(error, null, 2));
reject(error);
}
});
}
// true if event is inserted, false if event is deleted
async function insertEvent(event: calendar_v3.Schema$Event, calendarId: string, conn: PoolConnection): Promise<boolean> {
return new Promise(async (resolve, reject) => {
try {
if (event.status === 'cancelled') {
try {
if (event.id) {
await deleteEvent(event.id, calendarId, conn);
}
} catch (error) {}
resolve(false);
return;
}
let eventStart, eventEnd, eventUpdated, eventTransparency;
if (event.start && event.start.date) {
eventStart = new Date(event.start.date) || undefined;
eventStart.setHours(0, 0, 0, 0);
}
if (event.start && event.start.dateTime) eventStart = new Date(event.start.dateTime) || undefined;
if (event.end && event.end.date) {
eventEnd = new Date(event.end.date) || undefined;
eventEnd.setHours(0, 0, 0, 0);
}
if (event.end && event.end.dateTime) eventEnd = new Date(event.end.dateTime) || undefined;
if (!event.transparency) eventTransparency = 'opaque';
else eventTransparency = event.transparency;
//if (event.updated) eventUpdated = new Date(event.updated);
// use own updated time instead of google's
eventUpdated = new Date();
// insert event into database
let query = 'INSERT INTO `calendar_events` (`event_id`, `calendar_id`, `start_time`, `end_time`, `status_val`, `updated_time`, `transparency`) VALUES (?, ?, ?, ?, ?, ?, ?)';
let params = [event.id, calendarId, eventStart, eventEnd, event.status, eventUpdated, eventTransparency];
let updates = [];
if (eventStart !== null && eventStart !== undefined) {
updates.push('`start_time` = ?');
params.push(eventStart);
}
if (eventEnd !== null && eventEnd !== undefined) {
updates.push('`end_time` = ?');
params.push(eventEnd);
}
if (event.status !== null && event.status !== undefined) {
updates.push('`status_val` = ?');
params.push(event.status);
}
if (eventUpdated !== null && eventUpdated !== undefined) {
updates.push('`updated_time` = ?');
params.push(eventUpdated);
}
if (eventTransparency !== null && eventTransparency !== undefined) {
updates.push('`transparency` = ?');
params.push(eventTransparency);
}
if (updates.length > 0) {
query += ' ON DUPLICATE KEY UPDATE ' + updates.join(', ');
}
await conn.query(query, params);
resolve(true);
} catch (error) {
console.log('insertEvent error', error);
reject(error);
}
});
}
export { insertEvent, deleteEvent };

View File

@ -0,0 +1,131 @@
// External libraries
import { google } from 'googleapis';
// Calendar related imports
import { getAccessToken, getOAuthClient } from '../calendar/oauthAccess';
// Database related imports
import calendar_ids from '../database/calendar_ids';
import user_google_tokens from '../database/user_google_tokens';
import { getAllUsersIDsByStoreID, getUser } from '../database/user';
import { StoreID, getAllStoreIDs, getStore } from '../database/store';
// User related imports
import { User, UserID } from '../user/types';
// Event related imports
import { getAllEventsFromDatabase } from './getAllEvents';
// Redis related imports
import MyRedisKeys from '../redis/keys';
import redisClient from '../redis/init';
interface OpenHoursItem {
start: Date;
end: Date;
}
async function getHighestCalendarMaxFutureBookingDays(storeID: StoreID) {
return new Promise<number>(async (resolve, reject) => {
// get all users of this store
const users = await getAllUsersIDsByStoreID(storeID);
let highest_calendar_max_future_booking_days = 0;
for (const user_id of users) {
const user = await getUser(user_id);
if (user.settings.calendar_max_future_booking_days > highest_calendar_max_future_booking_days) {
highest_calendar_max_future_booking_days = user.settings.calendar_max_future_booking_days;
}
}
resolve(highest_calendar_max_future_booking_days);
});
}
async function refreshOpenHours(storeID: StoreID) {
try {
let openHoursList: OpenHoursItem[] = [];
const store = await getStore(storeID);
const user_id = store.owner_user_id;
const highest_calendar_max_future_booking_days = await getHighestCalendarMaxFutureBookingDays(storeID);
try {
const calRes = await getAllEventsFromDatabase(
await calendar_ids.getOpenHoursCalendar(user_id),
new Date(),
new Date(new Date().getTime() + highest_calendar_max_future_booking_days * 24 * 60 * 60 * 1000)
);
if (calRes) {
const events = calRes;
if (events.length) {
let eventList: OpenHoursItem[] = [];
for (let i = 0; i < events.length; i++) {
const event = events[i];
const start = event.start_time;
const end = event.end_time;
if (!start || !end) continue;
eventList.push({ start: new Date(start), end: new Date(end) });
}
let filteredEventList = [];
for (let i = 0; i < eventList.length; i++) {
const event = eventList[i];
let found = false;
for (let j = 0; j < filteredEventList.length; j++) {
const filteredEvent = filteredEventList[j];
if (event.start >= filteredEvent.start && event.start <= filteredEvent.end) {
if (event.end > filteredEvent.end) {
filteredEvent.end = event.end;
}
found = true;
break;
} else if (event.end >= filteredEvent.start && event.end <= filteredEvent.end) {
if (event.start < filteredEvent.start) {
filteredEvent.start = event.start;
}
found = true;
break;
} else if (event.start <= filteredEvent.start && event.end >= filteredEvent.end) {
filteredEvent.start = event.start;
filteredEvent.end = event.end;
found = true;
break;
}
}
if (!found) {
filteredEventList.push(event);
}
}
openHoursList = filteredEventList;
// save open hours to redis
const redisKey = MyRedisKeys.calendarOpenHours(storeID);
await redisClient.set(redisKey, JSON.stringify(openHoursList), { EX: 24 * 60 * 60 }); // 24 hours
return openHoursList;
} else {
console.log('No upcoming events found.');
}
} else {
console.log('No calendar response found.');
}
} catch (err) {
console.log('The API returned an error: ' + err);
}
} catch (error) {
console.log(error);
}
return null;
}
export { refreshOpenHours, getHighestCalendarMaxFutureBookingDays };

View File

@ -0,0 +1,622 @@
// External libraries
import { calendar_v3, google } from 'googleapis';
// Database related imports
import { getConnection } from '../database/initDatabase';
import { getUser } from '../database/user';
// Calendar related imports
import { getAccessToken, getOAuthClient } from '../calendar/oauthAccess';
// Redis related imports
import redisClient from '../redis/init';
import MyRedisKeys from '../redis/keys';
// Config and utilities
import { GLOBAL_google_webhook_time_delay, GLOBAL_sync_calendar_every_minutes, googleSyncMaxDays, publicURL } from '../config';
import countUp from '../requestCounter';
import countGoogleRequestStatUp from '../requestCounter';
// Other internal modules
import { insertEvent } from './misc';
import { refreshOpenHours } from './openHours';
import generateRandomString from '../randomString';
import { PoolConnection } from 'mariadb';
import { User } from '../user/types';
import { forceGetFreshOpenHours } from '../calendar/openHoursList';
import clearRedisCache from '../redis/clearCache';
import fillCache from '../redis/fillCache';
import calendar_ids from '../database/calendar_ids';
import { storeLogger, userLogger } from '../logger/logger';
import GoogleTokensDatabase from '../database/user_google_tokens';
interface CalendarIDRow {
user_id: string;
calendar_id: string;
type: string;
worker_user_id?: string;
sync_token?: string;
next_sync?: Date;
sync_token_expires_at?: Date;
webhook_channel_id?: string;
webhook_resource_id?: string;
webhook_expires_at?: Date;
isChanged?: boolean;
isDisabled?: boolean;
}
async function refreshSyncToken() {
let conn;
try {
conn = await getConnection();
const currentTime = new Date(new Date().getTime() + 1000 * 60 * 30); // when it expires in 30 minutes
const rows: CalendarIDRow[] = await conn.query('SELECT * FROM `calendar_ids` WHERE `is_disabled` IS NULL AND (`sync_token` IS NULL OR `sync_token_expires_at` < ?) ORDER BY RAND() LIMIT 1', [
currentTime,
]);
conn.end();
//console.log('Found ' + rows.length + ' calendars without sync token');
if (rows.length >= 1) {
for (const row of rows) {
try {
await syncCalendar(row, false);
} catch (error) {
console.error(error);
}
}
}
} catch (error) {
console.error(error);
} finally {
if (conn) {
conn.end();
}
}
}
async function _updateSyncToken(_myCalendar: CalendarIDRow, user: User, calendarId: string, fullCalendarId: string, conn: PoolConnection, refreshLock: Function): Promise<CalendarIDRow> {
return new Promise(async (resolve, reject) => {
let myCalendar: CalendarIDRow = _myCalendar;
try {
const isPrimary = myCalendar.type === 'primary';
if (isPrimary && user.settings.calendar_using_primary_calendar !== true) {
resolve(myCalendar);
return;
}
if (myCalendar.sync_token && myCalendar.sync_token_expires_at && myCalendar.sync_token_expires_at > new Date()) {
resolve(myCalendar);
return;
}
if (!myCalendar.sync_token || !myCalendar.sync_token_expires_at || myCalendar.sync_token_expires_at < new Date()) {
await calendar_ids.clearCalendarDatabase(myCalendar.calendar_id);
console.log('Cleared calendar database for calendar ' + myCalendar.calendar_id);
userLogger.info(user.user_id, '_updateSyncToken()', 'Cleared calendar database for calendar ' + myCalendar.calendar_id);
}
const next_sync = new Date(new Date().getTime() + 1000 * 60 * 15); // first time sync again in 15 minutes
const accessToken = await getAccessToken(user.user_id);
const calendar = google.calendar({ version: 'v3', auth: await getOAuthClient(accessToken) });
// create calendar sync token for row.calendar_id
const timeMax = new Date(new Date().getTime() + googleSyncMaxDays * 24 * 60 * 60 * 1000);
const expireTime = new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000); // make new whole sync every 14 days
let getListSettings: calendar_v3.Params$Resource$Events$List = {
calendarId: calendarId,
timeMin: new Date().toISOString(),
timeMax: timeMax.toISOString(),
maxResults: 2500, // warning: this is a hard limit
singleEvents: true,
showDeleted: true,
};
userLogger.info(user.user_id, '_updateSyncToken()', "running full sync for calendar '" + fullCalendarId + "'");
// get calendar events
countGoogleRequestStatUp('refreshSyncToken');
let events = await calendar.events.list(getListSettings);
const totalEvents = [events];
let pageCounter = 0;
// get all events
while (events.data.nextPageToken) {
pageCounter++;
// refresh lock
await refreshLock();
// wait random time between 0 and 10 seconds
conn.end();
await new Promise((resolve) => setTimeout(resolve, Math.random() * 10000));
conn = await getConnection();
getListSettings.pageToken = events.data.nextPageToken;
countGoogleRequestStatUp('refreshSyncTokenPage');
events = await calendar.events.list(getListSettings);
totalEvents.push(events);
}
// get sync token
const newSyncToken = events.data.nextSyncToken;
userLogger.info(user.user_id, '_updateSyncToken()', "calendar '" + fullCalendarId + "' took " + (pageCounter + 1) + ' pages to sync');
if (newSyncToken) {
let insertedEvents = 0;
let deletedEvents = 0;
for (const _events of totalEvents) {
if (!_events.data.items) continue;
for (const event of _events.data.items) {
// insert event into database
const isInsered = await insertEvent(event, fullCalendarId, conn);
if (isInsered) {
insertedEvents++;
} else {
// deleted
deletedEvents++;
}
}
}
userLogger.info(
user.user_id,
'_updateSyncToken()',
"calendar '" + fullCalendarId + "' took " + (pageCounter + 1) + ' pages to sync and inserted ' + insertedEvents + ' events and deleted ' + deletedEvents + ' events'
);
// update sync token
await conn.query('UPDATE `calendar_ids` SET `sync_token` = ?, `sync_token_expires_at` = ?, `next_sync` = ? WHERE `calendar_id` = ?', [
newSyncToken,
expireTime /*also min. sync_token_expires_at*/,
next_sync,
fullCalendarId,
]);
myCalendar.sync_token = newSyncToken;
myCalendar.sync_token_expires_at = expireTime;
myCalendar.next_sync = next_sync;
} else {
console.error('newSyncToken is null');
myCalendar.sync_token = undefined;
myCalendar.sync_token_expires_at = undefined;
myCalendar.next_sync = undefined;
}
myCalendar.isChanged = true;
resolve(myCalendar);
} catch (error) {
if ((error as any).code === 403 || (error as any).code === 404) {
console.error('403 error, setting is_disabled to 1');
userLogger.error(user.user_id, '_updateSyncToken()', '403 error, setting is_disabled to 1');
await conn.query('UPDATE `calendar_ids` SET `is_disabled` = 1 WHERE `calendar_id` = ?', [fullCalendarId]);
myCalendar.isDisabled = true;
myCalendar.isChanged = true;
resolve(myCalendar);
} else {
reject(error);
}
}
});
}
async function _updateEvents(_myCalendar: CalendarIDRow, user: User, calendarId: string, fullCalendarId: string, conn: PoolConnection, refreshLock: Function): Promise<CalendarIDRow> {
return new Promise(async (resolve, reject) => {
try {
let myCalendar: CalendarIDRow = _myCalendar;
const isPrimary = myCalendar.type === 'primary';
if (isPrimary && user.settings.calendar_using_primary_calendar !== true) {
resolve(myCalendar);
return;
}
if (!myCalendar.sync_token || !myCalendar.sync_token_expires_at || myCalendar.sync_token_expires_at < new Date()) {
resolve(myCalendar);
return;
}
if (myCalendar.next_sync && myCalendar.next_sync > new Date()) {
resolve(myCalendar);
return;
}
const accessToken = await getAccessToken(user.user_id);
const calendar = google.calendar({ version: 'v3', auth: await getOAuthClient(accessToken) });
const getListSettings: calendar_v3.Params$Resource$Events$List = {
calendarId: calendarId,
syncToken: myCalendar.sync_token,
maxResults: 2500, // warning: this is a hard limit
singleEvents: true,
showDeleted: true,
};
// get calendar events
countGoogleRequestStatUp('updateEvents');
let events = await calendar.events.list({
...getListSettings,
});
const totalEvents = [events];
let pageCounter = 0;
// get all events
while (events.data.nextPageToken) {
pageCounter++;
// refresh lock
await refreshLock();
// wait random time between 0 and 10 seconds
conn.end();
await new Promise((resolve) => setTimeout(resolve, Math.random() * 10000));
conn = await getConnection();
countGoogleRequestStatUp('updateEventsPage');
events = await calendar.events.list({
pageToken: events.data.nextPageToken,
...getListSettings,
});
totalEvents.push(events);
}
// update sync token
const syncToken = events.data.nextSyncToken;
const next_sync = new Date(new Date().getTime() + 1000 * 60 * GLOBAL_sync_calendar_every_minutes);
if (syncToken && syncToken !== myCalendar.sync_token) {
// update sync token in database
await conn.query('UPDATE `calendar_ids` SET `sync_token` = ? WHERE `calendar_id` = ?', [syncToken, fullCalendarId]);
console.log('Updated sync token for calendar ' + fullCalendarId, 'from', myCalendar.sync_token, 'to', syncToken);
myCalendar.sync_token = syncToken;
}
let insertedEvents = 0;
let deletedEvents = 0;
// update events
for (const _events of totalEvents) {
if (!_events.data.items) continue;
for (const event of _events.data.items) {
// insert event into database
console.log('inserting event', event);
const isInsered = await insertEvent(event, fullCalendarId, conn);
if (isInsered) {
insertedEvents++;
} else {
// deleted
deletedEvents++;
}
}
}
userLogger.info(
user.user_id,
'_updateEvents()',
"calendar '" + fullCalendarId + "' took " + (pageCounter + 1) + ' pages to sync and inserted ' + insertedEvents + ' events and deleted ' + deletedEvents + ' events'
);
// update next_sync
await conn.query('UPDATE `calendar_ids` SET `next_sync` = ? WHERE `calendar_id` = ?', [next_sync, fullCalendarId]);
console.log('Updated next_sync for calendar ' + fullCalendarId, 'to', next_sync);
myCalendar.next_sync = next_sync;
myCalendar.isChanged = true;
resolve(myCalendar);
} catch (error) {
reject(error);
}
});
}
async function _subscribeToWebhook(_myCalendar: CalendarIDRow, user: User, calendarId: string, fullCalendarId: string, conn: PoolConnection, refreshLock: Function): Promise<CalendarIDRow> {
return new Promise(async (resolve, reject) => {
try {
let myCalendar: CalendarIDRow = _myCalendar;
const isPrimary = myCalendar.type === 'primary';
if (isPrimary && user.settings.calendar_using_primary_calendar !== true) {
resolve(myCalendar);
return;
}
if (!myCalendar.sync_token || !myCalendar.sync_token_expires_at || myCalendar.sync_token_expires_at < new Date()) {
resolve(myCalendar);
return;
}
if (myCalendar.webhook_channel_id && myCalendar.webhook_expires_at && myCalendar.webhook_expires_at > new Date()) {
resolve(myCalendar);
return;
}
const accessToken = await getAccessToken(user.user_id);
const calendar = google.calendar({ version: 'v3', auth: await getOAuthClient(accessToken) });
const webhookId = await generateRandomString(32);
const webhook = await calendar.events.watch({
calendarId: calendarId,
requestBody: {
id: webhookId,
type: 'web_hook',
address: publicURL + '/api/v1/notification/' + encodeURIComponent(fullCalendarId),
token: process.env.GOOGLE_WEBHOOK_SECRET,
//expiration: 86400, // 24 hours
},
singleEvents: true,
showDeleted: true,
timeMin: new Date().toISOString(),
timeMax: myCalendar.sync_token_expires_at.toISOString(),
});
console.log('webhook', webhook);
console.log('webhook', webhook.data);
const webhookResourceId = webhook.data.resourceId;
let webhookExpiration;
if (Number(webhook.data.expiration) > 0) {
webhookExpiration = new Date(Number(webhook.data.expiration));
} else {
webhookExpiration = new Date(new Date().getTime() + 86400 * 1000); // 24 hours
}
// update webhook channel id and expiration
await conn.query('UPDATE `calendar_ids` SET `webhook_channel_id` = ?, `webhook_resource_id` = ?, `webhook_expires_at` = ? WHERE `calendar_id` = ?', [
webhookId,
webhookResourceId,
webhookExpiration,
fullCalendarId,
]);
myCalendar.webhook_channel_id = webhookId;
myCalendar.webhook_resource_id = webhookResourceId || undefined;
myCalendar.webhook_expires_at = webhookExpiration;
userLogger.info(user.user_id, '_subscribeToWebhook()', 'Subscribed to webhook for calendar ' + fullCalendarId, 'with webhook id', webhookId);
resolve(myCalendar);
} catch (error) {
reject(error);
}
});
}
async function _checkifAccessTokenIsValid(_myCalendar: CalendarIDRow, user: User, calendarId: string, fullCalendarId: string, conn: PoolConnection): Promise<CalendarIDRow> {
return new Promise(async (resolve, reject) => {
let myCalendar: CalendarIDRow = _myCalendar;
try {
// this can be used because when user.settings.calendar_using_primary_calendar is changed, the backend won't recognize it until the next sync
/*if (myCalendar.type === 'primary' && user.settings.calendar_using_primary_calendar !== true) {
throw new Error('Primary calendar is not used');
}*/
await getAccessToken(user.user_id);
resolve(myCalendar);
} catch (error) {
try {
// set `is_disabled` to 1 in `calendar_ids` table
await conn.query('UPDATE `calendar_ids` SET `is_disabled` = 1 WHERE `calendar_id` = ?', [fullCalendarId]);
myCalendar.isDisabled = true;
myCalendar.isChanged = true;
resolve(myCalendar);
} catch (error) {
reject(error);
}
}
});
}
async function enableCalendarSync(calendarId: string) {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('UPDATE `calendar_ids` SET `is_disabled` = NULL WHERE `calendar_id` = ?', [calendarId]);
resolve(rows);
} catch (error) {
reject(error);
} finally {
if (conn) {
conn.end();
}
}
});
}
async function syncCalendar(_myCalendar: CalendarIDRow, waitForLock: boolean = true) {
return new Promise(async (resolve, reject) => {
let myCalendar = { ..._myCalendar };
const redisLockKey = MyRedisKeys.calendarSyncLock(myCalendar.calendar_id);
const redisLockKeyTTL = 2 * 60; // 2 minutes
let isRedisLocked = null;
function lock() {
return redisClient.set(redisLockKey, 'true', { EX: redisLockKeyTTL, NX: true });
}
function refreshLock() {
return redisClient.expire(redisLockKey, redisLockKeyTTL);
}
try {
isRedisLocked = await lock();
} catch (error) {
console.error(error);
reject(error);
return;
}
// if lock is not set
if (isRedisLocked === null) {
if (waitForLock) {
while (isRedisLocked === null) {
// Wait for a short period before trying again
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second
// Try to acquire the lock again
isRedisLocked = await lock();
}
} else {
reject('Lock is already set');
return;
}
}
let conn;
try {
conn = await getConnection();
let calendarId = myCalendar.calendar_id;
let fullCalendarId = myCalendar.calendar_id;
let userId = myCalendar.user_id;
const user = await getUser(myCalendar.user_id);
const isPrimary = myCalendar.type === 'primary';
if (isPrimary) {
calendarId = fullCalendarId.split('#')[0];
userId = fullCalendarId.split('#')[1];
}
try {
myCalendar = await _checkifAccessTokenIsValid(myCalendar, user, calendarId, fullCalendarId, conn);
if (myCalendar.isDisabled !== true) {
myCalendar = await _updateSyncToken(myCalendar, user, calendarId, fullCalendarId, conn, refreshLock);
myCalendar = await _updateEvents(myCalendar, user, calendarId, fullCalendarId, conn, refreshLock);
myCalendar = await _subscribeToWebhook(myCalendar, user, calendarId, fullCalendarId, conn, refreshLock);
}
} catch (error) {
console.error(error);
if ((error as any).code === 410) {
// 410 is the code for the sync token being invalid
myCalendar.sync_token = undefined;
myCalendar.sync_token_expires_at = undefined;
myCalendar.next_sync = undefined;
myCalendar.isChanged = true;
await conn.query('UPDATE `calendar_ids` SET `sync_token` = NULL, `sync_token_expires_at` = NULL, `next_sync` = NULL WHERE `calendar_id` = ?', [fullCalendarId]);
userLogger.error(user.user_id, 'syncCalendar() error 410', 'Sync token is invalid');
} else if ((error as any).code === 401) {
// 401 is the code for the access token being invalid
await conn.query('UPDATE `calendar_ids` SET `is_disabled` = 1 WHERE `calendar_id` = ?', [fullCalendarId]);
myCalendar.isDisabled = true;
myCalendar.isChanged = true;
userLogger.error(user.user_id, 'syncCalendar() error 401', 'Access token is invalid');
// set `next_sync` = NULL for all calendars of this user
await conn.query('UPDATE `calendar_ids` SET `next_sync` = NULL WHERE `user_id` = ?', [user.user_id]);
if (user.isOwner) {
if (myCalendar.type === 'openHours') {
await GoogleTokensDatabase.updateGoogleAccountStatus(user.user_id, 'NOPERM');
storeLogger.error(user.store_id, 'syncCalendar() error 401', 'Access token is invalid', "set store status to 'NOPERM'");
}
} else {
await GoogleTokensDatabase.updateGoogleAccountStatus(user.user_id, 'NOPERM');
storeLogger.error(user.store_id, 'syncCalendar() error 401', 'Access token is invalid', "set store status to 'NOPERM'");
}
} else {
let errorCode = (error as any).code !== undefined && (error as any).code !== null ? (error as any).code : 'unknown';
userLogger.error(user.user_id, 'syncCalendar() error', errorCode, (error as Error).toString());
}
}
if (myCalendar.isChanged === true) {
// clear whole redis cache of this store
await clearRedisCache(user.store_id);
// update open hours
if (myCalendar.type === 'openHours') {
await forceGetFreshOpenHours(user.store_id);
}
setTimeout(() => {
fillCache(user.store_id);
}, 500);
}
resolve(myCalendar);
} catch (error) {
userLogger.error(myCalendar.user_id, 'syncCalendar()', 'Error', JSON.stringify(error, null, 2));
console.log('syncCalendar()');
console.error(error);
reject(error);
} finally {
if (conn) {
conn.end();
}
// remove lock
await redisClient.del(redisLockKey);
}
});
}
async function scheduleCalendarSync(fullcalendarId: string, channel_id: string) {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
let rows = await conn.query('SELECT * FROM `calendar_ids` WHERE `calendar_id` = ?', [fullcalendarId]);
if (rows.length === 0) {
reject('Calendar not found');
return;
}
const row = rows[0];
if (row.webhook_channel_id !== channel_id) {
resolve(false);
return;
}
const next_sync = new Date(new Date().getTime() + 1000 * GLOBAL_google_webhook_time_delay);
await conn.query('UPDATE `calendar_ids` SET `next_sync` = ? WHERE `calendar_id` = ?', [next_sync, fullcalendarId]);
resolve(true);
} catch (error) {
reject(error);
} finally {
if (conn) {
conn.end();
}
}
});
}
export default syncCalendar;
export { refreshSyncToken, scheduleCalendarSync, enableCalendarSync };

View File

@ -0,0 +1,50 @@
// External libraries
import { calendar_v3, google } from 'googleapis';
// Configuration
import { googleSyncMaxDays } from '../config';
// Redis related imports
import redisClient from '../redis/init';
import MyRedisKeys from '../redis/keys';
// Database related imports
import { getConnection } from '../database/initDatabase';
import { getUser } from '../database/user';
// Calendar related imports
import { getAccessToken, getOAuthClient } from '../calendar/oauthAccess';
// Event related imports
import { insertEvent } from './misc';
import { refreshOpenHours } from './openHours';
// Request counter
import countGoogleRequestStatUp from '../requestCounter';
import syncCalendar from './refreshSync';
async function updateEvents() {
let conn;
try {
conn = await getConnection();
const currentTime = new Date();
const rows = await conn.query('SELECT * FROM `calendar_ids` WHERE `is_disabled` IS NULL AND `sync_token` IS NOT NULL AND (`next_sync` < ? OR `next_sync` IS NULL) ORDER BY RAND() LIMIT 1', [
currentTime,
]);
conn.end();
if (rows.length >= 1) {
const row = rows[0];
await syncCalendar(row, false);
}
} catch (error) {
console.error(error);
} finally {
if (conn) conn.end();
}
}
export default updateEvents;

View File

@ -0,0 +1,56 @@
// External libraries
import { calendar_v3, google } from 'googleapis';
// Configuration
import { googleSyncMaxDays } from '../config';
// Redis related imports
import redisClient from '../redis/init';
import MyRedisKeys from '../redis/keys';
// Database related imports
import { getConnection } from '../database/initDatabase';
import { getUser } from '../database/user';
// Calendar related imports
import { getAccessToken, getOAuthClient } from '../calendar/oauthAccess';
// Event related imports
import { insertEvent } from './misc';
import { refreshOpenHours } from './openHours';
// Request counter
import countGoogleRequestStatUp from '../requestCounter';
import syncCalendar from './refreshSync';
async function updateWebhooks() {
let conn;
try {
conn = await getConnection();
const currentTime = new Date(new Date().getTime() + 1000 * 60 * 30); // when it expires in 30 minutes
// sync_token !== null AND (webhook_channel_id === NULL OR webhook_expires_at < new Date())
const rows = await conn.query(
'SELECT * FROM `calendar_ids` WHERE `is_disabled` IS NULL AND `sync_token` IS NOT NULL AND (`webhook_channel_id` IS NULL OR `webhook_expires_at` < ?) ORDER BY RAND() LIMIT 1',
[currentTime]
);
conn.end();
if (rows.length >= 1) {
const row = rows[0];
await syncCalendar(row, false);
// wait 2 seconds
await new Promise((resolve) => setTimeout(resolve, 2000));
await updateWebhooks();
}
} catch (error) {
console.error(error);
} finally {
if (conn) conn.end();
}
}
export default updateWebhooks;

47
src/captcha.ts Normal file
View File

@ -0,0 +1,47 @@
import e from 'express';
// crash if no recaptcha keys are set
if (!process.env.RECAPTCHA_SITE_KEY || !process.env.RECAPTCHA_SECRET_KEY) {
throw new Error('no recaptcha keys found!');
}
const encodedSecretKey = encodeURIComponent(process.env.RECAPTCHA_SECRET_KEY);
async function verifyCaptcha(token: string, ip?: string): Promise<boolean> {
return new Promise(async (resolve, reject) => {
console.log('verifying captcha token from ip:', ip);
if (typeof token !== 'string' || token.trim() === '') {
reject('Invalid token');
return;
}
if (!encodedSecretKey) {
reject('captcha secret not set');
return;
}
let url;
if (!ip) {
url = 'https://www.google.com/recaptcha/api/siteverify?secret=' + encodedSecretKey + '&response=' + encodeURIComponent(token);
} else {
url = 'https://www.google.com/recaptcha/api/siteverify?secret=' + encodedSecretKey + '&response=' + encodeURIComponent(token) + '&remoteip=' + ip;
}
try {
const res = await fetch(url, { method: 'POST' });
const json = await res.json();
if (json.success) {
resolve(true);
} else {
reject('captcha failed');
}
} catch (error) {
reject('Error verifying captcha');
console.error('Error verifying captcha:', error);
}
});
}
export default verifyCaptcha;

69
src/config.ts Normal file
View File

@ -0,0 +1,69 @@
import path from 'path';
import { StoreSettings } from './database/store';
import dotenv from 'dotenv';
dotenv.config();
const webPort = process.env.BACKEND_PORT;
const webHost = process.env.BACKEND_HOST;
const publicFolder = path.resolve(__dirname + '/../public/');
const clientId = process.env.GOOGLE_CLIENT_ID; // Google API client ID
const clientSecret = process.env.GOOGLE_CLIENT_SECRET; // Google API client secret
const domain = process.env.DOMAIN;
const cacheEventsTime = 1000 * 60 * 10; // 10 minutes
const googleSyncMaxDays = Number(process.env.GOOGLE_SYNC_MAX_DAYS);
const defaultPrimaryCalendarId = 'primary';
const databaseCredentials = {
host: process.env.MARIADB_HOST,
port: Number(process.env.MARIADB_PORT),
user: process.env.MARIADB_USERNAME,
database: process.env.MARIADB_DATABASE,
password: process.env.MARIADB_PASSWORD,
};
const GLOBAL_calendar_min_earliest_booking_time = 15; // because of email verification
const defaultUserSettings: StoreSettings = {
calendar_max_future_booking_days: 7,
calendar_min_earliest_booking_time: 0, // in minutes (+ GLOBAL_calendar_min_earliest_booking_time)
calendar_using_primary_calendar: true,
};
const REDIS_URL = 'redis://' + process.env.REDIS_HOST + ':' + process.env.REDIS_PORT;
const publicURL = process.env.PUBLIC_URL;
const GLOBAL_sync_calendar_every_minutes = Number(process.env.SYNC_CALENDAR_EVERY_MINUTES) || 360; // 6 hours
const GLOBAL_google_webhook_time_delay = Number(process.env.GOOGLE_WEBHOOK_TIME_DELAY_SECONDS) || 5; // 5 seconds
let notifyBeforeAppointment: number[] = [1440, 60]; // in minutes // the order is important! -> index 0 is the first notification, last index is the last notification
//sort notifyBeforeAppointment
notifyBeforeAppointment.sort((a, b) => b - a);
export {
webPort,
webHost,
defaultPrimaryCalendarId,
publicFolder,
clientId,
clientSecret,
databaseCredentials,
defaultUserSettings,
cacheEventsTime,
GLOBAL_calendar_min_earliest_booking_time,
GLOBAL_sync_calendar_every_minutes,
GLOBAL_google_webhook_time_delay,
domain,
googleSyncMaxDays,
REDIS_URL,
publicURL,
notifyBeforeAppointment,
};

View File

@ -0,0 +1,252 @@
import { User } from '../user/types';
import { getConnection } from './initDatabase';
import { getStoreByUser } from './store';
type CalendarType = 'openHours' | 'myEvents' | 'workerEvents' | 'primary';
async function addCalendar(user_id: string, calendar_id: string, type: CalendarType, worker_user_id?: string) {
return new Promise(async (resolve, reject) => {
const _worker_user_id = worker_user_id ? worker_user_id : null;
if (type == 'workerEvents' && _worker_user_id == null) {
reject('worker_user_id must be set for type workerEvents');
return;
}
let conn;
try {
conn = await getConnection();
const rows = await conn.query(
`
INSERT INTO calendar_ids (user_id, calendar_id, type, worker_user_id)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
calendar_id = VALUES(calendar_id),
type = VALUES(type),
worker_user_id = VALUES(worker_user_id),
sync_token = NULL,
is_disabled = NULL
`,
[user_id, calendar_id, type, _worker_user_id]
);
console.log(rows);
resolve(rows);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
type Calendar = {
user_id: string;
calendar_id: string;
type: CalendarType;
worker_user_id?: string; // only for workerEvents
};
async function getCalendars(user_id: string): Promise<Calendar[]> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT * FROM calendar_ids WHERE user_id=?', [user_id]);
let calendars: Calendar[] = [];
for (let i = 0; i < rows.length; i++) {
const calendar: Calendar = rows[i];
if (calendar.user_id != user_id) {
console.error('user_id does not match');
continue;
}
const type = calendar.type;
if (type === 'workerEvents' && calendar.worker_user_id === null) {
console.error('worker_user_id must be set for type workerEvents. calendar.calendar_id:', calendar.calendar_id);
continue;
}
if (type != 'workerEvents' && type != 'openHours' && type != 'myEvents' && type != 'primary') {
console.error('invalid type "' + type + '" calendar.calendar_id:', calendar.calendar_id);
continue;
}
calendars.push(calendar);
}
resolve(calendars);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getPrimaryCalendar(user_id: string): Promise<Calendar> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT calendar_id FROM calendar_ids WHERE user_id=? AND type="primary" AND is_disabled IS NULL', [user_id]);
if (rows.length >= 1) {
let calendar: Calendar = rows[0];
//calendar.calendar_id = calendar.calendar_id.split('#')[0];
resolve(calendar);
} else {
reject('no calendar found');
}
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function deleteCalendar(user: User, calendar_id: string) {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
let rows;
if (user.isOwner === true) {
await clearCalendarDatabase(calendar_id);
rows = await conn.query('DELETE FROM calendar_ids WHERE user_id=? AND calendar_id=?', [user.user_id, calendar_id]);
} else {
const store = await getStoreByUser(user);
const owner_user_id = store.owner_user_id;
await clearCalendarDatabase(calendar_id);
rows = await conn.query('DELETE FROM calendar_ids WHERE user_id=? AND calendar_id=? AND worker_user_id=?', [owner_user_id, calendar_id, user.user_id]);
}
resolve(rows);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function clearCalendarDatabase(calendar_id: string) {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
await conn.query('DELETE FROM calendar_events WHERE calendar_id=?', [calendar_id]);
resolve(undefined);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getOpenHoursCalendar(user_id: string): Promise<string> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT calendar_id FROM calendar_ids WHERE user_id=? AND type="openHours"', [user_id]);
if (rows.length >= 1) {
resolve(rows[0].calendar_id);
} else {
reject('no calendar found');
}
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getEventCalendarID(user: User): Promise<string> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
let rows;
if (user.isOwner === true) rows = await conn.query('SELECT calendar_id FROM calendar_ids WHERE user_id=? AND type="myEvents" AND is_disabled IS NULL', [user.user_id]);
else {
let store = await getStoreByUser(user);
if (!store) {
reject('no store found');
return;
}
let owner_user_id = store.owner_user_id;
rows = await conn.query('SELECT calendar_id FROM calendar_ids WHERE user_id=? AND type="workerEvents" AND worker_user_id=? AND is_disabled IS NULL', [owner_user_id, user.user_id]);
}
if (rows.length >= 1) {
resolve(rows[0].calendar_id);
} else {
reject('no calendar found');
}
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function checkIfCalendarExists(calendarId: string): Promise<boolean> {
return new Promise<boolean>(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT * FROM `calendar_ids` WHERE `calendar_id` = ? AND `is_disabled` IS NULL', [calendarId]);
if (rows.length >= 1) {
resolve(true);
} else {
resolve(false);
}
} catch (error) {
console.log('checkIfCalendarExists error', error);
reject(error);
} finally {
if (conn) {
conn.end();
}
}
});
}
const calendar_ids = {
addCalendar,
getCalendars,
deleteCalendar,
getOpenHoursCalendar,
getEventCalendarID,
getPrimaryCalendar,
checkIfCalendarExists,
clearCalendarDatabase,
};
export default calendar_ids;
export type { CalendarType };

View File

@ -0,0 +1,28 @@
//mariadb
import mariadb from 'mariadb';
// config
import { databaseCredentials } from '../config';
// init
const pool = mariadb.createPool({
host: databaseCredentials.host,
port: databaseCredentials.port,
user: databaseCredentials.user,
database: databaseCredentials.database,
password: databaseCredentials.password,
connectionLimit: 20,
});
async function getConnection() {
let conn;
try {
conn = await pool.getConnection();
} catch (err) {
console.log(err);
throw err;
}
return conn;
}
export default pool;
export { getConnection };

View File

@ -0,0 +1,372 @@
import { User } from '../user/types';
import { getConnection } from './initDatabase';
import { Store } from './store';
import { GLOBAL_calendar_min_earliest_booking_time, domain } from '../config';
import { deleteEvent, insertEvent } from '../calendar/insertEvent';
import calendar_ids from './calendar_ids';
import { getUser } from './user';
import sendAppointmentCanceledMail from '../mail/verifyFail';
import { sendMailIsVerifiedMail, sendNewAppointmentForEmployeeMail } from '../mail/verify';
import generateRandomString from '../randomString';
import clearRedisCache from '../redis/clearCache';
import { getActivity } from './services';
type CustomerID = string;
interface CalendarEvent {
store_id: string;
user_id: string;
activity_id: string;
calendar_event_id?: string;
time_start: Date;
time_end: Date;
customer_email: string;
customer_name: string;
customer_message?: string;
customer_id: CustomerID;
customer_verified_time?: Date;
send_next_notification_at?: Date;
created_at: Date;
}
interface PublicCalendarEvent {
username: string;
activity_name: string;
time_start: Date;
time_end: Date;
isVerified: boolean;
}
async function createEvent(
store: Store,
user: User,
activity_id: string,
time_start: Date,
time_end: Date,
customer_email: string,
customer_name: string,
customer_message?: string
): Promise<CalendarEvent> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const created_at = new Date();
// create a uuid for the customer email
const customer_uuid = await generateRandomString(128);
let calendarEvent: CalendarEvent = {
store_id: store.store_id,
user_id: user.user_id,
activity_id: activity_id,
time_start: time_start,
time_end: time_end,
customer_email: customer_email,
customer_name: customer_name,
customer_message: customer_message,
customer_id: customer_uuid,
created_at: created_at,
};
const insertedEvent = await insertEvent(calendarEvent);
calendarEvent = insertedEvent.event;
const rows = await conn.query(
`INSERT INTO calendar_customer
(store_id, name, email, message, customer_id, calendar_event_id, user_id, activity_id, time_start, time_end, created_at)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[store.store_id, customer_name, customer_email, customer_message, customer_uuid, calendarEvent.calendar_event_id, user.user_id, activity_id, time_start, time_end, created_at]
);
resolve(calendarEvent);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getEvent(customer_id: CustomerID): Promise<CalendarEvent | undefined> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query(`SELECT * FROM calendar_customer WHERE customer_id=?`, [customer_id]);
if (rows.length < 1) {
resolve(undefined);
return;
}
const row = rows[0];
const calendarEvent: CalendarEvent = {
store_id: row.store_id,
user_id: row.user_id,
activity_id: row.activity_id,
time_start: row.time_start,
time_end: row.time_end,
customer_email: row.email,
calendar_event_id: row.calendar_event_id,
customer_name: row.name,
customer_message: row.message,
customer_id: row.customer_id,
customer_verified_time: row.verified_time,
created_at: row.created_at,
};
resolve(calendarEvent);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getPublicEvent(customer_id: CustomerID): Promise<PublicCalendarEvent | undefined> {
return new Promise(async (resolve, reject) => {
let calendarEvent = await getEvent(customer_id);
if (calendarEvent === undefined) {
resolve(undefined);
return;
}
let username = 'unknown';
let activity_name = 'unknown';
try {
let user = await getUser(calendarEvent.user_id);
username = user.username;
} catch (error) {}
try {
let activity = await getActivity(calendarEvent.store_id, calendarEvent.activity_id, false);
activity_name = activity.name;
} catch (error) {}
let publicCalendarEvent: PublicCalendarEvent = {
username: username,
activity_name: activity_name,
time_start: calendarEvent.time_start,
time_end: calendarEvent.time_end,
isVerified: calendarEvent.customer_verified_time !== undefined && calendarEvent.customer_verified_time !== null,
};
resolve(publicCalendarEvent);
});
}
async function verifyEvent(customer_id: CustomerID): Promise<CalendarEvent | undefined> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const verified_time = new Date();
let isNewVerify = false;
// also select the event
const calendarEventRows = await conn.query(`SELECT * FROM calendar_customer WHERE customer_id=?`, [customer_id]);
if (calendarEventRows.length != 1) {
resolve(undefined);
return;
}
const calendarEventRow = calendarEventRows[0];
if (calendarEventRow.verified_time === null || calendarEventRow.verified_time === undefined) {
const rows = await conn.query(`UPDATE calendar_customer SET verified_time=? WHERE customer_id=?`, [verified_time, customer_id]);
isNewVerify = true;
}
const calendarEvent: CalendarEvent = {
store_id: calendarEventRow.store_id,
user_id: calendarEventRow.user_id,
activity_id: calendarEventRow.activity_id,
time_start: calendarEventRow.time_start,
time_end: calendarEventRow.time_end,
customer_email: calendarEventRow.email,
calendar_event_id: calendarEventRow.calendar_event_id,
customer_name: calendarEventRow.name,
customer_message: calendarEventRow.message,
customer_id: calendarEventRow.customer_id,
customer_verified_time: calendarEventRow.verified_time,
created_at: calendarEventRow.created_at,
};
resolve(calendarEvent);
try {
if (isNewVerify === true) {
await sendMailIsVerifiedMail(calendarEvent);
}
} catch (error) {
console.log('error sending sendMailIsVerifiedMail');
console.error(error);
}
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function cancelEvent(customer_id: CustomerID): Promise<CalendarEvent | undefined> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
// also select the event
const calendarEventRows = await conn.query(`SELECT * FROM calendar_customer WHERE customer_id=?`, [customer_id]);
console.log('calendarEventRows', calendarEventRows);
if (calendarEventRows.length != 1) {
resolve(undefined);
return;
}
const calendarEventRow = calendarEventRows[0];
const calendarEvent: CalendarEvent = {
store_id: calendarEventRow.store_id,
user_id: calendarEventRow.user_id,
activity_id: calendarEventRow.activity_id,
time_start: calendarEventRow.time_start,
time_end: calendarEventRow.time_end,
customer_email: calendarEventRow.email,
calendar_event_id: calendarEventRow.calendar_event_id,
customer_name: calendarEventRow.name,
customer_message: calendarEventRow.message,
customer_id: calendarEventRow.customer_id,
customer_verified_time: calendarEventRow.verified_time,
created_at: calendarEventRow.created_at,
};
await deleteEvent(calendarEvent, true);
await sendAppointmentCanceledMail(calendarEvent);
await conn.query(`DELETE FROM calendar_customer WHERE customer_id=?`, [customer_id]);
resolve(calendarEvent);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
function deleteAllThatIsUnverified(): Promise<void> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const dateTimeout = new Date().getTime() - GLOBAL_calendar_min_earliest_booking_time * 60 * 1000;
const rows = await conn.query(`SELECT * FROM calendar_customer WHERE verified_time IS NULL AND created_at < ?`, [new Date(dateTimeout)]);
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const calendarEvent: CalendarEvent = {
store_id: row.store_id,
user_id: row.user_id,
activity_id: row.activity_id,
time_start: row.time_start,
time_end: row.time_end,
customer_email: row.email,
calendar_event_id: row.calendar_event_id,
customer_name: row.name,
customer_message: row.message,
customer_id: row.customer_id,
customer_verified_time: row.verified_time,
created_at: row.created_at,
};
try {
if (calendarEvent.calendar_event_id !== undefined) {
await deleteEvent(calendarEvent, true);
}
await sendAppointmentCanceledMail(calendarEvent);
await conn.query(`DELETE FROM calendar_customer WHERE customer_id=?`, [row.customer_id]);
} catch (error) {
console.log('error deleting unverified event');
console.error(error);
}
}
resolve();
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
function generateEMailVerificationLink(customer_id: CustomerID): string {
return process.env.REACT_STATUS_URL + '/?id=' + encodeURIComponent(customer_id);
}
function generateEMailCancelLink(customer_id: CustomerID): string {
return process.env.REACT_STATUS_URL + '/?id=' + encodeURIComponent(customer_id) + '&cancel=true';
}
function generateEMailCancelNotificationLink(customer_id: CustomerID): string {
return process.env.REACT_STATUS_URL + '/?id=' + encodeURIComponent(customer_id) + '&notify=false';
}
setInterval(deleteAllThatIsUnverified, 1000 * 60); // every minute
deleteAllThatIsUnverified();
const MyEvents = {
createEvent: createEvent,
verifyEvent: verifyEvent,
cancelEvent: cancelEvent,
generateEMailVerificationLink: generateEMailVerificationLink,
generateEMailCancelLink: generateEMailCancelLink,
generateEMailCancelNotificationLink: generateEMailCancelNotificationLink,
getEvent: getEvent,
getPublicEvent: getPublicEvent,
};
export { CalendarEvent };
export default MyEvents;
/*
CREATE TABLE `calendar_customer` (
`store_id` varchar(255) NOT NULL,
`name` varchar(64) NOT NULL,
`email` varchar(255) NOT NULL,
`message` varchar(1000) DEFAULT NULL,
`customer_id` varchar(64) NOT NULL PRIMARY KEY,
`calendar_event_id` varchar(255) NOT NULL,
`user_id` varchar(255) NOT NULL,
`activity_id` varchar(255) NOT NULL,
`time_start` datetime NOT NULL,
`time_end` datetime NOT NULL,
`verified_time` datetime DEFAULT NULL,
`created_at` datetime NOT NULL
)
*/

161
src/database/services.ts Normal file
View File

@ -0,0 +1,161 @@
import { defaultUserSettings } from '../config';
import { User, UserID } from '../user/types';
import { getConnection } from './initDatabase';
import { StoreID } from './store';
import { getAllUsersIDsAndNamesByStoreID, getAllUsersIDsByStoreID } from './user';
type ServicesTypeId = string;
type ActivityTypeId = string;
interface ServicesType {
id: ServicesTypeId;
name: string;
activities: ActivityType[];
}
type ActivityUsersType = UserID[];
interface ActivityType {
id: ActivityTypeId;
name: string;
description: string;
price: number;
duration: number;
users: ActivityUsersType;
}
// displayMinimal: if all employees are assinged to the activity, the users object will be empty
async function getUserIDsByActivityID(store_id: StoreID, activity_id: ActivityTypeId, displayMinimal: boolean): Promise<ActivityUsersType> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
let userIDs: ActivityUsersType = [];
let userRows: { user_id: UserID }[] = await conn.query('SELECT user_id FROM store_service_activity_users WHERE activity_id=?', [activity_id]);
let allUserIDs: ActivityUsersType = await getAllUsersIDsByStoreID(store_id);
if (displayMinimal === true) {
if (!userRows || userRows.length === 0) userIDs = [];
else userIDs = userRows.map((row) => row.user_id);
} else {
if (!userRows || userRows.length === 0) userIDs = allUserIDs;
else userIDs = userRows.map((row) => row.user_id);
}
resolve(userIDs);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
// displayMinimal: if all employees are assinged to the activity, the users object will be empty
async function getServices(store_id: StoreID, displayMinimal: boolean): Promise<ServicesType[]> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT * FROM store_services WHERE store_id=?', [store_id]);
let services: ServicesType[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.store_id != store_id) {
console.error('store_id does not match');
continue;
}
const service: ServicesType = {
id: row.service_id,
name: row.name,
activities: [],
};
const activityRows = await conn.query('SELECT * FROM store_service_activities WHERE service_id=?', [service.id]);
for (let j = 0; j < activityRows.length; j++) {
const activityRow = activityRows[j];
if (activityRow.services_id != row.id) {
console.error('services_id does not match');
continue;
}
let users: ActivityUsersType = await getUserIDsByActivityID(store_id, activityRow.activity_id, displayMinimal);
const activity: ActivityType = {
id: activityRow.activity_id,
name: activityRow.name,
description: activityRow.description,
price: activityRow.price,
duration: activityRow.duration,
users: users,
};
service.activities.push(activity);
}
services.push(service);
}
resolve(services);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getActivity(store_id: StoreID, activity_id: ActivityTypeId, displayMinimal: boolean): Promise<ActivityType> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT * FROM store_service_activities WHERE activity_id=?', [activity_id]);
let activity: ActivityType;
if (rows.length >= 1) {
const row = rows[0];
if (row.activity_id != activity_id) {
reject('activity_id does not match');
return;
}
let users: ActivityUsersType = await getUserIDsByActivityID(store_id, activity_id, displayMinimal);
activity = {
id: row.activity_id,
name: row.name,
description: row.description,
price: row.price,
duration: row.duration,
users: users,
};
} else {
reject('no activity found');
return;
}
resolve(activity);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
export { getServices, getActivity, ServicesType, ActivityType, ServicesTypeId, ActivityTypeId };

119
src/database/store.ts Normal file
View File

@ -0,0 +1,119 @@
import { Connection } from 'mariadb';
import { getConnection } from './initDatabase';
import { defaultUserSettings } from '../config';
import { User } from '../user/types';
import redisClient from '../redis/init';
import MyRedisKeys from '../redis/keys';
interface StoreSettings {
calendar_max_future_booking_days: number;
calendar_min_earliest_booking_time: number;
calendar_using_primary_calendar: boolean;
}
const storeSettingsKeys: (keyof StoreSettings)[] = ['calendar_max_future_booking_days', 'calendar_min_earliest_booking_time', 'calendar_using_primary_calendar'];
type StoreID = string;
interface Store {
store_id: StoreID;
owner_user_id: string;
name: string;
address: string;
settings: StoreSettings;
}
async function getStore(store_id: string): Promise<Store> {
try {
//check if user is in redis
let redisStore = await redisClient.get(MyRedisKeys.storeCache(store_id));
if (redisStore) {
let store: Store = JSON.parse(redisStore);
return store;
}
} catch (error) {}
let conn: Connection | undefined;
try {
conn = await getConnection();
const rows = await conn.query('SELECT * FROM stores WHERE store_id = ?', [store_id]);
if (rows.length != 1) throw new Error('invalid store_id, ' + store_id);
const row = rows[0];
let _settings: any = {};
for (const key of storeSettingsKeys) {
_settings[key] = row[key];
}
let settings: StoreSettings = { ...defaultUserSettings };
for (const _key in settings) {
const key = _key as keyof StoreSettings;
if (_settings[key]) {
(settings as any)[key] = _settings[key];
}
}
let store: Store = {
store_id: row.store_id,
owner_user_id: row.owner_user_id,
name: row.name,
address: row.address,
settings,
};
try {
await redisClient.set(MyRedisKeys.storeCache(store_id), JSON.stringify(store), { EX: 1 * 60 });
} catch (error) {
console.log(error);
}
return store;
} catch (err) {
console.log(err);
throw err;
} finally {
if (conn) conn.end();
}
}
async function getStoreByUser(user: User): Promise<Store> {
return await getStore(user.store_id);
}
async function getAllStoreIDs(): Promise<StoreID[]> {
let conn: Connection | undefined;
try {
conn = await getConnection();
const rows = await conn.query('SELECT `store_id` FROM `stores`');
return rows.map((row: any) => row.store_id);
} catch (err) {
console.log(err);
throw err;
} finally {
if (conn) conn.end();
}
}
export { getStore, Store, StoreSettings, getStoreByUser, getAllStoreIDs, storeSettingsKeys, StoreID };
/*CREATE TABLE `stores` (
`store_id` varchar(255) NOT NULL,
`owner_user_id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`calendar_max_future_booking_days` int(11) NOT NULL,
`calendar_min_earliest_booking_time` int(11) NOT NULL,
`calendar_primary_calendar_id` varchar(255) NOT NULL,
`calendar_using_primary_calendar` tinyint(1) NOT NULL,
`createdAt` datetime NOT NULL,
`updatedAt` datetime NOT NULL
)*/

148
src/database/user.ts Normal file
View File

@ -0,0 +1,148 @@
import { defaultUserSettings } from '../config';
import logger, { userLogger } from '../logger/logger';
import redisClient from '../redis/init';
import MyRedisKeys from '../redis/keys';
import { User, UserID } from '../user/types';
import { getConnection } from './initDatabase';
import { StoreID, getStore, storeSettingsKeys } from './store';
async function getUser(user_id: UserID): Promise<User> {
return new Promise(async (resolve, reject) => {
try {
//check if user is in redis
let redisUser = await redisClient.get(MyRedisKeys.userCache(user_id));
if (redisUser) {
let user: User = JSON.parse(redisUser);
resolve(user);
return;
}
} catch (error) {}
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT * FROM users WHERE user_id=?', [user_id]);
let row;
if (rows.length >= 1) {
row = rows[0];
} else {
reject('no user found ' + user_id);
return;
}
if (row.user_id != user_id) {
reject('user_id does not match');
return;
}
let user: User;
const store = await getStore(row.store_id);
let _settings: any = {};
for (const key of storeSettingsKeys) {
_settings[key] = row[key];
}
let settings: User['settings'] = { ...store.settings }; //{ ...defaultUserSettings };
for (const _key in settings) {
const key = _key as keyof User['settings'];
if (_settings[key] !== undefined && _settings[key] !== null) {
if (key === 'calendar_using_primary_calendar') {
(settings as any)[key] = _settings[key] === 1;
} else {
(settings as any)[key] = _settings[key];
}
}
}
user = {
user_id: row.user_id,
store_id: row.store_id,
username: row.username,
lastUpdate: new Date(),
isOwner: store.owner_user_id == row.user_id,
settings,
email: row.email ? row.email : undefined,
};
//cache user
try {
await redisClient.set(MyRedisKeys.userCache(user_id), JSON.stringify(user), { EX: 1 * 60 });
} catch (error) {}
resolve(user);
} catch (err) {
userLogger.error(user_id, 'getUser', (err as Error).toString());
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getAllUsersIDsByStoreID(store_id: StoreID): Promise<UserID[]> {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT user_id FROM users WHERE store_id=?', [store_id]);
let userIDs: UserID[] = [];
for (const row of rows) {
userIDs.push(row.user_id);
}
return userIDs;
} catch (err) {
console.log(err);
throw err;
} finally {
if (conn) conn.end();
}
}
async function getAllUsersIDsAndNamesByStoreID(store_id: StoreID): Promise<{ [key: UserID]: { name: string } }> {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT user_id, username, FROM users WHERE store_id=?', [store_id]);
let userIDs: { [key: UserID]: { name: string } } = {};
for (const row of rows) {
userIDs[row.user_id] = { name: row.username };
}
return userIDs;
} catch (err) {
console.log(err);
throw err;
} finally {
if (conn) conn.end();
}
}
export { getUser, getAllUsersIDsByStoreID, getAllUsersIDsAndNamesByStoreID };
/*
CREATE TABLE `users` (
`user_id` varchar(255) NOT NULL,
`store_id` varchar(255) NOT NULL,
`account_name` varchar(255) DEFAULT NULL,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`calendar_max_future_booking_days` int(11) DEFAULT NULL,
`calendar_min_earliest_booking_time` int(11) DEFAULT NULL,
`calendar_primary_calendar_id` varchar(255) DEFAULT NULL,
`calendar_using_primary_calendar` tinyint(1) DEFAULT NULL,
`createdAt` datetime NOT NULL,
`updatedAt` datetime NOT NULL
)
*/

View File

@ -0,0 +1,234 @@
import { forceGetFreshOpenHours } from '../calendar/openHoursList';
import { clientId, clientSecret } from '../config';
import clearRedisCache from '../redis/clearCache';
import fillCache from '../redis/fillCache';
import { getConnection } from './initDatabase';
import { OAuth2Client } from 'google-auth-library';
import { getUser } from './user';
import calendar_ids from './calendar_ids';
type GoogleTokenStatus = 'OK' | 'PENDING' | 'ERROR' | 'NOPERM';
async function initGoogleAccount(user_id: string, access_token: string, refresh_token: string, expiry_date: Date, google_uuid: string) {
return new Promise(async (resolve, reject) => {
let alreadySavedTokens = null;
try {
alreadySavedTokens = await getGoogleTokens(user_id);
} catch (error) {}
let conn;
try {
conn = await getConnection();
let rows;
if (!alreadySavedTokens) {
rows = await conn.query('INSERT INTO user_google_tokens (user_id, access_token, refresh_token, status, expiry_date, google_uuid) VALUES (?, ?, ?, ?, ?, ?)', [
user_id,
access_token,
refresh_token,
'PENDING' as GoogleTokenStatus,
expiry_date,
google_uuid,
]);
} else {
rows = await conn.query('UPDATE user_google_tokens SET access_token=?, refresh_token=?, status=?, expiry_date=?, google_uuid=? WHERE user_id=?', [
access_token,
refresh_token,
'PENDING' as GoogleTokenStatus,
expiry_date,
google_uuid,
user_id,
]);
}
resolve(rows);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function updateGoogleAccountStatus(user_id: string, status: GoogleTokenStatus) {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
let rows;
rows = await conn.query('UPDATE user_google_tokens SET status=? WHERE user_id=?', [status, user_id]);
resolve(rows);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function unlinkGoogleAccount(user_id: string) {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
let rows;
try {
let alreadySavedTokens = await getGoogleTokens(user_id);
const oauth2Client = new OAuth2Client({
clientId,
clientSecret,
});
oauth2Client.setCredentials({
access_token: alreadySavedTokens.access_token,
refresh_token: alreadySavedTokens.refresh_token,
//expiry_date: tokens.expiry_date.getTime(),
});
await oauth2Client.revokeToken(alreadySavedTokens.refresh_token);
} catch (error) {}
try {
const rows = await conn.query('SELECT * FROM calendar_ids WHERE user_id=?', [user_id]);
for (let i = 0; i < rows.length; i++) {
const calendar = rows[i];
if (calendar.user_id != user_id) {
console.error('user_id does not match');
continue;
}
try {
await calendar_ids.clearCalendarDatabase(calendar.calendar_id);
} catch (error) {}
}
await conn.query('UPDATE calendar_ids SET is_disabled=1, sync_token=NULL WHERE user_id=?', [user_id]);
const user = await getUser(user_id);
// clear whole redis cache of this store
await clearRedisCache(user.store_id);
// update open hours
await forceGetFreshOpenHours(user.store_id);
setTimeout(() => {
fillCache(user.store_id);
}, 500);
} catch (error) {}
rows = await conn.query('DELETE FROM user_google_tokens WHERE user_id=?', [user_id]);
resolve(rows);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
type GoogleTokens = {
user_id: string;
access_token: string;
refresh_token: string;
expiry_date: Date;
google_uuid: string;
created_at: Date;
updated_at: Date;
};
async function getGoogleTokens(userId: string): Promise<GoogleTokens> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT * FROM user_google_tokens WHERE user_id=?', [userId]);
if (rows.length >= 1) {
const tokens: GoogleTokens = rows[0];
if (tokens.user_id != userId) {
reject('user_id does not match');
return;
}
resolve(tokens);
} else {
reject('no google tokens found for ' + userId);
}
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function updateAccessToken(userId: string, accessToken: string, expire_date: Date) {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('UPDATE user_google_tokens SET access_token=?, expiry_date=? WHERE user_id=?', [accessToken, expire_date, userId]);
console.log(rows); //[ {val: 1}, meta: ... ]
resolve(rows);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
async function getUserIds(): Promise<{ user_id: string }[]> {
return new Promise(async (resolve, reject) => {
let conn;
try {
conn = await getConnection();
const rows = await conn.query('SELECT user_id FROM user_google_tokens');
console.log(rows); //[ {val: 1}, meta: ... ]
resolve(rows);
} catch (err) {
console.log(err);
reject(err);
} finally {
if (conn) conn.end();
}
});
}
const GoogleTokensDatabase = {
initGoogleAccount,
unlinkGoogleAccount,
getGoogleTokens,
updateAccessToken,
getUserIds,
updateGoogleAccountStatus,
};
export default GoogleTokensDatabase;
/* Database structure:
CREATE TABLE `user_google_tokens` (
`user_id` varchar(255) NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`expiry_date` timestamp NOT NULL,
`google_uuid` text NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
)
*/

31
src/lang/default.ts Normal file
View File

@ -0,0 +1,31 @@
const lang = {
calendar: {
openHours: 'Öffnungszeiten',
myEvents: 'Termine',
},
days: {
0: 'Montag',
1: 'Dienstag',
2: 'Mittwoch',
3: 'Donnerstag',
4: 'Freitag',
5: 'Samstag',
6: 'Sonntag',
},
months: {
0: 'Januar',
1: 'Februar',
2: 'März',
3: 'April',
4: 'Mai',
5: 'Juni',
6: 'Juli',
7: 'August',
8: 'September',
9: 'Oktober',
10: 'November',
11: 'Dezember',
},
};
export default lang;

189
src/logger/logger.ts Normal file
View File

@ -0,0 +1,189 @@
import { isBreakOrContinueStatement } from 'typescript';
import winston from 'winston';
import Transport from 'winston-transport';
let cachedLogs = new Map();
export async function initLogHandler() {
while (true) {
if (cachedLogs.size > 0) {
for (let [logType, logs] of cachedLogs) {
const url = process.env.LOG_MANAGER_URL!;
const defaultLogType = process.env.NODE_APP_NAME!;
const body = JSON.stringify({
type: logType === undefined ? defaultLogType : logType,
logs: logs,
});
try {
const response = await fetch(url, {
method: 'POST',
body: body,
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
cachedLogs.delete(logType);
} catch (err) {
console.warn(err);
}
}
}
await new Promise((resolve) => setTimeout(resolve, Number(process.env.LOG_MANAGER_INTERVAL)));
}
}
class MyTransport extends Transport {
log(info: any, callback: any) {
setImmediate(() => this.emit('logged', info));
const currentDate = new Date();
const formattedDate = `${currentDate.getHours()}:${currentDate.getMinutes()}:${currentDate.getSeconds()}:${currentDate.getMilliseconds()}`;
let logLevel;
switch (info['level']) {
case 'info':
logLevel = 'I';
break;
case 'debug':
logLevel = 'D';
break;
case 'error':
logLevel = 'E';
break;
default:
logLevel = 'W';
}
const msgLocal = `${logLevel} ${formattedDate} ${info['message']}`;
const msg = `${logLevel} ${formattedDate} ${info['logType']} ${info['message']}`;
if (cachedLogs.has(info['logType'])) {
cachedLogs.get(info['logType']).push(msgLocal);
// for testing purposes send all logs to NODE_APP_NAME
if (info['logType'] !== process.env.NODE_APP_NAME!) {
cachedLogs.get(process.env.NODE_APP_NAME!).push(msg);
}
} else {
cachedLogs.set(info['logType'], [msgLocal]);
// for testing purposes send all logs to NODE_APP_NAME
if (info['logType'] !== process.env.NODE_APP_NAME!) {
cachedLogs.set(process.env.NODE_APP_NAME!, [msg]);
}
}
/*
const msg = `${logLevel} ${formattedDate} ${info["message"]}`;
if (cachedLogs.has(info["logType"])) {
cachedLogs.get(info["logType"]).push(msg);
// for testing purposes send all logs to NODE_APP_NAME
if (info["logType"] !== process.env.NODE_APP_NAME!) {
cachedLogs.get(process.env.NODE_APP_NAME!).push(msg);
}
} else {
cachedLogs.set(info["logType"], [msg]);
// for testing purposes send all logs to NODE_APP_NAME
if (info["logType"] !== process.env.NODE_APP_NAME!) {
cachedLogs.set(process.env.er!, [msg]);
}
} */
callback();
}
}
const winlogger = winston.createLogger({
level: 'debug',
format: winston.format.json(),
transports: [new MyTransport()],
});
if (process.env.NODE_ENV !== 'production') {
winlogger.add(
new winston.transports.Console({
format: winston.format.simple(),
})
);
}
class BaseLogger {
constructor(private logTypePrefix: string) {}
info(id: string, ...messages: string[]) {
this.log('info', id, ...messages);
}
error(id: string, ...messages: string[]) {
this.log('error', id, ...messages);
}
warn(id: string, ...messages: string[]) {
this.log('warn', id, ...messages);
}
debug(id: string, ...messages: string[]) {
this.log('debug', id, ...messages);
}
private log(level: string, id: string, ...messages: string[]) {
winlogger.log({
level: level,
logType: `${this.logTypePrefix}-${id}`,
message: messages.join(' '),
});
}
}
class UserLogger extends BaseLogger {
constructor() {
super('user');
}
}
class StoreLogger extends BaseLogger {
constructor() {
super('store');
}
}
class SystemLogger {
info(...messages: string[]) {
this.log('info', ...messages);
}
error(...messages: string[]) {
this.log('error', ...messages);
}
warn(...messages: string[]) {
this.log('warn', ...messages);
}
debug(...messages: string[]) {
this.log('debug', ...messages);
}
private log(level: string, ...messages: string[]) {
winlogger.log({
level: level,
logType: process.env.NODE_APP_NAME,
message: messages.join(' '),
});
}
}
export const userLogger = new UserLogger();
export const storeLogger = new StoreLogger();
export const logger = new SystemLogger();
export default logger;

54
src/mail/dateText.ts Normal file
View File

@ -0,0 +1,54 @@
import { CalendarEvent } from '../database/reservedCustomer';
import { getActivity } from '../database/services';
import { getUser } from '../database/user';
interface DateText {
activityName: string;
username: string;
day: string;
dayNumber: string;
month: string;
year: string;
startTime: string;
endTime: string;
}
async function getDateText(event: CalendarEvent): Promise<DateText> {
return new Promise(async (resolve, reject) => {
try {
const locale = 'de-DE';
let activityName = 'unknown';
try {
const activity = await getActivity(event.store_id, event.activity_id, false);
activityName = activity.name;
} catch (error) {}
const user = await getUser(event.user_id);
const username = user.username;
const day = event.time_start.toLocaleDateString(locale, { weekday: 'long' });
const dayNumber = event.time_start.getDate().toString();
const month = event.time_start.toLocaleDateString(locale, { month: 'long' });
const year = event.time_start.getFullYear().toString();
const startTime = event.time_start.toLocaleTimeString(locale, { hour: 'numeric', minute: 'numeric' });
const endTime = event.time_end.toLocaleTimeString(locale, { hour: 'numeric', minute: 'numeric' });
const dateText: DateText = {
activityName: activityName,
username: username,
day: day,
dayNumber: dayNumber,
month: month,
year: year,
startTime: startTime,
endTime: endTime,
};
resolve(dateText);
} catch (err) {
reject(err);
}
});
}
export { DateText, getDateText };

11
src/mail/getConnection.ts Normal file
View File

@ -0,0 +1,11 @@
import dotenv from 'dotenv';
import amqplib from 'amqplib';
dotenv.config();
async function getConnection() {
const conn = await amqplib.connect(`amqp://${process.env.RABBITMQ_USERNAME}:${process.env.RABBITMQ_PASSWORD}@${process.env.RABBITMQ_HOST}:${process.env.RABBITMQ_PORT}`);
return conn;
}
export default getConnection;

View File

@ -0,0 +1,291 @@
import { PoolConnection } from 'mariadb';
import { GLOBAL_calendar_min_earliest_booking_time, notifyBeforeAppointment } from '../config';
import { getConnection as getConnectionDB } from '../database/initDatabase';
import getConnectionRabbit from './getConnection';
import MyEvents, { CalendarEvent } from '../database/reservedCustomer';
import redisClient from '../redis/init';
import MyRedisKeys from '../redis/keys';
import { getStore } from '../database/store';
import { getDateText } from './dateText';
import { getUser } from '../database/user';
import logger, { userLogger } from '../logger/logger';
async function checkForNotifications() {
let conn: PoolConnection | null = null;
try {
conn = await getConnectionDB();
for (const notifyBefore of notifyBeforeAppointment) {
const isFirstNotification = notifyBefore === notifyBeforeAppointment[0];
let startSoonTime = new Date(new Date().getTime() + 1000 * 60 * notifyBefore);
const currentTime = new Date();
let rows;
if (isFirstNotification) {
rows = await conn.query(
'SELECT * FROM `calendar_customer` WHERE `verified_time` IS NOT NULL AND `send_next_notification_at` IS NULL AND `time_start` < ? AND `time_start` > ? ORDER BY RAND() LIMIT 1',
[startSoonTime, currentTime]
);
} else {
rows = await conn.query(
'SELECT * FROM `calendar_customer` WHERE `verified_time` IS NOT NULL AND `send_next_notification_at` < ? AND `time_start` < ? AND `time_start` > ? ORDER BY RAND() LIMIT 1',
[currentTime, startSoonTime, currentTime]
);
}
if (rows.length >= 1) {
const row = rows[0];
console.log('rows', rows);
const event: CalendarEvent = {
store_id: row.store_id,
customer_name: row.name,
customer_email: row.email,
customer_message: row.message ? row.message : undefined,
customer_id: row.customer_id,
calendar_event_id: row.calendar_event_id,
user_id: row.user_id,
activity_id: row.activity_id,
time_start: row.time_start,
time_end: row.time_end,
customer_verified_time: row.verified_time ? row.verified_time : undefined,
send_next_notification_at: row.send_next_notification_at ? row.send_next_notification_at : undefined,
created_at: row.created_at,
};
// redis mutex like lock
await new Promise(async (resolve, reject) => {
const redisLockKey = MyRedisKeys.appointmentNotificationLock(event.customer_id);
const redisLockKeyTTL = 10; // 10 seconds
let isRedisLocked = null;
function lock() {
return redisClient.set(redisLockKey, 'true', { EX: redisLockKeyTTL, NX: true });
}
function refreshLock() {
return redisClient.expire(redisLockKey, redisLockKeyTTL);
}
try {
isRedisLocked = await lock();
} catch (error) {
console.error(error);
reject(error);
return;
}
// if lock is not set
if (isRedisLocked === null) {
resolve(undefined);
return;
}
if (new Date() > event.time_start) {
resolve(undefined);
return;
}
let latestNotificationTime = notifyBefore;
for (let i = notifyBeforeAppointment.length - 1; i >= 0; i--) {
const _notifyBefore = notifyBeforeAppointment[i];
const _startSoonTime = new Date(new Date().getTime() + 1000 * 60 * _notifyBefore);
if (_startSoonTime > event.time_start) {
latestNotificationTime = _notifyBefore;
break;
}
}
let latestNotificationTimeIndex = notifyBeforeAppointment.indexOf(latestNotificationTime);
if (latestNotificationTimeIndex === -1) {
console.error('latestNotificationTimeIndex === -1', event);
resolve(undefined);
return;
}
let nextStartSoonTime;
if (latestNotificationTimeIndex + 1 >= notifyBeforeAppointment.length) {
nextStartSoonTime = event.time_start;
} else {
nextStartSoonTime = new Date(event.time_start.getTime() - 1000 * 60 * notifyBeforeAppointment[latestNotificationTimeIndex + 1]);
}
if (conn) {
await conn.query('UPDATE `calendar_customer` SET `send_next_notification_at` = ? WHERE `customer_id` = ?', [nextStartSoonTime, event.customer_id]);
if (event.created_at <= new Date(new Date().getTime() - 1000 * 60 * GLOBAL_calendar_min_earliest_booking_time)) {
try {
await sendNotificationByEMail(event, latestNotificationTime);
} catch (error) {
userLogger.error(
event.user_id,
'Customer Notification failed to send (begins ' + minutesToText('en', latestNotificationTime) + ')',
JSON.stringify(event, null, 2),
JSON.stringify(error, null, 2)
);
}
}
} else {
reject('conn is null');
return;
}
resolve(undefined);
});
}
}
} catch (error) {
console.error(error);
logger.error('checkForNotifications()', JSON.stringify(error, null, 2));
} finally {
if (conn) conn.end();
}
}
function minutesToText(lang: 'de' | 'en', minutes: number) {
// convert minutes to text like: 1 Tag, 2 Stunden und 3 Minuten
let text = '';
if (minutes < 0) minutes = 0;
const days = Math.floor(minutes / 60 / 24);
minutes -= days * 60 * 24;
const hours = Math.floor(minutes / 60);
minutes -= hours * 60;
switch (lang) {
case 'de':
if (days > 0) {
if (days === 1) text += 'einem';
else text += days;
text += ' Tag';
if (days > 1) text += 'e';
}
if (hours > 0) {
if (text.length > 0) text += ', ';
if (hours === 1) text += 'einer';
else text += hours;
text += ' Stunde';
if (hours > 1) text += 'n';
}
if (minutes > 0) {
if (text.length > 0) text += ' und ';
if (minutes === 1) text += 'einer';
else text += minutes;
text += ' Minute';
if (minutes > 1) text += 'n';
}
if (text.length === 0) text = '0 Minuten';
if (text === 'einem Tag') text = 'morgen';
else text = 'in ' + text;
break;
case 'en':
if (days > 0) {
if (days === 1) text += 'one';
else text += days;
text += ' day';
if (days > 1) text += 's';
}
if (hours > 0) {
if (text.length > 0) text += ' and ';
if (hours === 1) text += 'one';
else text += hours;
text += ' hour';
if (hours > 1) text += 's';
}
if (minutes > 0) {
if (text.length > 0) text += ' and ';
if (minutes === 1) text += 'one';
else text += minutes;
text += ' minute';
if (minutes > 1) text += 's';
}
if (text.length === 0) text = '0 minutes';
if (text === 'one day') text = 'tomorrow';
else text = 'in ' + text;
break;
default:
text = minutes + ' minutes';
break;
}
return text;
}
async function cancelEmailNotifications(customer_id: string): Promise<boolean> {
let conn;
try {
conn = await getConnectionDB();
let rows = await conn.query('UPDATE `calendar_customer` SET `send_next_notification_at` = `time_end` WHERE `customer_id` = ?', [customer_id]);
if (rows.affectedRows === 1) {
return true;
} else {
return false;
}
} catch (error) {
console.error(error);
return false;
} finally {
if (conn) conn.end();
}
}
async function sendNotificationByEMail(event: CalendarEvent, inMinutes: number) {
const queue = 'kk.mails';
const conn = await getConnectionRabbit();
// Sender
const ch2 = await conn.createChannel();
const store = await getStore(event.store_id);
const user = await getUser(event.user_id);
let data = {
m: event.customer_email, // UserMail
t: 'embedEmailNotificationZeitAdler', // TemplateId
l: 'de', // LanguageId
// BodyData
b: {
name: event.customer_name,
cancelURL: MyEvents.generateEMailCancelLink(event.customer_id),
address: store.address,
timeText: minutesToText('de', inMinutes),
cancelNotifyURL: MyEvents.generateEMailCancelNotificationLink(event.customer_id),
...(await getDateText(event)),
},
};
ch2.sendToQueue(queue, Buffer.from(JSON.stringify(data)));
userLogger.info(event.user_id, 'Customer Notification sent (begins ' + minutesToText('en', inMinutes) + ') to ' + event.customer_email);
}
export { checkForNotifications, cancelEmailNotifications };
export default checkForNotifications;

117
src/mail/verify.ts Normal file
View File

@ -0,0 +1,117 @@
import MyEvents, { CalendarEvent } from '../database/reservedCustomer';
import { getStore } from '../database/store';
import { getUser } from '../database/user';
import { User } from '../user/types';
import { getDateText } from './dateText';
import getConnection from './getConnection';
async function sendVerifyMail(event: CalendarEvent) {
try {
const queue = 'kk.mails';
const conn = await getConnection();
// Sender
const ch2 = await conn.createChannel();
const verifyURL = MyEvents.generateEMailVerificationLink(event.customer_id);
const cancelURL = MyEvents.generateEMailCancelLink(event.customer_id);
const store = await getStore(event.store_id);
let data = {
m: event.customer_email, // UserMail
t: 'embedVerificationZeitAdler', // TemplateId
l: 'de', // LanguageId
// BodyData
b: {
name: event.customer_name,
verifyURL: verifyURL,
cancelURL: cancelURL,
address: store.address,
...(await getDateText(event)),
},
};
ch2.sendToQueue(queue, Buffer.from(JSON.stringify(data)));
console.log('Sent:', data);
} catch (error) {
console.error('Error sending verify mail', error);
}
}
async function sendMailIsVerifiedMail(event: CalendarEvent) {
try {
const queue = 'kk.mails';
const conn = await getConnection();
// Sender
const ch2 = await conn.createChannel();
const cancelURL = MyEvents.generateEMailCancelLink(event.customer_id);
const store = await getStore(event.store_id);
let data = {
m: event.customer_email, // UserMail
t: 'embedEmailVerifiedZeitAdler', // TemplateId
l: 'de', // LanguageId
// BodyData
b: {
name: event.customer_name,
cancelURL: cancelURL,
address: store.address,
...(await getDateText(event)),
},
};
ch2.sendToQueue(queue, Buffer.from(JSON.stringify(data)));
try {
await sendNewAppointmentForEmployeeMail(event);
} catch (error) {
console.log('error sending sendNewAppointmentForEmployeeMail');
console.error(error);
}
} catch (error) {
console.error('Error sending mail is verified mail', error);
}
}
async function sendNewAppointmentForEmployeeMail(event: CalendarEvent) {
try {
const user = await getUser(event.user_id);
if (!user.email) return;
const queue = 'kk.mails';
const conn = await getConnection();
// Sender
const ch2 = await conn.createChannel();
const cancelURL = MyEvents.generateEMailCancelLink(event.customer_id);
const store = await getStore(event.store_id);
let data = {
m: user.email, // UserMail
t: 'embedEmployeeNewNotificationZeitAdler', // TemplateId
l: 'de', // LanguageId
// BodyData
b: {
name: event.customer_name,
customerMessage: event.customer_message,
customerEMail: event.customer_email,
cancelURL: cancelURL,
address: store.address,
...(await getDateText(event)),
},
};
ch2.sendToQueue(queue, Buffer.from(JSON.stringify(data)));
} catch (error) {
console.error('Error sending new appointment for employee mail', error);
}
}
export default sendVerifyMail;
export { sendMailIsVerifiedMail, sendNewAppointmentForEmployeeMail };

75
src/mail/verifyFail.ts Normal file
View File

@ -0,0 +1,75 @@
import MyEvents, { CalendarEvent } from '../database/reservedCustomer';
import { getStore } from '../database/store';
import { getUser } from '../database/user';
import { User } from '../user/types';
import { getDateText } from './dateText';
import getConnection from './getConnection';
async function sendAppointmentCanceledMail(event: CalendarEvent) {
const queue = 'kk.mails';
const conn = await getConnection();
// Sender
const ch2 = await conn.createChannel();
const store = await getStore(event.store_id);
let data = {
m: event.customer_email, // UserMail
t: 'embedAppointmentCanceledZeitAdler', // TemplateId
l: 'de', // LanguageId
// BodyData
b: {
name: event.customer_name,
appointment1: 'Termin',
appointment2: event.time_start.toISOString(),
appointment3: event.time_end.toISOString(),
address: store.address,
...(await getDateText(event)),
},
};
ch2.sendToQueue(queue, Buffer.from(JSON.stringify(data)));
try {
await sendCancelAppointmentForEmployeeMail(event);
} catch (error) {
console.log('error sending sendNewAppointmentForEmployeeMail');
console.error(error);
}
console.log('Sent:', data);
}
async function sendCancelAppointmentForEmployeeMail(event: CalendarEvent) {
if (!event.customer_verified_time) return;
const user = await getUser(event.user_id);
if (!user.email) return;
const queue = 'kk.mails';
const conn = await getConnection();
// Sender
const ch2 = await conn.createChannel();
const store = await getStore(event.store_id);
let data = {
m: user.email, // UserMail
t: 'embedEmployeeCancelNotificationZeitAdler', // TemplateId
l: 'de', // LanguageId
// BodyData
b: {
name: event.customer_name,
customerEMail: event.customer_email,
address: store.address,
...(await getDateText(event)),
},
};
ch2.sendToQueue(queue, Buffer.from(JSON.stringify(data)));
console.log('Sent:', data);
}
export default sendAppointmentCanceledMail;

35
src/main.ts Normal file
View File

@ -0,0 +1,35 @@
// Configuration
import { webPort, webHost } from './config';
// Web server
import app from './web/express';
import activateFileServe from './web/serveFiles';
// Calendar related imports
import { refreshSyncToken } from './calendarSync/refreshSync';
import updateEvents from './calendarSync/updateEvents';
// Backend and utilities
import { connectToRedis } from './redis/init';
import { initGoogleRequestStatUp } from './requestCounter';
import updateWebhooks from './calendarSync/updateWebhooks';
import checkForNotifications from './mail/notifyBeforeAppointment';
import { initLogHandler } from './logger/logger';
initLogHandler();
initGoogleRequestStatUp();
connectToRedis();
activateFileServe();
app.listen(webPort, webHost, function () {
console.log('Server is running on http://' + webHost + ':' + webPort);
});
setInterval(refreshSyncToken, 1000 * 2);
setInterval(updateEvents, 1000 * 1);
setInterval(updateWebhooks, 1000 * 10);
checkForNotifications();
setInterval(checkForNotifications, 1000 * 1);
refreshSyncToken();

14
src/randomString.ts Normal file
View File

@ -0,0 +1,14 @@
import crypto from 'crypto';
async function generateRandomString(length: number): Promise<string> {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-';
let result = '';
const buf = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
const randomIndex = buf[i] % chars.length;
result += chars[randomIndex];
}
return result;
}
export default generateRandomString;

30
src/redis/clearCache.ts Normal file
View File

@ -0,0 +1,30 @@
import { StoreID } from '../database/store';
import { getAllUsersIDsByStoreID } from '../database/user';
import redisClient from './init';
async function clearRedisCache(store_id: StoreID) {
return new Promise(async (resolve, reject) => {
try {
const keys = await redisClient.keys(`store:${store_id}*`);
//console.log('keys', keys);
if (keys.length > 0) await redisClient.del(keys);
const users = await getAllUsersIDsByStoreID(store_id);
for (const user_id of users) {
const userKeys = await redisClient.keys(`user:${user_id}*`);
//console.log('userKeys', userKeys);
if (userKeys.length > 0) await redisClient.del(userKeys);
}
resolve(true);
} catch (error) {
console.error(error);
reject();
}
});
}
export default clearRedisCache;

49
src/redis/fillCache.ts Normal file
View File

@ -0,0 +1,49 @@
import { getFreeNextDays } from '../calendar/getFreeTime';
import { getServices } from '../database/services';
import { StoreID } from '../database/store';
import { UserID } from '../user/types';
async function fillCache(store_id: StoreID) {
return new Promise(async (resolve, reject) => {
try {
let services = await getServices(store_id, false);
let durationList: Number[] = [];
let timeMeasure = new Date();
for (const service of services) {
for (const activity of service.activities) {
let serviceDuration = activity.duration;
if (durationList.includes(activity.duration)) continue;
let date = new Date();
let dateNextMonth = new Date();
dateNextMonth.setDate(1);
dateNextMonth.setMonth(date.getMonth() + 1);
try {
// delay 2 seconds
//await new Promise(resolve => setTimeout(resolve, 2000));
let users: UserID[] = activity.users;
console.log('fillCache', store_id, users, serviceDuration, date, dateNextMonth);
await getFreeNextDays(store_id, users, serviceDuration, date);
await getFreeNextDays(store_id, users, serviceDuration, dateNextMonth);
} catch (error) {}
durationList.push(activity.duration);
}
}
console.log('fillCache time took (ms)', new Date().getTime() - timeMeasure.getTime());
} catch (error) {
console.error(error);
reject();
}
});
}
export default fillCache;

23
src/redis/init.ts Normal file
View File

@ -0,0 +1,23 @@
import { redis } from 'googleapis/build/src/apis/redis';
import { createClient } from 'redis';
import { REDIS_URL } from '../config';
const redisClient = createClient({
url: REDIS_URL,
});
redisClient.on('error', (err) => console.log('Redis Client Error', err));
function connectToRedis() {
console.log('Connecting to Redis');
redisClient.connect();
}
process.on('SIGINT', () => {
redisClient.disconnect();
console.log('Disconnected from Redis due to application termination');
process.exit(0);
});
export { connectToRedis };
export default redisClient;

41
src/redis/keys.ts Normal file
View File

@ -0,0 +1,41 @@
import { StoreID } from '../database/store';
import crypto from 'crypto';
const dayToString = (day: Date) =>
day.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
});
const MyRedisKeys = {
calendarSyncLock: (calendarId: string) => 'calendar:' + calendarId + ':sync:locked',
userBookingLock: (userId: string, day: Date) => 'userLockBooking:' + userId + ':' + dayToString(day),
userBookingLockUnique: (userId: string, day: Date, date: Date) => MyRedisKeys.userBookingLock(userId, day) + ':' + date.getTime(),
appointmentNotificationLock: (customer_id: string) => 'appointmentNotificationLock:' + customer_id,
calendarOpenHours: (storeId: string) => 'store:' + storeId + ':calendar:openHours',
storeCache: (storeId: string) => 'store:' + storeId + ':cache',
freeTimeOfDayCache: (storeID: StoreID, _userIDs: string[], bookTimeLength: number, day: Date) => {
// sort userIDs by alphabetical order
let userIDs = _userIDs.sort();
//create text from userIDs
let text = '';
for (const userID of userIDs) {
text += userID;
}
// create hash 256
const hash = crypto.createHash('md5').update(text).digest('hex');
const dateStr = dayToString(day);
return 'store:' + storeID + ':' + hash + ':' + dateStr + ':' + bookTimeLength + ':freeTimeOfDay';
},
freeTimeFromOneUserCache: (userId: string, bookTimeLength: number, date: Date) => 'user:' + userId + ':' + dayToString(date) + ':' + bookTimeLength + ':freeTimeFromOneUser',
userCache: (userId: string) => 'user:' + userId + ':cache',
};
export default MyRedisKeys;

55
src/requestCounter.ts Normal file
View File

@ -0,0 +1,55 @@
import logger from './logger/logger';
let counter = 0;
let counterTotal = 0;
interface Statistics {
[key: string]: {
totalCount: number;
count: number;
lastUpdated: Date;
};
}
let statistics: Statistics = {};
function countGoogleRequestStatUp(type: string) {
counter++;
counterTotal++;
let stat = statistics[type];
if (!stat) stat = { totalCount: 0, count: 0, lastUpdated: new Date() };
stat.totalCount++;
stat.count++;
stat.lastUpdated = new Date();
statistics[type] = stat;
}
function initGoogleRequestStatUp() {
setInterval(() => {
logger.info('Google Request Statistics', JSON.stringify(statistics, null, 2));
console.log('Google Request Statistics', statistics);
console.log('Google Request Counter', counter);
console.log('Google Request Total Counter', counterTotal);
counter = 0;
for (const type in statistics) {
const stat = statistics[type];
stat.count = 0;
stat.lastUpdated = new Date();
statistics[type] = stat;
}
}, 1000 * 60 * 15);
setInterval(() => {
console.log('Google Request Total Counter', counterTotal);
}, 1000 * 60);
}
export { initGoogleRequestStatUp };
export default countGoogleRequestStatUp;

27
src/user/types.ts Normal file
View File

@ -0,0 +1,27 @@
import { StoreSettings } from '../database/store';
import { getUser } from '../database/user';
type UserID = string;
type isOwner = true | false;
interface User {
user_id: UserID;
store_id: string;
username: string;
isOwner: isOwner;
settings: StoreSettings;
lastUpdate: Date;
session?: string;
email?: string;
}
interface MasterUser extends User {
isOwner: true;
}
interface WorkerUser extends User {
isOwner: false;
}
export { UserID, User, MasterUser, WorkerUser };

10
src/web/express.ts Normal file
View File

@ -0,0 +1,10 @@
const express = require('express');
let app = express();
const bodyParser = require('body-parser');
import cookieParser from 'cookie-parser';
app.use(bodyParser.json());
app.use(cookieParser());
export default app;

965
src/web/serveFiles.ts Normal file
View File

@ -0,0 +1,965 @@
import { Request, Response, NextFunction } from 'express';
import app from './express';
import { GLOBAL_calendar_min_earliest_booking_time, publicFolder, publicURL } from '../config';
import { getAccessToken, getOAuthClient, initOauthAccess } from '../calendar/oauthAccess';
import { google } from 'googleapis';
import calendar_ids from '../database/calendar_ids';
import { getFreeNextDays, getFreeTimeOfDay } from '../calendar/getFreeTime';
import { getAllUsersIDsAndNamesByStoreID, getAllUsersIDsByStoreID, getUser } from '../database/user';
import { MasterUser, UserID } from '../user/types';
import { getStore } from '../database/store';
import { getActivity, getServices } from '../database/services';
import { checkIfTimeIsAvailable } from '../calendar/insertEvent';
import MyEvents from '../database/reservedCustomer';
import initCalendar, { removeCalendar } from '../calendar/initCalendar';
import verifyCaptcha from '../captcha';
import sendVerifyMail from '../mail/verify';
import GoogleTokensDatabase from '../database/user_google_tokens';
import { forceGetFreshOpenHours } from '../calendar/openHoursList';
import { scheduleCalendarSync } from '../calendarSync/refreshSync';
import MyRedisKeys from '../redis/keys';
import redisClient from '../redis/init';
import clearRedisCache from '../redis/clearCache';
import { getHighestCalendarMaxFutureBookingDays } from '../calendarSync/openHours';
import fs from 'fs';
import { cancelEmailNotifications } from '../mail/notifyBeforeAppointment';
import logger, { storeLogger, userLogger } from '../logger/logger';
import * as EmailValidator from 'email-validator';
import { log } from 'console';
function getIP(req: Request) {
return req.headers['x-real-ip'] as string;
}
function activateFileServe() {
app.get('/embedPopup/script.js', async function (req: Request, res: Response) {
try {
// read file publicFolder + '/embedPopup/script.js' by fs.readFile
const file = await new Promise<Buffer>((resolve, reject) => {
fs.readFile(publicFolder + '/embedPopup/script.js', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (!file) throw new Error('file not found');
if (publicURL === undefined) throw new Error('publicURL in .env not found');
// replace %PUBLIC_URL% with config.publicURL
const fileString = file.toString().replace(/%PUBLIC_URL%/g, publicURL);
// send file as response
res.setHeader('Content-Type', 'application/javascript');
res.send(fileString);
} catch (error) {
console.log(error);
res.status(500).send('error 500');
}
});
app.get('/embedPopup/style.css', function (req: Request, res: Response) {
res.sendFile(publicFolder + '/embedPopup/style.css');
});
app.get('/ttest', async function (req: Request, res: Response) {
res.sendFile(publicFolder + '/index.html');
let userId = req.cookies['token'] as string | undefined;
if (userId) {
try {
const accessToken = await getAccessToken(userId);
console.log('accessToken: ' + accessToken);
} catch (error) {}
}
});
app.post('/getBooking', async function (req: Request, res: Response) {
const customer_id = req.body.bookingID;
const ip = getIP(req);
// check if name is too long
if (!customer_id || customer_id.length < 32) {
res.status(400).send(JSON.stringify({ error: 'no valid id given' }));
logger.warn('REQUEST /getBooking', 'no valid id given', customer_id ? customer_id : '"no id given"', ip);
return;
}
try {
const event = await MyEvents.getPublicEvent(customer_id);
if (event && event.time_start > new Date()) {
res.status(200).send(JSON.stringify({ event: event }));
} else {
res.status(404).send(JSON.stringify({ error: 'not found' }));
logger.warn('/getBooking', 'no valid id given', customer_id ? customer_id : '"no id given"', ip);
}
} catch (error) {
logger.error('REQUEST /getBooking', 'error', (error as Error).toString(), ip);
res.status(500).send(JSON.stringify({ error: 'unknown error' }));
}
});
app.post('/booking', async function (req: Request, res: Response) {
// get store_id from body
const store_id = req.body.store_id;
const activity_id = req.body.activity_id;
const date = req.body.time;
const name = req.body.name;
const email = req.body.email;
let message = req.body.message;
const employee = req.body.employee;
const captcha = req.body.captcha;
const ip = getIP(req);
if (store_id) {
try {
const store = await getStore(store_id);
// check if date is valid
if (isNaN(Number(date))) {
res.status(400).send(JSON.stringify({ error: 'invalid date' }));
storeLogger.warn(store_id, 'REQUEST /booking', 'invalid date', date, ip);
return;
}
// check if name is valid
if (name === undefined || name === null || name.length < 3) {
res.status(400).send(JSON.stringify({ error: 'invalid name' }));
storeLogger.warn(store_id, 'REQUEST /booking', 'invalid name', name, ip);
return;
}
// check if name is too long
if (name.length > 32) {
res.status(400).send(JSON.stringify({ error: 'name too long' }));
storeLogger.warn(store_id, 'REQUEST /booking', 'name too long', name, ip);
return;
}
// check if email is valid
if (email === undefined || email === null || !EmailValidator.validate(email)) {
res.status(400).send(JSON.stringify({ error: 'invalid email' }));
storeLogger.warn(store_id, 'REQUEST /booking', 'invalid email', email, ip);
return;
}
// check if email is too long
if (email.length > 255) {
res.status(400).send(JSON.stringify({ error: 'email too long' }));
storeLogger.warn(store_id, 'REQUEST /booking', 'email too long (' + email.length + ')', ip);
return;
}
// check if message is valid
if (message === undefined || message === null) {
message = '';
} else if (message.length > 1502) {
res.status(400).send(JSON.stringify({ error: 'message too long' }));
storeLogger.warn(store_id, 'REQUEST /booking', 'message too long (' + message.length + ')', ip);
return;
}
try {
await verifyCaptcha(captcha, ip);
} catch (error) {
storeLogger.warn(store_id, 'REQUEST /booking', 'captcha error', (error as Error).toString(), ip);
res.status(400).send(JSON.stringify({ error: 'captcha' }));
return;
}
const calendarLockIDs: {
[key: string]: {
calendar_id: string | undefined;
isLocked: boolean;
refreshLock: () => void;
removeLock: () => void;
};
} = {
event: {
calendar_id: undefined,
isLocked: false,
refreshLock: async () => {},
removeLock: async () => {},
},
primary: {
calendar_id: undefined,
isLocked: false,
refreshLock: async () => {},
removeLock: async () => {},
},
};
try {
const activity = await getActivity(store_id, activity_id, false);
// check if employee is valid
if (employee === undefined || employee === null || !activity.users.includes(employee)) {
res.status(400).send(JSON.stringify({ error: 'invalid employee' }));
storeLogger.warn(store_id, 'REQUEST /booking', 'invalid employee', employee, ip);
return;
}
// start and end time
const startTime = new Date(Number(date));
const endTime = new Date(Number(date) + activity.duration * 60 * 1000);
const user = await getUser(employee);
//wait for calendarSyncLock
await new Promise(async (resolve, reject) => {
let isLocked = true;
calendarLockIDs.event.calendar_id = await calendar_ids.getEventCalendarID(user);
try {
calendarLockIDs.primary.calendar_id = (await calendar_ids.getPrimaryCalendar(user.user_id)).calendar_id;
} catch (error) {}
while (isLocked) {
let hasFoundSomethingLocked = false;
for (const key in calendarLockIDs) {
const calendarID = calendarLockIDs[key];
if (calendarID.calendar_id) {
const lockKey = MyRedisKeys.calendarSyncLock(calendarID.calendar_id);
if (calendarID.isLocked) {
await calendarID.refreshLock();
continue;
}
const lock = await redisClient.set(lockKey, 'locked', { NX: true, EX: 30 });
if (lock) {
calendarID.isLocked = true;
calendarID.refreshLock = async () => {
await redisClient.expire(lockKey, 30);
};
calendarID.removeLock = async () => {
await redisClient.del(lockKey);
};
} else {
hasFoundSomethingLocked = true;
}
}
}
if (hasFoundSomethingLocked) {
await new Promise((resolve) => setTimeout(resolve, 500));
} else {
isLocked = false;
}
}
resolve(undefined);
});
const lockTimeout = 30; // 30 seconds
const lockValue = 'locked';
let lockDate = new Date();
let lockKeyUnique = MyRedisKeys.userBookingLockUnique(user.user_id, startTime, lockDate);
const lockKey = MyRedisKeys.userBookingLock(user.user_id, startTime);
const refreshLock = () => {
redisClient.expire(lockKeyUnique, lockTimeout);
};
try {
// lock user
let lock = await redisClient.set(lockKeyUnique, lockValue, { NX: true, EX: lockTimeout });
while (!lock) {
lockDate = new Date();
lockKeyUnique = MyRedisKeys.userBookingLockUnique(user.user_id, startTime, lockDate);
lock = await redisClient.set(lockKeyUnique, lockValue, { NX: true, EX: lockTimeout });
if (!lock) {
// wait 500ms
await new Promise((resolve) => setTimeout(resolve, 500 + Math.random() * 500));
}
refreshLock();
}
const splitLength = lockKeyUnique.split(':').length;
let isFirstQueue = false;
while (!isFirstQueue) {
// list all keys
const keys = await redisClient.keys(lockKey + ':*');
let oldestTime = -1;
// check if first in queue
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const time = Number(key.split(':')[splitLength - 1]);
if (time < oldestTime || oldestTime === -1) {
oldestTime = time;
}
}
if (oldestTime === Number(lockKeyUnique.split(':')[splitLength - 1])) {
isFirstQueue = true;
} else {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
refreshLock();
}
// check if time is available
try {
await clearRedisCache(store_id);
await checkIfTimeIsAvailable(store, user, startTime, endTime);
} catch (error) {
console.error(error);
userLogger.error(user.user_id, 'REQUEST /booking', 'time not available', (error as Error).toString(), ip);
res.status(409).send(JSON.stringify({ status: 'already booked' }));
return;
}
// create event
const event = await MyEvents.createEvent(store, user, activity_id, startTime, endTime, email, name, message);
userLogger.info(user.user_id, 'REQUEST /booking', 'event created', JSON.stringify(event, null, 2), ip);
// send email
try {
await sendVerifyMail(event);
} catch (error) {
console.error(error);
userLogger.error(user.user_id, 'REQUEST /booking', 'email not sent', (error as Error).toString(), JSON.stringify(event, null, 2), ip);
res.status(500).send(JSON.stringify({ error: 'unknown error' }));
return;
}
res.status(200).send(JSON.stringify({ status: 'success' }));
} catch (error) {
console.log(error);
userLogger.error(user.user_id, 'REQUEST /booking', 'unknown error', (error as Error).toString(), ip);
res.status(500).send(JSON.stringify({ error: 'unknown error' }));
}
} catch (error) {
console.log(error);
storeLogger.error(store_id, 'REQUEST /booking', 'unknown error', (error as Error).toString(), ip);
res.status(500).send(JSON.stringify({ error: 'unknown error' }));
} finally {
for (const key in calendarLockIDs) {
const calendarID = calendarLockIDs[key];
if (calendarID.isLocked) {
await calendarID.removeLock();
}
}
}
} catch (error) {
console.log(error);
storeLogger.error(store_id, 'REQUEST /booking', 'unknown error', (error as Error).toString(), ip);
res.status(404).send(JSON.stringify({ error: 'store not found' }));
}
} else {
logger.warn('REQUEST /booking', 'no storeId given', ip);
res.status(400).send(JSON.stringify({ error: 'store not found' }));
}
});
app.post('/services', async function (req: Request, res: Response) {
// get store_id from body
const store_id = req.body.store_id;
const ip = getIP(req);
if (store_id) {
try {
const store = await getStore(store_id);
try {
const services = await getServices(store.store_id, true);
const _users = await getAllUsersIDsByStoreID(store.store_id);
let users: { [key: string]: { name: string; cmebt: number } } = {};
for (let i = 0; i < _users.length; i++) {
const user = await getUser(_users[i]);
users[user.user_id] = { name: user.username, cmebt: user.settings.calendar_min_earliest_booking_time + GLOBAL_calendar_min_earliest_booking_time };
}
const highest_calendar_max_future_booking_days = await getHighestCalendarMaxFutureBookingDays(store.store_id);
const maxDate = new Date(new Date().getTime() + highest_calendar_max_future_booking_days * 24 * 60 * 60 * 1000);
res.status(200).send(JSON.stringify({ services: services, users: users, maxDate: maxDate.getTime(), storeName: store.name }));
} catch (error) {
console.log(error);
storeLogger.error(store_id, 'REQUEST /services', 'unknown error', (error as Error).toString(), ip);
res.status(500).send('error 500');
}
} catch (error) {
console.log(error);
logger.warn('REQUEST /services', 'store not found', (error as Error).toString(), ip);
res.status(404).send('store not found');
}
} else {
logger.warn('REQUEST /services', 'no store id found', ip);
res.status(400).send('id not found');
}
});
app.post('/freeByActivity', async function (req: Request, res: Response) {
const store_id = req.body.store_id;
const dateQuery = Number(req.body.date);
const activity_id = req.body.activity_id;
const wishUser = req.body.users;
const ip = getIP(req);
if (store_id) {
try {
const store = await getStore(store_id);
try {
const activity = await getActivity(store_id, activity_id, false);
const serviceDuration = activity.duration;
// check if wishUser is an array
if (wishUser !== undefined && !Array.isArray(wishUser)) {
logger.warn(store_id, 'REQUEST /freeByActivity', 'invalid users', JSON.stringify(wishUser, null, 2), ip);
res.status(400).send('invalid users');
return;
}
if (isNaN(dateQuery)) throw new Error('invalid date');
let date = new Date(dateQuery || Date.now());
let users: UserID[] = [];
if (wishUser === undefined || wishUser.length == 0) {
users = activity.users;
} else {
for (let i = 0; i < wishUser.length; i++) {
if (wishUser[i] !== null && wishUser[i] !== undefined) {
const userId = wishUser[i].toString();
// check if user contains in activity.users
if (activity.users.includes(userId)) {
users.push(userId);
}
}
}
}
const freeTime = await getFreeTimeOfDay(store_id, users, serviceDuration, date);
// convert all dates to timestamps
for (const key in freeTime) {
const time = freeTime[key].events;
for (let i = 0; i < time.length; i++) {
const timeSlot = time[i];
(timeSlot.start as any) = timeSlot.start.getTime();
(timeSlot.end as any) = timeSlot.end.getTime();
}
} /* [{"userId":"ayg2xg0w0vwswbl1c6w28","events":[{"start":1704964831091,"end":1704966300000},{"start":1704969900000,"end":1704970800000},{"start":1704978900000,"end":1704980700000},{"start":1704988800000,"end":1704990600000},{"start":1704994200000,"end":1704997800000},{"start":1705001400000,"end":1705005000000}]}] */
res.status(200).send(JSON.stringify({ events: freeTime }));
} catch (error) {
console.log(error);
storeLogger.error(store_id, 'REQUEST /freeByActivity', 'error', (error as Error).toString(), ip);
res.status(500).send('error 500');
}
} catch (error) {
console.log(error);
logger.warn('REQUEST /freeByActivity', 'store not found', (error as Error).toString(), ip);
res.status(400).send('no store found');
}
} else {
res.status(400).send('no store found');
logger.warn('REQUEST /freeByActivity', 'no store found', ip);
}
});
app.post('/freeNextDaysByActivity', async function (req: Request, res: Response) {
const store_id = req.body.store_id;
const dateQuery = Number(req.body.date);
const activity_id = req.body.activity_id;
const wishUser = req.body.users;
const ip = getIP(req);
if (store_id) {
try {
const store = await getStore(store_id);
try {
const activity = await getActivity(store_id, activity_id, false);
const serviceDuration = activity.duration;
// check if wishUser is an array
if (wishUser !== undefined && !Array.isArray(wishUser)) {
res.status(400).send('invalid users');
logger.warn(store_id, 'REQUEST /freeNextDaysByActivity', 'invalid users', JSON.stringify(wishUser, null, 2), ip);
return;
}
if (isNaN(dateQuery)) {
res.status(400).send('invalid date');
logger.warn(store_id, 'REQUEST /freeNextDaysByActivity', 'invalid date', dateQuery.toString(), ip);
return;
}
if (isNaN(serviceDuration) || serviceDuration > 24 * 60) {
res.status(400).send('invalid service duration');
logger.warn(store_id, 'REQUEST /freeNextDaysByActivity', 'invalid service duration', serviceDuration.toString(), ip);
return;
}
// convert dateQueryNumber to date
let date = new Date(dateQuery || Date.now());
let users: UserID[] = [];
if (wishUser === undefined || wishUser.length == 0) {
users = activity.users;
} else {
for (let i = 0; i < wishUser.length; i++) {
if (wishUser[i] !== null && wishUser[i] !== undefined) {
const userId = wishUser[i].toString();
// check if user contains in activity.users
if (activity.users.includes(userId)) {
users.push(userId);
}
}
}
}
const _freeDays = await getFreeNextDays(store.store_id, users, serviceDuration, date);
let freeDays: number[] = []; // timestamp
// convert all dates to timestamps
for (let i = 0; i < _freeDays.length; i++) {
freeDays.push(_freeDays[i].getTime());
}
res.status(200).send(JSON.stringify({ freeDays: freeDays }));
} catch (error) {
console.log(error);
res.status(500).send('error 500');
storeLogger.error(store_id, 'REQUEST /freeNextDaysByActivity', 'error', (error as Error).toString(), ip);
}
} catch (error) {
console.log(error);
res.status(404).send('store not found');
logger.warn('REQUEST /freeNextDaysByActivity', 'store not found', (error as Error).toString(), ip);
}
} else {
res.status(404).send('store not found');
logger.warn('REQUEST /freeNextDaysByActivity', 'no storeId given', ip);
}
});
// for google api webhook
app.post('/api/v1/notification/:eventId', async function (req: Request, res: Response) {
const eventId = req.params.eventId;
const headers = req.headers;
const token = headers['x-goog-channel-token'] as string;
const ip = getIP(req);
if (!token) {
console.log(headers);
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/notification', 'no token found', ip);
return;
}
if (token !== process.env.GOOGLE_WEBHOOK_SECRET) {
console.log(headers);
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/notification', 'invalid token', ip);
return;
}
const state = headers['x-goog-resource-state'] as string;
const channel_id = headers['x-goog-channel-id'] as string;
const channel_expiration = headers['x-goog-channel-expiration'] as string;
const resource_id = headers['x-goog-resource-id'] as string;
if (state === 'sync') {
res.status(200).send('success');
return;
}
logger.info('REQUEST /api/v1/notification', 'state', state, 'channel_id', channel_id, 'channel_expiration', channel_expiration, 'resource_id', resource_id, ip);
try {
const channelIDExists = await scheduleCalendarSync(eventId, channel_id);
if (!channelIDExists) {
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/notification', 'channel_id not found', ip);
return;
}
res.status(200).send('success');
} catch (error) {
console.error('notificationError', error);
logger.error('REQUEST /api/v1/notification', 'eventId:', eventId, 'error', (error as Error).toString(), ip);
res.status(500).send('error 500');
}
});
app.post('/api/v1/changeFutureBookingDays', async function (req: Request, res: Response) {
let storeId = req.body.storeId as string;
let pass = req.body.pass as string;
const ip = getIP(req);
if (pass !== process.env.BACKEND_PASSPHRASE) {
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/changeFutureBookingDays', 'invalid passphrase', ip);
return;
}
if (!storeId) {
res.status(400).send('no storeId found');
logger.warn('REQUEST /api/v1/changeFutureBookingDays', 'no storeId given', ip);
return;
}
try {
try {
await clearRedisCache(storeId);
await forceGetFreshOpenHours(storeId);
} catch (error) {}
res.status(200).send('success');
} catch (error) {
storeLogger.error(storeId, 'REQUEST /api/v1/changeFutureBookingDays', 'error', (error as Error).toString(), ip);
res.status(404).send('unknown error');
}
});
app.post('/api/v1/changedSettings', async function (req: Request, res: Response) {
let storeId = req.body.storeId as string;
let pass = req.body.pass as string;
const ip = getIP(req);
if (pass !== process.env.BACKEND_PASSPHRASE) {
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/changeFutureBookingDays', 'invalid passphrase', ip);
return;
}
if (!storeId) {
res.status(400).send('no userId found');
return;
}
try {
try {
await clearRedisCache(storeId);
await forceGetFreshOpenHours(storeId);
} catch (error) {}
res.status(200).send('success');
} catch (error) {
console.log(error);
storeLogger.error(storeId, 'REQUEST /api/v1/changedSettings', 'error', (error as Error).toString(), ip);
res.status(404).send('user not found');
}
});
app.post('/api/v1/addGoogleAccount', async function (req: Request, res: Response) {
let userId = req.body.userId as string;
let accessToken = req.body.accessToken as string;
let refreshToken = req.body.refreshToken as string;
let googleUuid = req.body.sub as string;
let pass = req.body.pass as string;
const ip = getIP(req);
if (pass !== process.env.BACKEND_PASSPHRASE) {
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/changeFutureBookingDays', 'invalid passphrase', ip);
return;
}
if (!userId) {
res.status(400).send('no userId found');
logger.error('REQUEST /api/v1/addGoogleAccount', 'no userId given', ip);
return;
}
let user = undefined;
try {
user = await getUser(userId);
} catch (error) {
console.log(error);
res.status(404).send('user not found');
logger.error('REQUEST /api/v1/addGoogleAccount', 'user not found', ip);
return;
}
if (!accessToken) {
res.status(400).send('no accessToken found');
userLogger.error(user.user_id, 'REQUEST /api/v1/addGoogleAccount', 'no accessToken given', ip);
return;
}
if (!refreshToken) {
const oauth2Client = await getOAuthClient(accessToken);
await oauth2Client.revokeToken(accessToken);
res.status(400).send('no refreshToken found');
userLogger.error(user.user_id, 'REQUEST /api/v1/addGoogleAccount', 'no refreshToken given', ip);
return;
}
let expireDate = new Date(new Date().getTime() + 1000 * 60 * 30);
try {
await initOauthAccess(userId, refreshToken, accessToken, expireDate, googleUuid);
res.status(200).send('success');
try {
await forceGetFreshOpenHours(user.store_id);
} catch (error) {}
} catch (error) {
try {
const oauth2Client = await getOAuthClient(accessToken);
await oauth2Client.revokeToken(accessToken);
} catch (error) {}
userLogger.error(user.user_id, 'REQUEST /api/v1/addGoogleAccount', 'error', (error as Error).toString(), ip);
res.status(500).send('error 500');
}
});
app.post('/api/v1/removeGoogleAccount', async function (req: Request, res: Response) {
let userId = req.body.userId as string;
let pass = req.body.pass as string;
const ip = getIP(req);
if (pass !== process.env.BACKEND_PASSPHRASE) {
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/changeFutureBookingDays', 'invalid passphrase', ip);
return;
}
if (!userId) {
res.status(400).send('no userId found');
logger.error('REQUEST /api/v1/removeGoogleAccount', 'no userId given', ip);
return;
}
try {
let user;
try {
user = await getUser(userId);
} catch (error) {
logger.error('REQUEST /api/v1/removeGoogleAccount', 'user not found', ip);
res.status(404).send('user not found');
return;
}
await GoogleTokensDatabase.unlinkGoogleAccount(userId);
res.status(200).send('success');
try {
await forceGetFreshOpenHours(user.store_id);
} catch (error) {}
} catch (error) {
res.status(500).send('error 500');
logger.error('REQUEST /api/v1/removeGoogleAccount', 'error', (error as Error).toString(), ip);
}
});
app.post('/api/v1/addUser', async function (req: Request, res: Response) {
let userId = req.body.userId as string;
let pass = req.body.pass as string;
const ip = getIP(req);
if (pass !== process.env.BACKEND_PASSPHRASE) {
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/changeFutureBookingDays', 'invalid passphrase', ip);
return;
}
if (!userId) {
res.status(400).send('no userId found');
logger.error('REQUEST /api/v1/addUser', 'no userId given', ip);
return;
}
try {
const user = await getUser(userId);
try {
await initCalendar(user.user_id);
res.status(200).send('success');
} catch (error) {
console.log(error);
userLogger.error(user.user_id, 'REQUEST /api/v1/addUser', 'error', (error as Error).toString(), ip);
res.status(500).send('error 500');
}
} catch (error) {
console.log(error);
logger.error('REQUEST /api/v1/addUser', 'user not found', ip);
res.status(404).send('user not found');
}
});
app.post('/api/v1/removeUser', async function (req: Request, res: Response) {
let userId = req.body.userId as string;
let pass = req.body.pass as string;
const ip = getIP(req);
if (pass !== process.env.BACKEND_PASSPHRASE) {
res.status(404).send('Not found');
logger.warn('REQUEST /api/v1/changeFutureBookingDays', 'invalid passphrase', ip);
return;
}
if (!userId) {
res.status(400).send('no userId found');
logger.error('REQUEST /api/v1/removeUser', 'no userId given', ip);
return;
}
try {
const user = await getUser(userId);
try {
await removeCalendar(user);
res.status(200).send('success');
} catch (error) {
console.log(error);
userLogger.error(user.user_id, 'REQUEST /api/v1/removeUser', 'error', (error as Error).toString(), ip);
res.status(500).send('error 500');
}
} catch (error) {
console.log(error);
logger.error('REQUEST /api/v1/removeUser', 'user not found', ip);
res.status(404).send('user not found');
}
});
app.get('/appointment/verify/:customer_id', async function (req: Request, res: Response) {
const customer_id = req.params.customer_id;
const ip = getIP(req);
if (customer_id) {
try {
const event = await MyEvents.verifyEvent(customer_id);
if (event) {
userLogger.info(event.user_id, 'REQUEST /appointment/verify', 'event verified', ip);
res.send(JSON.stringify({ status: 'success' }));
} else {
logger.warn('REQUEST /appointment/verify', 'event not found', customer_id, ip);
res.status(404).sendFile(publicFolder + '/404.html');
}
} catch (error) {
console.log(error);
logger.error('REQUEST /appointment/verify', 'error', customer_id, (error as Error).toString(), ip);
res.status(500).sendFile(publicFolder + '/error.html');
}
} else {
logger.warn('REQUEST /appointment/verify', 'no customer_id given', ip);
res.status(400).sendFile(publicFolder + '/error.html');
}
});
app.get('/appointment/cancel/:customer_id', async function (req: Request, res: Response) {
const customer_id = req.params.customer_id;
const ip = getIP(req);
if (customer_id) {
try {
const event = await MyEvents.cancelEvent(customer_id);
if (event) {
userLogger.info(event.user_id, 'REQUEST /appointment/cancel', 'event canceled', ip);
res.send(JSON.stringify({ status: 'success' }));
} else {
res.status(404).sendFile(publicFolder + '/404.html');
logger.warn('REQUEST /appointment/cancel', 'event not found', customer_id, ip);
}
} catch (error) {
console.log(error);
logger.error('REQUEST /appointment/cancel', 'error', customer_id, (error as Error).toString(), ip);
res.status(500).sendFile(publicFolder + '/error.html');
}
} else {
logger.warn('REQUEST /appointment/cancel', 'no customer_id given', ip);
res.status(400).sendFile(publicFolder + '/error.html');
}
});
app.get('/appointment/cancelNotification/:customer_id', async function (req: Request, res: Response) {
const customer_id = req.params.customer_id;
const ip = getIP(req);
if (customer_id) {
try {
const event = await cancelEmailNotifications(customer_id);
if (event) {
userLogger.info('REQUEST /appointment/cancelNotification', 'notifications canceled', customer_id, ip);
res.send(JSON.stringify({ status: 'success' }));
} else {
res.status(404).sendFile(publicFolder + '/404.html');
logger.warn('REQUEST /appointment/cancelNotification', 'event not found', customer_id, ip);
}
} catch (error) {
console.log(error);
res.status(500).sendFile(publicFolder + '/error.html');
logger.error('REQUEST /appointment/cancelNotification', 'error', customer_id, (error as Error).toString(), ip);
}
} else {
res.status(400).sendFile(publicFolder + '/error.html');
logger.warn('REQUEST /appointment/cancelNotification', 'no customer_id given', ip);
}
});
//on 404
app.use(function (req: Request, res: Response, next: NextFunction) {
res.status(404).sendFile(publicFolder + '/404.html');
logger.warn('REQUEST', '404', req.url, getIP(req));
});
}
export default activateFileServe;
function printDate(str: string, date: Date) {
console.log(
str,
date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
);
}

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
}
}