Preview:
/* 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 });
  };
}
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