tel_selections.rb

  1# frozen_string_literal: true
  2
  3require "ruby-bandwidth-iris"
  4Faraday.default_adapter = :em_synchrony
  5
  6require "cbor"
  7require "countries"
  8
  9require_relative "area_code_repo"
 10require_relative "form_template"
 11
 12class TelSelections
 13	THIRTY_DAYS = 60 * 60 * 24 * 30
 14
 15	def initialize(redis: REDIS, db: DB, memcache: MEMCACHE)
 16		@redis = redis
 17		@memcache = memcache
 18		@db = db
 19	end
 20
 21	def set(jid, tel)
 22		@redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel.pending_value)
 23	end
 24
 25	def set_tel(jid, tel)
 26		ChooseTel::Tn::LocalInventory.fetch(tel).then do |local_inv|
 27			set(
 28				jid,
 29				local_inv || ChooseTel::Tn::Bandwidth.new(ChooseTel::Tn.new(tel))
 30			)
 31		end
 32	end
 33
 34	def delete(jid)
 35		@redis.del("pending_tel_for-#{jid}")
 36	end
 37
 38	def [](jid)
 39		@redis.get("pending_tel_for-#{jid}").then do |tel|
 40			tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
 41		end
 42	end
 43
 44	class HaveTel
 45		def initialize(tel)
 46			@tel = ChooseTel::Tn.for_pending_value(tel)
 47		end
 48
 49		def choose_tel
 50			EMPromise.resolve(@tel)
 51		end
 52	end
 53
 54	class ChooseTel
 55		class Fail < RuntimeError; end
 56
 57		def initialize(db: DB, memcache: MEMCACHE)
 58			@db = db
 59			@memcache = memcache
 60		end
 61
 62		def choose_tel(error: nil)
 63			Command.reply { |reply|
 64				reply.allowed_actions = [:next]
 65				reply.command << FormTemplate.render("tn_search", error: error)
 66			}.then do |iq|
 67				available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
 68				next available if available.is_a?(Tn::Bandwidth)
 69
 70				choose_from_list(available.tns)
 71			rescue Fail
 72				choose_tel(error: $!.to_s)
 73			end
 74		end
 75
 76		def choose_from_list(tns)
 77			raise Fail, "No numbers found, try another search." if tns.empty?
 78
 79			Command.reply { |reply|
 80				reply.allowed_actions = [:next, :prev]
 81				reply.command << FormTemplate.render("tn_list", tns: tns)
 82			}.then { |iq|
 83				choose_from_list_result(tns, iq)
 84			}
 85		end
 86
 87		def choose_from_list_result(tns, iq)
 88			tel = iq.form.field("tel")&.value
 89			return choose_tel if iq.prev? || !tel
 90
 91			tns.find { |tn| tn.tel == tel } || Tn::Bandwidth.new(Tn.new(tel))
 92		end
 93
 94		class AvailableNumber
 95			def self.for(form, db: DB, memcache: MEMCACHE)
 96				qs = form.field("q")&.value.to_s.strip
 97				return Tn.for_pending_value(qs) if qs =~ /\A\+1\d{10}\Z/
 98
 99				quantity = Quantity.for(form)
