Time based conditions in voice application


The typical telephony system processes incoming call-flow based on different times, and customers want to have control over this and handle time-ranges. The whole platform can be a multi-tenant server with different virtual PBXes.

Here are the different call-handling scenarios based on time:

  • During the night, calls should be delivered to the voicemail.
  • In the morning, lunchtime, and evening, calls should be forwarded to the cell phone.
  • During business hours, calls should be processed following standard call-flow logic.
  • On Fridays, the working hours are shorter.
  • On Saturdays, the company works only until lunchtime.
  • On Sundays, the company is closed, and calls should be routed to the voicemail.
  • Calls on public holidays should also be forwarded to the voicemail.

A long time ago, I created the following Ruby classes and ActiveRecord migrations for flexible business time management. I am now sharing this, as it could be useful for anyone dealing with time conditions in backend applications, not just telephony-related ones.

The “Item” class is a polymorphic association that points to any voice-related entity in a PBX. By reusing polymorphic database associations, it is possible to create very flexible call flow building blocks. These blocks can point to any voice-related entity in a PBX, such as an extension, voicemail, conference, etc. This allows for customizable and dynamic call handling based on time conditions.

If we trigger the “operational?” method for a given business-time collection, it should return the “item_id” or nil if the time conditions are not met (i.e., the office is closed).

Tests

1.9.3-p327 :001 > Campaign.last.business_time.operational?

Campaign Load (0.1ms)  SELECT "campaigns".* FROM "campaigns" ORDER BY "campaigns"."id" DESC LIMIT 1
BusinessTime Load (0.2ms)  SELECT "business_times".* FROM "business_times" WHERE "business_times"."id" = 1 LIMIT 1
BusinessTimeMember Load (0.3ms)  SELECT "business_time_members".* FROM "business_time_members" WHERE "business_time_members"."business_time_id" = 1 ORDER BY
  business_time_id,
  day_of_month desc,
  month desc,
  year desc,
  weekday desc,
  time_from desc,
  time_to asc

=> 0

Migrations

create_table :business_time_members do |t|
	t.string   :name, :limit => 100
	t.integer  :business_time_id
	t.time     :time_from, :default => '2000-01-01 00:00:00'
	t.time     :time_to, :default => '2000-01-01 23:59:59'
	t.integer  :weekday      # 0 - sunday, 1 - monday, 2 -tuesday, ...
	t.integer  :day_of_month # 1-31
	t.integer  :month        # 1-12
	t.integer  :year         # 2012
	t.integer  :item_id, :default => 0      # item with action!
	t.timestamps
end

create_table :business_times do |t|
	t.string  :name, :limit => 100
	t.timestamps
end

execute "INSERT INTO `business_times` (`id`, `name`)
	VALUES (1, 'Call Mon-Fri 9 to 21; Sat, Sun 11 to 19')"

# Always closed!
# It is a default condition with lowest priority and will be executed it other conditions did not met.
# BusinessTimeMember.create :business_time => business_time, :item => closed_action
execute "INSERT INTO `business_time_members` (`id`, `business_time_id`, `item_id`) VALUES (?, 1, NULL)"
# Make calls on business days (Monday to Friday) from 9:00 to 21:00,
# on weekends (Sunday and Saturday) from 11:00 to 19:00
# 0 - Sun, 6 - Sat
0.upto(6) do |day|
	time_from, time_to = if day == 0 || day == 6 then
		#[Time.parse("11:00:00"), Time.parse("18:59:59")]
		[Time.parse("00:00:00"), Time.parse("23:59:59")]
	else
		#[Time.parse("9:00:00"), Time.parse("20:59:59")]
		[Time.parse("00:00:00"), Time.parse("23:59:59")]
	end

	# item_id 0 = continue dialplan!
	# BusinessTimeMember.create :business_time => business_time,
	#   :item_id => 0, :weekday => day,
	#   :time_from => "6:00", :time_to => "17:00"
	execute "INSERT INTO `business_time_members`
		(`id`, `business_time_id`, `weekday`, `time_from`, `time_to`, `item_id`)
		VALUES (?, 1, #{day}, '#{time_from}', '#{time_to}', 0)"
end

Models

class BusinessTimeMember < ActiveRecord::Base
  #validations
  validates_presence_of :business_time_id
  validates_presence_of :item_id
  # 0 (zero) means to continue dialplan after check!
  validates_numericality_of :item_id #, :greater_than => 0

  # relations
  belongs_to :business_time
  belongs_to :item
end

class BusinessTime < ActiveRecord::Base
  has_many :business_time_members, :dependent => :destroy
  has_many :items, :as => :routable

  has_many :soho_pbxes

  # return false if no records found,
  # 0 if specified - means "continue" dialplan,
  # or item_id
  def operational?(details = { :datetime => Time.now.utc })
    d = details[:datetime]

    begin
      if self.timezone
        begin
          tz = TZInfo::Timezone.get(self.timezone)
          local = tz.utc_to_local(d)
          puts "got timezone #{self.timezone} - utc #{d}, local #{local}"
          d = local
        rescue
          puts "got exception for timezone #{self.timezone}"
        end
      end
    end

    tm = BusinessTimeMember.find(
      :all,
      :conditions => { :business_time_id => id },
      :order => "
        business_time_id,
        day_of_month desc,
        month desc,
        year desc,
        weekday desc,
        time_from desc,
        time_to asc
      "
    )

    return false if tm.empty?
    tm.each { |t|
      tf = t[:time_from] ? Time.parse(t[:time_from].strftime("%H:%M:%S")) : Time.parse("00:00:00")
      tt = t[:time_to] ? Time.parse(t[:time_to].strftime("%H:%M:%S")) : Time.parse("23:59:59")
      tw = t[:weekday] ? t[:weekday].to_i : d.wday
      td = t[:day_of_month] ? t[:day_of_month].to_i : d.day
      tm = t[:month] ? t[:month].to_i : d.month
      ty = t[:year] ? t[:year].to_i : d.year

      if ty == d.year && tm == d.month && td == d.day && tw == d.wday && d >= tf && d <= tt then
        return t.item_id
      end
    }
    return false
  end
end