Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
July 9, 2021 07:14 am GMT

How to Build a Booking Engine Similar to Booking.com with Ruby on Rails 6 and ElasticSearch 7

Think building a booking system is not your cup of tea? I bet, you will change your mind after reading this.

I've made it super easy for you with every steps. Here, I'll share all about building a booking engine for a rental property like booking.com using Ruby on Rails 6 and Elasticsearch 7.

When it comes to hiring Ruby developers, Elasticsearch is one of the top technology they have to know well.

I was unsure about building a Booking engine, but here is how it went:

  • It's too hard to code; maybe takes a few years
  • But what if... I just give it a try
  • Wow, this is amazing, it works!

The funny thing is, building a working "Booking engine" was easier than designing and implementing an interface that manages availability, prices, and properties of the owners.

It's a little more complex than Airbnb. Mainly because it allows hotels to manage multiple rooms with different options with their own availability/prices.

But, here is a short and high-level specification of the project for easier understanding:

We will be building a system that searches for properties by filtering:

  • Rooms available for a given date range
  • Property options
  • Room options
  • Number of guests

In my real project, there are added requirements like geo-location search, distinct availability statuses, minimum booking nights, and more.

But in this project, I've skipped adding photos, coordinates, and others as I'd like to focus ONLY on booking search.

Ruby on Rails Models

I'll be using the Property model as data model. If you've worked on something similar, you would already know what it does. It's pretty self-explanatory.

I just decided to call it Property rather than Hotel as we have different properties such as hotels, cottages, bed&breakfast, apartments, and others.

class Property < ApplicationRecord  include PropertyElastic  has_many :rooms, dependent: :destroy  has_many :booking_rooms, through: :rooms  has_and_belongs_to_many :property_options, dependent: :destroyend

The PropertyOption model has just one field: 'title'.

In my real project, property options are grouped into predefined sets, like services, comfort levels, the closest infrastructure, and others.

The Room model represents room type in a hotel. For example, if a hotel has 10 standards for 2 guests rooms and 5 rooms for 4 guests, we'll create two Room objects for this hotel.

This model should display room types that are available in a specific hotel.

If the property is a cottage that could be rented as a whole, we'll still create one Room object to describe that cottage.

The room model is a kind of room type.

class Room < ApplicationRecord  belongs_to :property  has_and_belongs_to_many :room_options  has_many :booking_rooms, dependent: :destroyend

RoomOption model has fields: 'title' and 'guests' - representing the number of people this room can host.

Preparing models for booking

Now we'll add more models to store actual rooms and their availabilities. It's quite important as based on the availability, users can book a room. Pretty straightforward!

BookingRoom model represents a real room in a hotel. Let's create as many BookingRoom objects as real rooms of specific room types in the hotel. You can customize it as needed.

class BookingRoom < ApplicationRecord  belongs_to :room  has_many :booking_room_availabilities, dependent: :destroy  enum status: %i[published draft]end

BookingRoom has property 'title', 'capacity,' and relations as described above.

The status will allow us to enable or disable room for search and booking.

Here is BookingRoomAvailability class:

class BookingRoomAvailability < ApplicationRecord  belongs_to :booking_room  enum status: %i[available closed booked]  validates_uniqueness_of :booking_room, scope: %i[day]end

And here is the migration:

class CreateBookingRoomAvailabilities < ActiveRecord::Migration[5.2]  def change    create_table :booking_room_availabilities do |t|      t.references :booking_room      t.date :day      t.integer :price      t.integer :status      t.timestamps    end  endend

Important properties of this class are 'day' and 'price'. So that particular instance of this class will store price for a specific date and for a specific BookingRoom instance.

Integrating Elasticsearch

At this point, we have everything to start building the Elasticsearch index. For integrating, we'll use two gems:

gem 'elasticsearch-model', '7.1.1'gem 'elasticsearch-rails', '7.1.1'

All the Elasticsearch logic, we'll be putting into a concern called property_elastic.rb

It contains index configuration and method to export the property as a JSON document.

