models
Fri Mar 17 2023 15:36:16 GMT+0000 (Coordinated Universal Time)
Saved by @artemka
/* eslint no-param-reassign: ["error", { "props": true, "ignorePropertyModificationsFor": ["Reservations"] }] */
import { i18n } from 'meteor/universe:i18n';
import { Meteor } from 'meteor/meteor';
import moment from 'moment-timezone';
import Tenantify from '../../lib/tenantify';
import OpeningHours from '../opening_hours';
import Interactions from '../interactions';
import ClosedDates from '../closed_dates';
import Tableplans from '../tableplans';
import Consumers from '../consumers';
import Modules from '../modules';
import Tenants from '../tenants';
import Users from '../users';
import { getDateTimeFormatter, getLegacyDateTimeFormatter } from '../../lib/timeZone';
import { escapeHTML } from '../../lib/format';
export default function (Reservations) {
Reservations.create = function (
{ name, email, comment, duration, people, phone, date } = {},
userId,
consumerUrl,
browserLocale = undefined
) {
const currentDate = new Date();
const {
durationGranularity = 30, // In minutes
maxStartDate = 365, // In days
minStartDate = 20, // In minutes
maxDuration = 600, // In minutes (10 hours)
minDuration = 30,
capacity = 1, // In reservations
maxPeople = 12,
minPeople = 1,
managerIds = [],
approvalInstructions = '',
} = Modules.reservations.get().settings;
const parsedDate = new Date(date);
if (name.length < 1) {
throw new Error('Name too short');
}
if (name.length > 80) {
throw new Error('Name is too long (max 80)');
}
if (email.length < 1 || !Users.validateEmail(email)) {
throw new Error('Invalid email');
}
if (email.length > 200) {
throw new Error('Email is too long (max 200)');
}
if (comment && comment.length > 500) {
throw new Error('Invalid comment length (max 500)');
}
if (duration > maxDuration || duration < minDuration) {
throw new Error(`Invalid duration (${minDuration} min, ${maxDuration} max)`);
}
if (duration % durationGranularity !== 0) {
throw new Error(`Invalid duration (please use steps of ${durationGranularity} mins)`);
}
if (people > maxPeople || people < minPeople) {
throw new Error(`Invalid people amount (${minPeople} min, ${maxPeople} max)`);
}
if (Number.isNaN(parsedDate.getTime())) {
throw new Error('Invalid date');
}
const timeDifference = parsedDate.getTime() - currentDate.getTime();
if (timeDifference < 0) {
throw new Error('Date has already passed');
}
if (Math.round(timeDifference / (1000 * 60 * 60 * 24)) > maxStartDate) {
throw new Error(`The selected date is too far in the future (max ${maxStartDate}mins)`);
}
if (Math.round(timeDifference / (1000 * 60)) < minStartDate) {
throw new Error(`The selected date must be ${minStartDate} minutes after the current time`);
}
const endDate = moment(parsedDate).add(duration, 'minutes').toDate();
const startDate = moment(parsedDate).toDate();
const overlappingReservations = Reservations.getOverlapping(startDate, endDate);
const { max_nr_reservations = 10, max_nr_guests = 20 } = (() => {
if (capacity < 1) {
return {};
}
const [nrGuests, nrReservations] = Tableplans.find()
.fetch()
.reduce((arr, item) => [arr[0] + (item.max_nr_guests ?? 0), arr[1] + (item.max_nr_reservations ?? 0)], [0, 0]);
return { max_nr_guests: nrGuests || 20, max_nr_reservations: nrReservations || 10 };
})();
if (overlappingReservations.length >= max_nr_reservations) {
throw new Meteor.Error('reservation', 'maxReservationsOverlapping', 'Too many reservations overlapping (for this seating area)');
}
if (overlappingReservations.reduce((a, r) => a + (r.people ?? 0), 0) + people >= max_nr_guests) {
throw new Meteor.Error('reservation', 'maxPersonsOverlapping', 'Max number of guests reached (for this seating area)');
}
if (
OpeningHours.isClosedOn(OpeningHours.forTypes.DEFAULT, date) ||
ClosedDates.getEntriesOn(OpeningHours.forTypes.DEFAULT, date).length > 0
) {
throw new Meteor.Error('reservation', 'closed', 'Invalid requested date: tenant is closed on that date / at that time');
}
const reservationId = Reservations.insert({
locale: browserLocale,
approved: false,
startDate,
endDate,
name,
people,
phone,
email,
comment,
});
const user = userId === false ? undefined : Consumers.findOne(userId ? { _id: userId } : { 'profile.email': email });
if (user) {
const interactionType = Interactions.types.RESERVATION;
Interactions.insert({
funnel_stage: Interactions.getFunnelStageFromType(interactionType),
entity_id: reservationId,
type: interactionType,
consumer_id: user._id,
});
}
const consumerReservationUrl = new URL(
`/reservation/${reservationId}`,
consumerUrl ?? Meteor.settings.public.consumer_url ?? 'https://mrwinston.app'
).toString();
const backofficeReservationUrl = new URL(
`/admin/reservations/approval/${reservationId}`,
Meteor.settings.public.backoffice_url ?? 'https://backoffice.mrwinston.com'
).toString();
const tenant = Tenants.findOne(Tenantify.getCurrentTenant());
const tenantName = tenant?.companyDetails?.name ?? 'Mr. Winston';
const managers = Users.find({ _id: { $in: managerIds } }).fetch();
const managerEmails = managers.map((u) => u.profile.email).filter(Boolean);
const tenantLocale = Modules.default.get().settings.locale?.language ?? 'en-US';
const userLocale = browserLocale ?? user?.profile?.locale ?? 'en-US';
const contactDetails = (() => {
// eslint-disable-next-line no-shadow
const { email, phone } = Tenants.getContactDetails(tenant);
return email ?? phone ?? managerEmails[0] ?? 'info+reservations@mrwinston.nl';
})();
const mailVars = (locale, url) => {
const { name: timeZone } = Tenants.getTimeZone(startDate);
return {
url,
time:
getDateTimeFormatter(locale, timeZone, { hour: 'numeric', minute: 'numeric' })?.format(startDate) ??
getLegacyDateTimeFormatter(locale, timeZone, startDate).format('LT'),
date:
getDateTimeFormatter(locale, timeZone, { dateStyle: 'short' })?.format(startDate) ??
getLegacyDateTimeFormatter(locale, timeZone, startDate).format('YYYY-MM-DD'),
consumerName: name,
tenantName,
contactDetails,
instructions: escapeHTML(approvalInstructions.trim()),
};
};
if (!Meteor.isTest) {
// Send mail to customer
Meteor.call(
'sendRichMail',
'reservationRequested',
email,
mailVars(userLocale, consumerReservationUrl),
{ replyTo: managerEmails },
userLocale
);
// Send mail to owner/managers
if (managers.length > 0) {
managers.forEach((manager) => {
Meteor.call(
'sendRichMail',
'reservationPlaced',
manager.profile.email,
mailVars(manager.profile.locale ?? tenantLocale, backofficeReservationUrl),
{},
manager.profile.locale ?? tenantLocale
);
});
}
}
return { reservationId, url: consumerReservationUrl };
};
Reservations.modify = function (reservationId, { name, email, comment, duration, people, phone, date, tableplanId } = {}) {
const tableplan = Tableplans.findOne({ _id: tableplanId });
const reservation = Reservations.findOne(reservationId);
if (!reservation) {
throw new Error('Reservation not found');
}
const currentDate = new Date();
const {
durationGranularity = 30, // In minutes
maxStartDate = 365, // In days
minStartDate = 20, // In minutes
maxDuration = 600, // In minutes (10 hours)
minDuration = 30,
capacity = 1,
maxPeople = 12,
minPeople = 1,
} = Modules.reservations.get().settings;
const parsedDate = new Date(date ?? reservation.startDate);
if (name.length < 1) {
throw new Error('Name too short');
}
if (name.length > 80) {
throw new Error('Name is too long (max 80)');
}
if (email.length < 1 || !Users.validateEmail(email)) {
throw new Error('Invalid email');
}
if (email.length > 200) {
throw new Error('Email is too long (max 200)');
}
if (comment && comment.length > 500) {
throw new Error('Invalid comment length (max 500)');
}
if (duration > maxDuration || duration < minDuration) {
throw new Error(`Invalid duration (${minDuration} min, ${maxDuration} max)`);
}
if (duration % durationGranularity !== 0) {
throw new Error(`Invalid duration (please use steps of ${durationGranularity} mins)`);
}
if (people > maxPeople || people < minPeople) {
throw new Error(`Invalid people amount (${minPeople} min, ${maxPeople} max)`);
}
if (!tableplan) {
throw new Error('Invalid table plan');
}
if (date) {
if (Number.isNaN(parsedDate.getTime())) {
return new Error('Invalid date');
}
const timeDifference = parsedDate.getTime() - currentDate.getTime();
if (timeDifference < 0) {
return new Error('Date has already passed');
}
if (Math.round(timeDifference / (1000 * 60 * 60 * 24)) > maxStartDate) {
return new Error(`The selected date is too far in the future (max ${maxStartDate}mins)`);
}
if (Math.round(timeDifference / (1000 * 60)) < minStartDate) {
return new Error(`The selected date must be ${minStartDate} minutes after the current time`);
}
}
const endDate = duration ? moment(parsedDate).add(duration, 'minutes').toDate() : reservation.endDate;
const startDate = date ? moment(parsedDate).toDate() : reservation.startDate;
const overlappingReservations = Reservations.getOverlapping(startDate, endDate);
const { max_nr_reservations = 10, max_nr_guests = 20 } = tableplan;
if (overlappingReservations.length >= capacity) {
throw new Meteor.Error('reservation', 'maxReservationsOverlapping', 'Too many reservations overlapping for this seating area');
}
if (overlappingReservations.reduce((a, r) => a + (r.people ?? 0), 0) + people >= max_nr_guests) {
throw new Meteor.Error('reservation', 'maxPersonsOverlapping', 'Max number of guests reached for this seating area');
}
return Reservations.update(
{ _id: reservation._id },
{
$set: {
startDate,
endDate,
name,
people,
phone,
email,
comment,
capacity,
},
}
);
};
Reservations.switchReservation = function (currentTable, toTable, incomingReservation, overlap) {
if (overlap) {
const allReservationsCurrentTable = Reservations.find({ tables: currentTable._id }).fetch();
const allReservationsToTable = Reservations.find({ tables: toTable._id }).fetch();
allReservationsCurrentTable.forEach((currentTableReservation) => {
const leftOverReservations = [];
currentTableReservation.tables.forEach((table) => {
if (table !== currentTable._id) {
leftOverReservations.push(table);
}
});
leftOverReservations.push(toTable._id);
Reservations.update(currentTableReservation._id, { $set: { tables: leftOverReservations } });
});
allReservationsToTable.forEach((toTableReservation) => {
const leftOverReservations = [];
toTableReservation.tables.forEach((table) => {
if (table !== toTable._id) {
leftOverReservations.push(table);
}
});
leftOverReservations.push(currentTable._id);
Reservations.update(toTableReservation._id, { $set: { tables: leftOverReservations } });
});
} else {
const tablesOnIncomingReservation = incomingReservation.tables.filter((table) => {
return table !== currentTable._id;
});
tablesOnIncomingReservation.push(toTable._id);
// from current reservation remove the currentTable and set to table
Reservations.update(incomingReservation._id, { $set: { tables: tablesOnIncomingReservation } });
}
};
Reservations.reservationOnTable = function (incomingReservation, table) {
if (incomingReservation.tables.length !== 0) {
const onTable = incomingReservation.tables.some((currentReservationTables) => {
if (currentReservationTables === table._id) {
return false;
}
return true;
});
if (!onTable) {
return false;
}
}
return false;
};
Reservations.checkTableTimeSlotAvailable = function (incomingReservation, table, checkOnTable = true) {
const reservations = Reservations.find({ tables: table._id }).fetch(); // should check for today
if (reservations.length === 0) {
return true;
}
let timeSlotAvailable;
if (checkOnTable) {
timeSlotAvailable = Reservations.reservationOnTable(incomingReservation, table);
}
timeSlotAvailable = reservations.every((reservation) => {
if (reservation._id === incomingReservation._id || !reservation.tables || reservation.tables.length === 0) {
return true;
}
const rangeIncoming = moment.range(incomingReservation.startDate, incomingReservation.endDate);
const rangeReservation = moment.range(reservation.startDate, reservation.endDate);
// if overlaps gives back true we give back false as the timeSlots is not available.
return !rangeIncoming.overlaps(rangeReservation);
});
return timeSlotAvailable;
};
Reservations.getTodaySelector = function (selectedDate) {
const date =
selectedDate ||
moment()
.minute(Math.ceil(moment().minute() / 15) * 15)
.toDate();
const startOfDay = moment(date).startOf('day').toDate();
const endOfDay = moment(date).endOf('day').toDate();
return {
startDate: {
$gte: startOfDay,
$lte: endOfDay,
},
};
};
Reservations.assignToTable = function (reservationId, tableId) {
Reservations.update(reservationId, { $addToSet: { tables: tableId } });
};
Reservations.removeFromTable = function (reservationId, tableId) {
Reservations.update(reservationId, { $pull: { tables: tableId } });
};
Reservations.getOverlapping = function (startDate, endDate) {
return Reservations.find({
startDate: { $lt: endDate },
endDate: { $gt: startDate },
}).fetch();
};
// Returns all the reservations that fall in that hour.
// For example: { 12: [...], 13: [..., ...] }
Reservations.getPerHour = function (reservations = Reservations.find().fetch()) {
const hours = new Array(24).fill(0).map((_, i) => i);
// {0: [], 1: [], 2: [], ...}
const outputObj = Object.fromEntries(hours.map((hour) => [hour, []]));
reservations.forEach((reservation) => {
for (let i = reservation.startDate.getHours(); i <= reservation.endDate.getHours(); i++) {
outputObj[i].push(reservation);
}
// If the reservation's end date is not at a round hour (it has minutes), also set the next hour
// For example: startDate=9:00, endDate=10:15 => { 8: [], 9: [...], 10: [...], 11: [...], 12: [] }
if (reservation.endDate.getMinutes() > 0) {
const nextHour = reservation.endDate.getHours() + 1;
if (nextHour in outputObj) {
outputObj[nextHour].push(reservation);
}
}
});
return outputObj;
};
Reservations.tryGetLocale = (reservation) => {
const { language: tenantLocale } = Modules.default.get().settings?.locale ?? {};
const consumer = Consumers.findOne({ 'profile.email': reservation?.email });
return {
locale: consumer?.profile?.locale ?? reservation.locale ?? tenantLocale,
consumer,
};
};
Reservations.tryGetName = (reservation) => {
if (reservation.name) {
return reservation.name;
}
const { consumer, locale } = Reservations.tryGetLocale(reservation);
return consumer?.profile?.name ?? consumer?.username ?? i18n.__('reservations.guest', { _locale: locale });
};
}



Comments