100				q = Q.for(feelinglucky(qs, form), db: db, memcache: memcache)
101
102				new(
103					q.iris_query
104					.merge(enableTNDetail: true, LCA: false),
105					q.sql_query, quantity,
106					fallback: q.fallback, memcache: memcache, db: db
107				)
108			end
109
110			ACTION_FIELD = "http://jabber.org/protocol/commands#actions"
111
112			def self.feelinglucky(q, form)
113				return q unless q.empty?
114				return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"
115
116				"810"
117			end
118
119			def initialize(
120				iris_query, sql_query, quantity,
121				fallback: [], memcache: MEMCACHE, db: DB
122			)
123				@iris_query = iris_query.merge(quantity.iris_query)
124				@sql_query = sql_query
125				@quantity = quantity
126				@fallback = fallback
127				@memcache = memcache
128				@db = db
129			end
130
131			def tns
132				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
133				unless (result = fetch_cache)
134					result = fetch_bandwidth_inventory + fetch_local_inventory.sync
135				end
136				return next_fallback if result.empty? && !@fallback.empty?
137
138				@quantity.limit(result)
139			end
140
141			def fetch_bandwidth_inventory
142				BandwidthIris::AvailableNumber
143					.list(@iris_query)
144					.map { |tn| Tn::Bandwidth.new(Tn::Option.new(**tn)) }
145			rescue BandwidthIris::APIError
146				raise Fail, $!.message
147			end
148
149			def fetch_local_inventory
150				return EMPromise.resolve([]) unless @sql_query
151
152				@db.query_defer(@sql_query[0], @sql_query[1..-1]).then { |rows|
153					rows.map { |row|
154						Tn::LocalInventory.new(Tn::Option.new(
155							full_number: row["tel"].sub(/\A\+1/, ""),
156							city: row["locality"],
157							state: row["region"]
158						), row["bandwidth_account_id"], price: row["premium_price"])
159					}
160				}
161			end
162
163			def next_fallback
164				@memcache.set(cache_key, CBOR.encode([]), 43200)
165				fallback = @fallback.shift
166				self.class.new(
167					fallback.iris_query.merge(enableTNDetail: true),
168					fallback.sql_query,
169					@quantity,
170					fallback: @fallback,
171					memcache: @memcache, db: @db
172				).tns
173			end
174
175			def fetch_cache
176				promise = EMPromise.new
177				@memcache.get(cache_key, &promise.method(:fulfill))
178				result = promise.sync
179				result ? CBOR.decode(result) : nil
180			end
181
182			def cache_key
183				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
184			end
185
186			class Quantity
187				def self.for(form)
188					return new(10) if form.field(ACTION_FIELD)&.value == "feelinglucky"
189
190					rsm_max = form.find(
191						"ns:set/ns:max",
192						ns: "http://jabber.org/protocol/rsm"
193					).first
194					return new(rsm_max.content.to_i) if rsm_max
195
196					new(10)
197				end
198
199				def initialize(quantity)
200					@quantity = quantity
201				end
202
203				def limit(result)
204					(result || [])[0..@quantity - 1]
205				end
206
207				def iris_query
208					{ quantity: [@quantity, 500].min }
209				end
210			end
211		end
212
213		class Tn
214			attr_reader :tel
215
216			def self.for_pending_value(value)
217				if value.start_with?("LocalInventory/")
218					tel, account, price =
219						value.sub(/\ALocalInventory\//, "").split("/", 3)
220					LocalInventory.new(Tn.new(tel), account, price: price.to_d)
221				else
222					Bandwidth.new(Tn.new(value))
223				end
224			end
225
226			def initialize(tel)
227				@tel = tel
228			end
229
230			def formatted_tel
231				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
232				"(#{$1}) #{$2}-#{$3}"
233			end
234
235			def to_s
236				formatted_tel
237			end
238
239			def price
240				0
241			end
242
243			def charge(*); end
244
245			class Option < Tn
246				def initialize(full_number:, city:, state:, **)
247					@tel = "+1#{full_number}"
248					@locality = city
249					@region = state
250				end
251
252				def option(label: nil)
253					op = Blather::Stanza::X::Field::Option.new(
254						value: tel,
255						label: label || to_s
256					)
257					op << reference
258					op
259				end
260
261				def reference
262					Nokogiri::XML::Builder.new { |xml|
263						xml.reference(
264							xmlns: "urn:xmpp:reference:0",
265							begin: 0,
266							end: formatted_tel.length - 1,
267							type: "data",
268							uri: "tel:#{tel}"
269						)
270					}.doc.root
271				end
272
273				def to_s
274					"#{formatted_tel} (#{@locality}, #{@region})"
275				end
276			end
277
278			class Bandwidth < SimpleDelegator
279				def pending_value
280					tel
281				end
282
283				def reserve(customer)
284					BandwidthTnReservationRepo.new.ensure(customer, tel)
285				end
286
287				def order(_, customer)
288					BandwidthTnReservationRepo.new.get(customer, tel).then do |rid|
289						BandwidthTNOrder.create(
290							tel,
291							customer_order_id: customer.customer_id,
292							reservation_id: rid
293						).then(&:poll)
294					end
295				end
296			end
297
298			class LocalInventory < SimpleDelegator
299				attr_reader :price
300
301				def initialize(tn, bandwidth_account_id, price: 0)
302					super(tn)
303					@bandwidth_account_id = bandwidth_account_id
304					@price = price || 0
305				end
306
307				def option
308					super(label: to_s)
309				end
310
311				# Creates and inserts transaction charging the customer
312				# for the phone number. If price <= 0 this is a noop.
313				# This method never checks customer balance.
314				#
315				# @param customer [Customer] the customer to charge
316				def charge(customer)
317					return if price <= 0
318
319					transaction(customer).insert
320				end
321
322				# @param customer [Customer] the customer to charge
323				def transaction(customer)
324					Transaction.new(
325						customer_id: customer.customer_id,
326						transaction_id:
327							"#{customer.customer_id}-bill-#{@tel}-at-#{Time.now.to_i}",
328						amount: -price,
329						note: "One-time charge for number: #{formatted_tel}",
330						ignore_duplicate: false
331					)
332				end
333
334				def to_s
335					super + (price.positive? ? " +$%.2f" % price : "")
336				end
337
338				def self.fetch(tn, db: DB)
339					db.query_defer("SELECT * FROM tel_inventory WHERE tel = $1", [tn])
340						.then { |rows|
341						rows.first&.then { |row|
342							new(Tn::Option.new(
343								full_number: row["tel"].sub(/\A\+1/, ""),
344								city: row["locality"],
345								state: row["region"]
346							), row["bandwidth_account_id"], price: row["premium_price"])
347						}
348					}
349				end
350
351				def pending_value
352					"LocalInventory/#{tel}/#{@bandwidth_account_id}/#{price}"
353				end
354
355				def reserve(*)
356					EMPromise.resolve(nil)
357				end
358
359				def order(db, _customer)
360					# Move always moves to wrong account, oops
361					# Also probably can't move from/to same account
362					# BandwidthTnRepo.new.move(
363					# 	tel, customer.customer_id, @bandwidth_account_id
364					# )
365					db.exec_defer("DELETE FROM tel_inventory WHERE tel = $1", [tel])
366						.then { |r| raise unless r.cmd_tuples.positive? }
367				end
368			end
369		end
370
371		class Q
372			def self.register(regex, &block)
373				@queries ||= []
374				@queries << [regex, block]
375			end
376
377			def self.for(q, **kwa)
378				q = replace_region_names(q) unless q.start_with?("~")
379
380				@queries.each do |(regex, block)|
381					match_data = (q =~ regex)
382					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
383				end
384
385				raise Fail, "Format not recognized: #{q}"
386			end
387
388			def self.replace_region_names(query)
389				ISO3166::Country[:US].subdivisions.merge(
390					ISO3166::Country[:CA].subdivisions
391				).reduce(query) do |q, (code, region)|
392					([region.name] + Array(region.unofficial_names))
393						.reduce(q) do |r, name|
394							r.sub(/#{name}\s*(?!,)/i, code)
395						end
396				end
397			end
398
399			def initialize(q, **)
400				@q = q
401			end
402
403			def fallback
404				[]
405			end
406
407			{
408				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
409				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
410				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
411			}.each do |k, args|
412				klass = const_set(
413					args[0],
414					Class.new(Q) {
415						define_method(:iris_query) do
416							{ k => @q }
417						end
418
419						define_method(:sql_query) do
420							[
421								"SELECT * FROM tel_inventory " \
422								"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
423								"+1#{@q}%"
424							]
425						end
426					}
427				)
428
429				args[1..-1].each do |regex|
430					register(regex) { |q, **| klass.new(q) }
431				end
432			end
433
434			class PostalCode < Q
435				Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
436
437				def iris_query
438					{ zip: @q }
439				end
440
441				def sql_query
442					nil
443				end
444			end
445
446			class LocalVanity < Q
447				Q.register(/\A~(.+)\Z/, &method(:new))
448
449				def iris_query
450					{ localVanity: @q }
451				end
452
453				def sql_query
454					[
455						"SELECT * FROM tel_inventory " \
456						"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
457						"%#{q_digits}%"
458					]
459				end
460
461				def q_digits
462					@q
463						.gsub(/[ABC]/i, "2")
464						.gsub(/[DEF]/i, "3")
465						.gsub(/[GHI]/i, "4")
466						.gsub(/[JKL]/i, "5")
467						.gsub(/[MNO]/i, "6")
468						.gsub(/[PQRS]/i, "7")
469						.gsub(/[TUV]/i, "8")
470						.gsub(/[WXYZ]/i, "9")
471				end
472			end
473
474			class State
475				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
476
477				STATE_MAP = {
478					"QC" => "PQ"
479				}.freeze
480
481				def initialize(state, **)
482					@state = STATE_MAP.fetch(state.upcase, state.upcase)
483				end
484
485				def fallback
486					[]
487				end
488
489				def iris_query
490					{ state: @state }
491				end
492
493				def sql_query
494					[
495						"SELECT * FROM tel_inventory " \
496						"WHERE available_after < LOCALTIMESTAMP AND region = $1",
497						@state
498					]
499				end
500
501				def to_s
502					@state
503				end
504			end
505
506			class CityState
507				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
508
509				CITY_MAP = {
510					"ajax" => "Ajax-Pickering",
511					"kitchener" => "Kitchener-Waterloo",
512					"new york" => "New York City",
513					"pickering" => "Ajax-Pickering",
514					"sault ste marie" => "sault sainte marie",
515					"sault ste. marie" => "sault sainte marie",
516					"south durham" => "Durham",
517					"township of langley" => "Langley",
518					"waterloo" => "Kitchener-Waterloo",
519					"west durham" => "Durham"
520				}.freeze
521
522				def initialize(city, state, db: DB, memcache: MEMCACHE)
523					@city = CITY_MAP.fetch(city.downcase, city)
524					@state = State.new(state)
525					@db = db
526					@memcache = memcache
527				end
528
529				def fallback
530					LazyObject.new do
531						AreaCodeRepo.new(
532							db: @db,
533							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
534						).find(to_s).sync.map { |area_code|
535							AreaCode.new(area_code)
536						}
537					end
538				end
539
540				def iris_query
541					@state.iris_query.merge(city: @city)
542				end
543
544				def sql_query
545					[
546						"SELECT * FROM tel_inventory " \
547						"WHERE available_after < LOCALTIMESTAMP " \
548						"AND region = $1 AND locality = $2",
549						@state.to_s, @city
550					]
551				end
552
553				def to_s
554					"#{@city}, #{@state}"
555				end
556			end
557		end
558	end
559end