require 'elasticsearch/model'module PropertyElastic  extend ActiveSupport::Concern  included do    include Elasticsearch::Model    include Elasticsearch::Model::Callbacks    settings index: { number_of_shards: 1 } do      mappings dynamic: 'false' do        indexes :id        indexes :search_body, analyzer: 'snowball'        indexes :property_options        indexes :rooms, type: 'nested', properties: {          'name' => {'type' => 'text'},          'id' => {'type' => 'long'},          'room_options' => {'type' => 'keyword'},          'guests' => { 'type' => 'long'},          'availability' => { 'type' => 'nested', properties: {            'day' => { 'type' => 'date' },            'id' => { 'type' => 'long' },            'price' => {'type' => 'long'},            'status' => { 'type' => 'keyword' },            'booking_room_id' => { 'type' => 'long'}          } }        }      end    end  end  def as_indexed_json(_options = {})    as_json(      only: %i[id updated_at]    ).merge({      property_options: property_options.pluck(:id),      search_body: title,      rooms: availabilities,      })  end  def availabilities    if booking_rooms.any?      booking_rooms.map do |br|        {          name: br.title,          id: br.id,          room_options: br.room.room_options.pluck(:id).uniq,          guests: br.guests,          availability: br.booking_room_availabilities.where("day >= ? AND day < ?", Date.today, Date.today + 6.months).map do |s|            {day: s.day.to_s, price: s.price, status: s.status, room_id: br.id, id: s.id}          end        }      end    else      rooms.map do |r|        {          name: r.title,          id: r.id,          room_options: r.room_options.pluck(:id).uniq,        }      end    end  endend

Here are a few things to note about this file:

The index mapping for Property contains two levels of nesting documents. This is where we store all room prices and availabilities.

For each property, we create a document that includes nested documents for each BookingRoom that also contains an array of documents for each date that includes price, date, and availability.

First I thought it is not very optimal or would not work fast but somehow Elasticsearch does all the magic, and it works very fast. At least on a relatively small amount of data.

Method 'as_indexed_json' is a method to export documents for indexing.

As index mapping is more or less self-explanatory, it is worth mentioning how the 'availabilities' method works.

Not every property could have defined RoomAvailabilities with prices and as per my requirements, the booking engine should be able to find both available properties and those without prices but still counting RoomOptions.

This is why the 'availabilities' method returns different data for both scenarios. This allows searching either for counting Room or BookingRoom.

Now it's time to build the heart of our booking engine.

A search!

Search for properties with available rooms

To be able to search for Properties as per our requirements, we'll create a BookingSearch service:

class BookingSearch  def self.perform(params, page = 1)    @page = page || 1    @per_page = params[:per_page]    @query = params[:q]&.strip    @params = params    search  end  def self.search    salt = @params[:salt] || "salt"    terms = {      sort: {        _script: {          script: "(doc['_id'] + '#{salt}').hashCode()",          type: "number",          order: "asc"        }      },      query: {        bool: {          must: search_terms,          filter:  {            bool: {              must: generate_term + generate_nested_term            }          }        }      },      size: @per_page,      from: (@page.to_i - 1) * @per_page    }    Property.__elasticsearch__.search(terms)  end  def self.generate_nested_term    terms_all = []    terms = []    if @params[:room_options]      @params[:room_options].split(",")&.map(&:to_i)&.each do |ro|        terms.push({terms: { "rooms.room_options" => [ro] }})      end    end    terms.push({range: { "rooms.guests" => {'gte' => @params[:guests].to_i}}})    n = {nested: {      path: "rooms",      query: {        bool: {          must: terms        }      },      inner_hits: { name: 'room' }    }}    terms_all.push(n)    Date.parse(@params[:from]).upto(Date.parse(@params[:to]) - 1.day) do |d|      terms = []      terms.push(match: { "rooms.availability.status" => 'available' })      terms.push(match: { "rooms.availability.day" => d.to_s })      n = {nested: {        path: "rooms.availability",        query: {          bool: {            must: terms          }        },        inner_hits: { name: d.to_s }      }      }      terms_all.push(n)    end    terms_all  end  def self.generate_term    terms = []    if @params[:property_options].present?      @params[:property_options].split(',').each do |lo|        terms.push(term: { property_options: lo })      end    end    terms  end  def self.search_terms    match = [@query.blank? ? { match_all: {} } : { multi_match: { query: @query, fields: %w[search_body], operator: 'and' } }]    match.push( { ids: { values: @params[:ids] } }) if @params[:ids]    match  endend

Here are some of the key points in this file:

Order is done with a random salt string that comes from outside this service. In my project, I store this random string in the user's session to have a different order for different users but keep the order the same for one user.

We use a property title as a search_body so that we can search by title.

generate_nested_term

The search query looks pretty clear, except maybe the 'generate_nested_term' part.

In this method, we extend a query for search in nested documents for available rooms.

As you can notice, we can pass {skip_availability: true} into this service to skip availability search, in this case, it would be a regular search with filtering.

To find a property with available rooms, we add conditions for each date, so that for example, when users search for a date range from 21st till 27th, we'll add 5 conditions for 21st, 22nd, 23rd, 24th, 25th, and 26th dates where we expect BookingRoomAvailability is available and has status 'available'.

inner_hits

This is an important option in our search query as later we'll take data from inner hits to show search not only as property but also to show cost for entire booking per each BookingRoom. This will also allow us to know how many rooms are available and per which cost.

