/* 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 }); }; }
Preview:
downloadDownload PNG
downloadDownload JPEG
downloadDownload SVG
Tip: You can change the style, width & colours of the snippet with the inspect tool before clicking Download!
Click to optimize width for Twitter