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