Simple as that! Well, I spent a lot of time building both index and search queries but still have few issues. One of the issues is that I can't skip all BookingRoomAvailabilities from the "room" inner_hits. So that in results I have documents that include prices not only for the given date range but all available dates for every room found.

Now it's time to tinker with the code! I promise you, it's going to be a lot of fun ;)

Testing

Let's create some data for testing. We can do this in a file app/services/data_demo.rb

And a logic to retrieve rich search results with Elasticsearch inner hits:

class DataDemo  PROPERTIES = ['White', 'Blue', 'Yellow', 'Red', 'Green']  PROPERTY_OPTIONS = ['WiFi', 'Parking', 'Swimming Pool', 'Playground']  ROOM_OPTIONS = ['Kitchen', 'Kettle', 'Work table', 'TV']  ROOM_TYPES = ['Standard', 'Comfort']  def self.search    params = {}    from_search = Date.today + rand(20).days    params[:from] = from_search.to_s    params[:to] = (from_search + 3.days).to_s    params[:per_page] = 10    property_options = PROPERTY_OPTIONS.sample(1).map{|po| PropertyOption.find_by title: "po}"    room_options = ROOM_OPTIONS.sample(1).map{|ro| RoomOption.find_by title: "ro}"    params[:property_options] = property_options.map(&:id).join(',')    params[:room_options] = room_options.map(&:id).join(',')    params[:guests] = 2    res = BookingSearch.perform(params)    puts "Search for dates: #{params[:from]}..#{params[:to]}"    puts "Property options: #{property_options.map(&:title).to_sentence}"    puts "Room options: #{room_options.map(&:title).to_sentence}"    res.response.hits.hits.each do |hit|      puts "Property: #{hit._source.search_body}"      available_rooms = {}      # dive into inner hits to get detailed search data      # here we transform search result into more structured way         hit.inner_hits.each do |key, inner_hit|        if key != 'room'          inner_hit.hits.hits.each do |v|            available_rooms[v._source.room_id.to_s] ||= []            available_rooms[v._source.room_id.to_s] << { day: v._source.day, price: v._source.price }          end        else          puts "Rooms: #{inner_hit.hits.hits.count}"        end      end      # printing results      available_rooms.each do |key, ar|        booking_room = BookingRoom.find key        puts "Room: #{booking_room.room.title} / #{booking_room.title}"        total_price = 0        ar.each do |day|          puts "#{day[:day]}: $#{day[:price]}/night"          total_price += day[:price]        end        puts "Total price for #{ar.count} #{'night'.pluralize(ar.count)}: $#{total_price}"        puts "----------------------------
" end end res.response.hits["total"].value end def self.delete Property.destroy_all PropertyOption.destroy_all RoomOption.destroy_all end def self.run delete PROPERTY_OPTIONS.each { |po| PropertyOption.create(title: po) } ROOM_OPTIONS.each { |ro| RoomOption.create(title: ro) } 5.times do |i| p = Property.create(title: PROPERTIES[i]) rooms = rand(2) + 1 p.property_options = PROPERTY_OPTIONS.sample(2).map{ |po| PropertyOption.find_by title: po } rooms.times do |j| room = p.rooms.create({title: "#{ROOM_TYPES[rand(2)]} #{j}" }) room.room_options = ROOM_OPTIONS.sample(2).map{ |po| RoomOption.find_by title: po } (rand(2) + 1).times do |k| booking_room = room.booking_rooms.create title: "Room #{k+1}", status: :published, guests: rand(4) + 1 30.times do |d| booking_room.booking_room_availabilities.create day: Date.today + d.days, status: :available, price: [100, 200, 300][rand(3)] end end end end Property.__elasticsearch__.delete_index! Property.__elasticsearch__.create_index! Property.__elasticsearch__.import endend

I encourage you to play more with data in response to ElasticSearch.

To run this code, open Rails console with "rails c" and run:

DataDemo.run

This will generate an example data and will index it into ElasticSearch.

Then play with data by running:

DataDemo.search

Booking test results 1
Booking test results 2

Final thoughts

I enjoyed it a lot while building a booking engine for my pet project. There are tons of additional features out there, but I hope you get the main idea of how to build a booking search engine here.

Ruby on Rails and Elasticsearch are great tools to craft software.

If you're looking to hire great Ruby on Rails developers, consider contacting Reintech.

The code and an example app is available at GitHub: https://github.com/kiosan/elastic_booking

Let me know in the comments if this tutorial was helpful.


Original Link: https://dev.to/kiosan/how-to-build-a-booking-engine-similar-to-booking-com-with-ruby-on-rails-6-and-elasticsearch-7-40nk

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To