diff --git a/forms/admin_add_invites.rb b/forms/admin_add_invites.rb new file mode 100644 index 0000000000000000000000000000000000000000..dd28687f0f9a298d9c99eea7240186f501fd655a --- /dev/null +++ b/forms/admin_add_invites.rb @@ -0,0 +1,10 @@ +form! +instructions "Add Invites" + +field( + var: "to_add", + type: "text-single", + datatype: "xs:integer", + label: "How many invites to add", + value: "0" +) diff --git a/forms/admin_menu.rb b/forms/admin_menu.rb index aeb8b7ddc1b36ef9b7b9fc7dc5fed41e985dcf21..bb963bcb4b969631f277a98106721cde7b9a29bd 100644 --- a/forms/admin_menu.rb +++ b/forms/admin_menu.rb @@ -1,6 +1,13 @@ form! title "Menu" +if @notice + field( + type: "fixed", + value: @notice + ) +end + field( var: "action", type: "list-single", @@ -11,6 +18,10 @@ field( { value: "info", label: "Customer Info" }, { value: "financial", label: "Customer Billing Information" }, { value: "bill_plan", label: "Bill Customer" }, - { value: "cancel_account", label: "Cancel Customer" } + { value: "cancel_account", label: "Cancel Customer" }, + { value: "undo", label: "Undo" }, + { value: "reset_declines", label: "Reset Declines" }, + { value: "set_trust_level", label: "Set Trust Level" }, + { value: "add_invites", label: "Add Invites" } ] ) diff --git a/forms/admin_set_trust_level.rb b/forms/admin_set_trust_level.rb new file mode 100644 index 0000000000000000000000000000000000000000..3f8a74a52a2408d795e91020e7afe5a491e00bfc --- /dev/null +++ b/forms/admin_set_trust_level.rb @@ -0,0 +1,11 @@ +form! +instructions "Set Trust Level" + +field( + var: "new_trust_level", + type: "list-single", + label: "Trust Level", + value: @manual || "automatic", + options: @levels.map { |lvl| { label: lvl, value: lvl } } + + [{ label: "Automatic", value: "automatic" }] +) diff --git a/forms/customer_picker.rb b/forms/customer_picker.rb index 46f3c42d7bba3bce31a1a90f2fbc785f1e6b55d4..7be0a77cd7c45dca961e28cf8868b23afc1fdba5 100644 --- a/forms/customer_picker.rb +++ b/forms/customer_picker.rb @@ -6,6 +6,13 @@ instructions( "information for you" ) +if @notice + field( + type: "fixed", + value: @notice + ) +end + field( var: "q", type: "text-single", diff --git a/lib/admin_action.rb b/lib/admin_action.rb new file mode 100644 index 0000000000000000000000000000000000000000..576140d8cbe8b8c5bc400631e53b3b12c388db5f --- /dev/null +++ b/lib/admin_action.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "delegate" + +class AdminAction + class NoOp + def to_s + "NoOp" + end + end + + module Direction + class InvalidDirection < StandardError; end + + def self.for(direction) + { + forward: Forward, + reverse: Reverse, + reforward: Reforward + }.fetch(direction.to_sym) { raise InvalidDirection } + end + + class Forward < SimpleDelegator + def with(**kwargs) + self.class.new(__getobj__.with(**kwargs)) + end + + def perform + check_forward.then { forward }.then { |x| self.class.new(x) } + end + + def to_h + super.merge(direction: :forward) + end + + def undo + Reverse.new(__getobj__.with(parent_id: id)) + end + end + + class Reverse < SimpleDelegator + def with(**kwargs) + self.class.new(__getobj__.with(**kwargs)) + end + + def perform + check_reverse.then { reverse }.then { |x| self.class.new(x) } + end + + def to_s + "UNDO(#{parent_id}) #{super}" + end + + def to_h + super.merge(direction: :reverse) + end + + def undo + Reforward.new(__getobj__) + end + end + + class Reforward < Forward + def with(**kwargs) + self.class.new(__getobj__.with(**kwargs)) + end + + def to_s + "REDO(#{parent_id}) #{super}" + end + + def to_h + super.merge(direction: :reforward) + end + + def undo + Reverse.new(__getobj__) + end + end + end + + def self.for(**kwargs) + Direction::Forward.new(new(**kwargs)) + end + + def initialize(**kwargs) + @attributes = kwargs + end + + def with(**kwargs) + self.class.new(@attributes.merge(kwargs)) + end + + def id + @attributes[:id] + end + + def parent_id + @attributes[:parent_id] + end + + def actor_id + @attributes[:actor_id] + end + + def check_forward + EMPromise.resolve(nil) + end + + def forward + EMPromise.resolve(self) + end + + def check_reverse + EMPromise.resolve(nil) + end + + def reverse + EMPromise.resolve(self) + end + + def to_h + @attributes.merge({ + class: self.class.to_s.delete_prefix("AdminAction::") + }.compact) + end + + module Isomorphic + def check_reverse + to_reverse.check_forward + end + + def reverse + # We don't want it to return the reversed one + # We want it to return itself but with the reverse state + to_reverse.forward.then { self } + end + end +end diff --git a/lib/admin_action_repo.rb b/lib/admin_action_repo.rb new file mode 100644 index 0000000000000000000000000000000000000000..4dc59a3f466f3d9f0fa771b3c0eb5275a8541d30 --- /dev/null +++ b/lib/admin_action_repo.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class AdminActionRepo + class NotFound < StandardError; end + + def initialize(redis: REDIS) + @redis = redis + end + + def build(klass:, direction:, **kwargs) + dir = AdminAction::Direction.for(direction) + dir.new(AdminAction.const_get(klass).new(**kwargs)) + end + + # I'm using hash subset test for pred + # So if you give me any keys I'll find only things where those keys are + # present and set to that value + def find(limit, max="+", **pred) + return EMPromise.resolve([]) unless limit.positive? + + xrevrange( + "admin_actions", max: max, min: "-", count: limit + ).then { |new_max, results| + next [] if results.empty? + + selected = results.select { |_id, values| pred < values } + .map { |id, values| build(id: id, **rename_class(values)) } + + find(limit - selected.length, "(#{new_max}", **pred) + .then { |r| selected + r } + } + end + + def create(action) + push_to_redis(**action.to_h).then { |id| + action.with(id: id) + } + end + +protected + + def rename_class(hash) + hash.transform_keys { |k| k == :class ? :klass : k } + end + + # Turn value into a hash, paper over redis version issue, return earliest ID + def xrevrange(stream, min:, max:, count:) + min = next_id(min[1..-1]) if min.start_with?("(") + max = previous_id(max[1..-1]) if max.start_with?("(") + + @redis.xrevrange(stream, max, min, "COUNT", count).then { |result| + next ["+", []] if result.empty? + + [ + result.last.first, # Reverse order, so this is the lowest ID + result.map { |id, values| [id, Hash[*values].transform_keys(&:to_sym)] } + ] + } + end + + # Versions of REDIS after 6.2 can just do "(#{current_id}" to make an + # exclusive version + def previous_id(current_id) + time, seq = current_id.split("-") + if seq == "0" + "#{time.to_i - 1}-18446744073709551615" + else + "#{time}-#{seq.to_i - 1}" + end + end + + # Versions of REDIS after 6.2 can just do "(#{current_id}" to make an + # exclusive version + def next_id(current_id) + time, seq = current_id.split("-") + if seq == "18446744073709551615" + "#{time.to_i + 1}-0" + else + "#{time}-#{seq.to_i + 1}" + end + end + + def push_to_redis(**kwargs) + @redis.xadd("admin_actions", "*", *kwargs.flatten) + end +end diff --git a/lib/admin_actions/add_invites.rb b/lib/admin_actions/add_invites.rb new file mode 100644 index 0000000000000000000000000000000000000000..48c457f276a5f20f1c2443f6916c37bcb9d981f9 --- /dev/null +++ b/lib/admin_actions/add_invites.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "value_semantics/monkey_patched" +require_relative "../admin_action" +require_relative "../form_to_h" + +class AdminAction + class AddInvites < AdminAction + class Command + using FormToH + + def self.for(target_customer, reply:) + EMPromise.resolve( + new(customer_id: target_customer.customer_id) + ).then { |x| + reply.call(x.form).then(&x.method(:create)) + } + end + + def initialize(**bag) + @bag = bag + end + + def form + FormTemplate.render("admin_add_invites") + end + + def create(result) + AdminAction::AddInvites.for( + **@bag, + **result.form.to_h + .reject { |_k, v| v == "nil" }.transform_keys(&:to_sym) + ) + end + end + + CodesTaken = Struct.new(:codes) do + def to_s + "One of these tokens already exists: #{codes.join(', ')}" + end + end + + CodesClaimed = Struct.new(:codes) do + def to_s + "One of these tokens is already claimed: #{codes.join(', ')}" + end + end + + class MustHaveCodes + def to_s + "Action must have list of codes to reverse" + end + end + + def customer_id + @attributes[:customer_id] + end + + def to_add + @attributes[:to_add].to_i + end + + def check_forward + EMPromise.resolve(nil) + .then { check_noop } + .then { + next nil if chosen_invites.empty? + + InvitesRepo.new.any_existing?(chosen_invites).then { |taken| + EMPromise.reject(CodesTaken.new(chosen_invites)) if taken + } + } + end + + def forward + if chosen_invites.empty? + InvitesRepo.new.create_n_codes(customer_id, to_add).then { |codes| + with(invites: codes.join("\n")) + } + else + InvitesRepo.new.create_codes(customer_id, chosen_invites).then { self } + end + end + + def check_reverse + return EMPromise.reject(MustHaveCodes.new) if chosen_invites.empty? + + InvitesRepo.new.any_claimed?(chosen_invites).then { |claimed| + EMPromise.reject(CodesClaimed.new(chosen_invites)) if claimed + } + end + + def reverse + InvitesRepo.new.delete_codes(chosen_invites).then { self } + end + + def to_s + "add_invites(#{customer_id}): #{chosen_invites.join(', ')}" + end + + protected + + def check_noop + EMPromise.reject(NoOp.new) if to_add.zero? + end + + def check_too_many + max = split_invites.length + EMPromise.reject(TooMany.new(to_add, max)) if to_add > max + end + + def chosen_invites + @attributes[:invites]&.split("\n") || [] + end + end +end diff --git a/lib/admin_actions/cancel.rb b/lib/admin_actions/cancel.rb new file mode 100644 index 0000000000000000000000000000000000000000..a45d9f32882d52404e7a80d688dcaece3d31eedf --- /dev/null +++ b/lib/admin_actions/cancel.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AdminAction + class CancelCustomer + def self.call(customer, customer_repo:, **) + m = Blather::Stanza::Message.new + m.from = CONFIG[:notify_from] + m.body = "Your JMP account has been cancelled." + customer.stanza_to(m).then { + EMPromise.all([ + customer.stanza_to(Blather::Stanza::Iq::IBR.new(:set).tap(&:remove!)), + customer.deregister!, + customer_repo.disconnect_tel(customer) + ]) + } + end + end +end diff --git a/lib/admin_actions/financial.rb b/lib/admin_actions/financial.rb new file mode 100644 index 0000000000000000000000000000000000000000..732c70d2b383f4dfce6d8d710a376b4495b2a8b8 --- /dev/null +++ b/lib/admin_actions/financial.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "../admin_action" +require_relative "../financial_info" +require_relative "../form_template" + +class AdminAction + class Financial + def self.call(customer_id, reply:, **) + new(customer_id, reply: reply).call + end + + def initialize(customer_id, reply:) + @customer_id = customer_id + @reply = reply + end + + def call + AdminFinancialInfo.for(@customer_id).then do |financial_info| + @reply.call(FormTemplate.render( + "admin_financial_info", + info: financial_info + )).then { + pay_methods(financial_info) + }.then { + transactions(financial_info) + } + end + end + + def pay_methods(financial_info) + @reply.call(FormTemplate.render( + "admin_payment_methods", + **financial_info.to_h + )) + end + + def transactions(financial_info) + @reply.call(FormTemplate.render( + "admin_transaction_list", + transactions: financial_info.transactions + )) + end + end +end diff --git a/lib/admin_actions/reset_declines.rb b/lib/admin_actions/reset_declines.rb new file mode 100644 index 0000000000000000000000000000000000000000..57bf2ee108f23da610bc11987895d8169f83ff02 --- /dev/null +++ b/lib/admin_actions/reset_declines.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "value_semantics/monkey_patched" +require_relative "../admin_action" + +class AdminAction + class ResetDeclines < AdminAction + class Command + def self.for(target_customer, **) + target_customer.declines.then { |declines| + AdminAction::ResetDeclines.for( + customer_id: target_customer.customer_id, + previous_value: declines + ) + } + end + end + + def customer_id + @attributes[:customer_id] + end + + def previous_value + @attributes[:previous_value].to_i + end + + def forward + CustomerFinancials.new(customer_id).set_declines(0).then { self } + end + + # I could make sure here that they're still set to 0 in the reverse case, so + # I know there haven't been any declines since I ran the command, but I + # think I don't care actually, and I should just set it back to what it was + # and trust the human knows what they're doing + def reverse + CustomerFinancials.new(customer_id).set_declines(previous_value) + .then { self } + end + + def to_s + "reset_declines(#{customer_id}): #{previous_value} -> 0" + end + end +end diff --git a/lib/admin_actions/set_trust_level.rb b/lib/admin_actions/set_trust_level.rb new file mode 100644 index 0000000000000000000000000000000000000000..3ffb20f688195e9fbf026ea8b695ed95be4e205a --- /dev/null +++ b/lib/admin_actions/set_trust_level.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "value_semantics/monkey_patched" +require_relative "../admin_action" +require_relative "../form_to_h" +require_relative "../trust_level_repo" + +class AdminAction + class SetTrustLevel < AdminAction + include Isomorphic + + class Command + using FormToH + + def self.for(target_customer, reply:) + TrustLevelRepo.new.find_manual(target_customer.customer_id).then { |man| + new( + man, + customer_id: target_customer.customer_id + ) + }.then { |x| + reply.call(x.form).then(&x.method(:create)) + } + end + + def initialize(manual, **bag) + @manual = manual + @bag = bag.compact + end + + def form + FormTemplate.render( + "admin_set_trust_level", + manual: @manual, + levels: TrustLevel.constants.map(&:to_s).reject { |x| x == "Manual" } + ) + end + + def create(result) + AdminAction::SetTrustLevel.for( + previous_trust_level: @manual, + **@bag, + **result.form.to_h + .reject { |_k, v| v == "automatic" }.transform_keys(&:to_sym) + ) + end + end + + InvalidLevel = Struct.new(:level, :levels) { + def to_s + "Trust level invalid: expected #{levels.join(', ')}, got #{level}" + end + } + + NoMatch = Struct.new(:expected, :actual) { + def to_s + "Trust level doesn't match: expected #{expected}, got #{actual}" + end + } + + def initialize(previous_trust_level: nil, new_trust_level: nil, **kwargs) + super( + previous_trust_level: previous_trust_level.presence, + new_trust_level: new_trust_level.presence, + **kwargs + ) + end + + def customer_id + @attributes[:customer_id] + end + + def previous_trust_level + @attributes[:previous_trust_level] + end + + def new_trust_level + @attributes[:new_trust_level] + end + + # If I don't check previous_trust_level here I could get into this + # situation: + # 1. Set from automatic to Customer + # 2. Undo + # 3. Set from automatic to Paragon + # 4. Undo the undo (redo set from automatic to customer) + # Now if I don't check previous_trust_level we'll enqueue a thing that says + # we've set from manual to customer, but that's not actually what we did! We + # set from Paragon to customer. If I undo that now I won't end up back a + # paragon, I'll end up at automatic again, which isn't the state I was in a + # second ago + def check_forward + EMPromise.all([ + check_noop, + check_valid, + check_consistent + ]) + end + + def forward + TrustLevelRepo.new.put(customer_id, new_trust_level).then { self } + end + + def to_reverse + with( + previous_trust_level: new_trust_level, + new_trust_level: previous_trust_level + ) + end + + def to_s + "set_trust_level(#{customer_id}): "\ + "#{pretty(previous_trust_level)} -> #{pretty(new_trust_level)}" + end + + protected + + def check_noop + EMPromise.reject(NoOp.new) if new_trust_level == previous_trust_level + end + + def check_valid + options = TrustLevel.constants.map(&:to_s) + return unless new_trust_level && !options.include?(new_trust_level) + + EMPromise.reject(InvalidLevel.new(new_trust_level, options)) + end + + def check_consistent + TrustLevelRepo.new.find_manual(customer_id).then { |trust| + unless previous_trust_level == trust + EMPromise.reject( + NoMatch.new(pretty(previous_trust_level), pretty(trust)) + ) + end + } + end + + def pretty(level) + level || "automatic" + end + end +end diff --git a/lib/admin_command.rb b/lib/admin_command.rb index ac7659b1a863583a52d100ecae49f2f5937e5939..5393e0a92141cc78c8aa12974c19c033670e0eb4 100644 --- a/lib/admin_command.rb +++ b/lib/admin_command.rb @@ -1,20 +1,69 @@ # frozen_string_literal: true +require_relative "admin_action_repo" +require_relative "admin_actions/add_invites" +require_relative "admin_actions/cancel" +require_relative "admin_actions/financial" +require_relative "admin_actions/reset_declines" +require_relative "admin_actions/set_trust_level" require_relative "bill_plan_command" require_relative "customer_info_form" require_relative "financial_info" require_relative "form_template" class AdminCommand - def initialize(target_customer, customer_repo) + def self.for( + target_customer, + customer_repo, + admin_action_repo=AdminActionRepo.new + ) + if target_customer + new(target_customer, customer_repo, admin_action_repo) + else + NoUser.new(customer_repo, admin_action_repo, notice: "Customer Not Found") + end + end + + class NoUser < AdminCommand + def initialize( + customer_repo, + admin_action_repo=AdminActionRepo.new, + notice: nil + ) + @customer_repo = customer_repo + @admin_action_repo = admin_action_repo + @notice = notice + end + + def start(command_action=:execute) + return Command.finish(@notice || "Done") if command_action == :complete + + reply( + FormTemplate.render("customer_picker", notice: @notice) + ).then { |response| + new_context(response.form.field("q").value, response.action) + } + end + end + + def initialize( + target_customer, + customer_repo, + admin_action_repo=AdminActionRepo.new + ) @target_customer = target_customer @customer_repo = customer_repo + @admin_action_repo = admin_action_repo end - def start + def start(command_action=:execute) @target_customer.admin_info.then { |info| - reply(info.form) - }.then { menu_or_done } + if command_action == :complete + Command.finish { |iq| iq.command << info.form } + else + reply(info.form) + end + }.then { |response| menu_or_done(response.action) } end def reply(form) @@ -24,10 +73,10 @@ class AdminCommand } end - def menu_or_done(command_action=:execute) + def menu_or_done(command_action=:execute, notice: nil) return Command.finish("Done") if command_action == :complete - reply(FormTemplate.render("admin_menu")).then do |response| + reply(FormTemplate.render("admin_menu", notice: notice)).then do |response| if response.form.field("action") handle(response.form.field("action").value, response.action) end @@ -36,20 +85,19 @@ class AdminCommand def handle(action, command_action) if respond_to?("action_#{action}") - send("action_#{action}") + send("action_#{action}").then do |notice| + menu_or_done(command_action, notice: notice) + end else new_context(action) - end.then { menu_or_done(command_action) } + end end - def new_context(q) + def new_context(q, command_action=:execute) CustomerInfoForm.new(@customer_repo) .parse_something(q).then do |new_customer| - if new_customer.respond_to?(:customer_id) - AdminCommand.new(new_customer, @customer_repo).start - else - reply(new_customer.form) - end + AdminCommand.for(new_customer, @customer_repo, @admin_action_repo) + .then { |ac| ac.start(command_action) } end end @@ -58,53 +106,84 @@ class AdminCommand new_context(@target_customer.customer_id) end - def action_financial - AdminFinancialInfo.for(@target_customer).then do |financial_info| - reply(FormTemplate.render( - "admin_financial_info", - info: financial_info - )).then { - pay_methods(financial_info) - }.then { - transactions(financial_info) - } - end - end - def action_bill_plan BillPlanCommand.for(@target_customer).call end - def notify_customer(body) - m = Blather::Stanza::Message.new - m.from = CONFIG[:notify_from] - m.body = body - @target_customer.stanza_to(m) + class Undoable + def initialize(klass) + @klass = klass + end + + def call(customer, admin_action_repo:, **) + @klass.for(customer, reply: method(:reply)).then { |action| + Command.customer.then { |actor| + action.with(actor_id: actor.customer_id).perform.then do |performed| + admin_action_repo.create(performed) + end + } + }.then { |action| "Action #{action.id}: #{action}" } + end + + def reply(form=nil, note_type: nil, note_text: nil) + Command.reply { |reply| + reply.allowed_actions = [:next, :complete] + reply.command << form if form + reply.note_type = note_type if note_type + reply.note_text = note_text if note_text + } + end end - def action_cancel_account - notify_customer("Your JMP account has been cancelled.").then { - EMPromise.all([ - @target_customer.stanza_to( - Blather::Stanza::Iq::IBR.new(:set).tap(&:remove!) - ), - @target_customer.deregister!, - @customer_repo.disconnect_tel(@target_customer) - ]) - } + class Simple + def initialize(klass) + @klass = klass + end + + def call(customer_id, customer_repo:, **) + @klass.call( + customer_id, + reply: method(:reply), + customer_repo: customer_repo + ).then { nil } + end + + def reply(form=nil, note_type: nil, note_text: nil) + Command.reply { |reply| + reply.allowed_actions = [:next, :complete] + reply.command << form if form + reply.note_type = note_type if note_type + reply.note_text = note_text if note_text + } + end end - def pay_methods(financial_info) - reply(FormTemplate.render( - "admin_payment_methods", - **financial_info.to_h - )) + class Undo + def self.for(target_customer, **) + AdminActionRepo.new + .find(1, customer_id: target_customer.customer_id) + .then { |actions| + raise "No actions found" if actions.empty? + + actions.first.undo + } + end end - def transactions(financial_info) - reply(FormTemplate.render( - "admin_transaction_list", - transactions: financial_info.transactions - )) + [ + [:cancel_account, Simple.new(AdminAction::CancelCustomer)], + [:financial, Simple.new(AdminAction::Financial)], + [:undo, Undoable.new(Undo)], + [:reset_declines, Undoable.new(AdminAction::ResetDeclines::Command)], + [:set_trust_level, Undoable.new(AdminAction::SetTrustLevel::Command)], + [:add_invites, Undoable.new(AdminAction::AddInvites::Command)] + ].each do |action, handler| + define_method("action_#{action}") do + handler.call( + @target_customer, + admin_action_repo: @admin_action_repo, + customer_repo: @customer_repo + ) + end end end diff --git a/lib/customer.rb b/lib/customer.rb index 0a378805a670438bb16b534f26139eb77c74e421..8b7361c648af5ddc7f52dbbea62ea8353e26392a 100644 --- a/lib/customer.rb +++ b/lib/customer.rb @@ -10,6 +10,7 @@ require_relative "./customer_ogm" require_relative "./customer_info" require_relative "./customer_finacials" require_relative "./backend_sgx" +require_relative "./invites_repo" require_relative "./payment_methods" require_relative "./plan" require_relative "./proxied_jid" @@ -75,10 +76,7 @@ class Customer end def unused_invites - promise = DB.query_defer(<<~SQL, [customer_id]) - SELECT code FROM unused_invites WHERE creator_id=$1 - SQL - promise.then { |result| result.map { |row| row["code"] } } + InvitesRepo.new(DB).unused_invites(customer_id) end def stanza_to(stanza) diff --git a/lib/customer_finacials.rb b/lib/customer_finacials.rb index 07c33eacc86bb502a7dda6ceac52c645fc3bb377..8ff43d40fd7f8f477a8295327fe85a0c0c19117f 100644 --- a/lib/customer_finacials.rb +++ b/lib/customer_finacials.rb @@ -42,6 +42,14 @@ class CustomerFinancials end end + def set_declines(num) + if num.positive? + REDIS.set("jmp_pay_decline-#{@customer_id}", num) + else + REDIS.del("jmp_pay_decline-#{@customer_id}") + end + end + class TransactionInfo value_semantics do transaction_id String diff --git a/lib/customer_info_form.rb b/lib/customer_info_form.rb index b4db45e16d3fb658694e60e1d7f526316b2091d3..0b3ca108caa5c15d29eca318ebc038b26ec9e9a4 100644 --- a/lib/customer_info_form.rb +++ b/lib/customer_info_form.rb @@ -13,29 +13,14 @@ class CustomerInfoForm parse_something(response.form.field("q").value) end - class NoCustomer - def form - FormTemplate.render("no_customer_info") - end - - def admin_info - self - end - - def registered? - false - end - end - def parse_something(value) - return EMPromise.resolve(NoCustomer.new) if value.to_s.empty? + return EMPromise.resolve(nil) if value.to_s.empty? EMPromise.all([ find_customer_one(value), find_customer_one(Blather::JID.new(value)), find_customer_one(ProxiedJID.proxy(value)), - find_customer_by_phone(value), - EMPromise.resolve(NoCustomer.new) + find_customer_by_phone(value) ]).then { |approaches| approaches.compact.first } end diff --git a/lib/financial_info.rb b/lib/financial_info.rb index ca394d797ad741a0aecf8af21660843c5a120025..712c261cf5039d26a7c4da969495e8301b7d640f 100644 --- a/lib/financial_info.rb +++ b/lib/financial_info.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "value_semantics/monkey_patched" +require_relative "customer_finacials" +require_relative "payment_methods" class AdminFinancialInfo value_semantics do diff --git a/lib/invites_repo.rb b/lib/invites_repo.rb new file mode 100644 index 0000000000000000000000000000000000000000..a4dac5dbadf1991665afd62534066eca47e44730 --- /dev/null +++ b/lib/invites_repo.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class InvitesRepo + class Invalid < StandardError; end + + def initialize(db=DB) + @db = db + end + + def unused_invites(customer_id) + promise = @db.query_defer(<<~SQL, [customer_id]) + SELECT code FROM unused_invites WHERE creator_id=$1 + SQL + promise.then { |result| result.map { |row| row["code"] } } + end + + def claim_code(customer_id, code, &blk) + EMPromise.resolve(nil).then do + @db.transaction do + valid = @db.exec(<<~SQL, [customer_id, code]).cmd_tuples.positive? + UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP + WHERE code=$2 AND used_by_id IS NULL + SQL + raise Invalid, "Not a valid invite code: #{code}" unless valid + + blk.call + end + end + end + + CREATE_N_SQL = <<~SQL + INSERT INTO invites + SELECT unnest(array_fill($1::text, array[$2::int])) + RETURNING code + SQL + + def create_n_codes(customer_id, num) + EMPromise.resolve(nil).then { + codes = @db.exec(CREATE_N_SQL, [customer_id, num]) + raise Invalid, "Failed to fetch codes" unless codes.cmd_tuples.positive? + + codes.map { |row| row["code"] } + } + end + + def any_existing?(codes) + promise = @db.query_one(<<~SQL, [codes]) + SELECT count(1) FROM invites WHERE code = ANY($1) + SQL + promise.then { |result| result[:count].positive? } + end + + def any_claimed?(codes) + promise = @db.query_one(<<~SQL, [codes]) + SELECT count(1) FROM invites WHERE code = ANY($1) AND used_by_id IS NOT NULL + SQL + promise.then { |result| result[:count].positive? } + end + + def create_codes(customer_id, codes) + custs = [customer_id] * codes.length + EMPromise.resolve(nil).then { + @db.transaction do + valid = @db.exec(<<~SQL, [custs, codes]).cmd_tuples.positive? + INSERT INTO invites(creator_id, code) SELECT unnest($1), unnest($2) + SQL + raise Invalid, "Failed to insert one of: #{codes}" unless valid + end + } + end + + def delete_codes(codes) + EMPromise.resolve(nil).then { + @db.exec(<<~SQL, [codes]) + DELETE FROM invites WHERE code = ANY($1) + SQL + } + end +end diff --git a/lib/registration.rb b/lib/registration.rb index c03ddea8a617a8d9d2757a963300ece74b532d11..9bb03b7d1e20ee22e02aa7ca502b7f1dc346c2c0 100644 --- a/lib/registration.rb +++ b/lib/registration.rb @@ -8,6 +8,7 @@ require_relative "./alt_top_up_form" require_relative "./bandwidth_tn_order" require_relative "./command" require_relative "./em" +require_relative "./invites_repo" require_relative "./oob" require_relative "./proxied_jid" require_relative "./tel_selections" @@ -318,8 +319,6 @@ class Registration class InviteCode Payment.kinds[:code] = method(:new) - class Invalid < StandardError; end - FIELDS = [{ var: "code", type: "text-single", @@ -353,14 +352,14 @@ class Registration verify(iq.form.field("code")&.value&.to_s) }.then { Finish.new(@customer, @tel) - }.catch_only(Invalid, &method(:invalid_code)).then(&:write) + }.catch_only(InvitesRepo::Invalid, &method(:invalid_code)).then(&:write) end protected def guard_too_many_tries REDIS.get("jmp_invite_tries-#{customer_id}").then do |t| - raise Invalid, "Too many wrong attempts" if t.to_i > 10 + raise InvitesRepo::Invalid, "Too many wrong attempts" if t.to_i > 10 end end @@ -378,16 +377,8 @@ class Registration end def verify(code) - EMPromise.resolve(nil).then do - DB.transaction do - valid = DB.exec(<<~SQL, [customer_id, code]).cmd_tuples.positive? - UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP - WHERE code=$2 AND used_by_id IS NULL - SQL - raise Invalid, "Not a valid invite code: #{code}" unless valid - - @customer.activate_plan_starting_now - end + InvitesRepo.new(DB).claim_code(customer_id, code) do + @customer.activate_plan_starting_now end end end diff --git a/lib/trust_level_repo.rb b/lib/trust_level_repo.rb index 74fbecbc8df820f3f34e4e97529ceff49647b264..6c52e27bc6e8c01a8296b2bf5d058b1073046265 100644 --- a/lib/trust_level_repo.rb +++ b/lib/trust_level_repo.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "lazy_object" require "value_semantics/monkey_patched" require_relative "trust_level" @@ -12,7 +13,7 @@ class TrustLevelRepo def find(customer) EMPromise.all([ - redis.get("jmp_customer_trust_level-#{customer.customer_id}"), + find_manual(customer.customer_id), fetch_settled_amount(customer.customer_id) ]).then do |(manual, row)| TrustLevel.for( @@ -23,6 +24,18 @@ class TrustLevelRepo end end + def find_manual(customer_id) + redis.get("jmp_customer_trust_level-#{customer_id}") + end + + def put(customer_id, trust_level) + if trust_level + redis.set("jmp_customer_trust_level-#{customer_id}", trust_level) + else + redis.del("jmp_customer_trust_level-#{customer_id}") + end + end + protected def fetch_settled_amount(customer_id) diff --git a/sgx_jmp.rb b/sgx_jmp.rb index 2c6dcd9db52dc3c0e91f9212a111aac51553c508..4233fc0df807c13543fcebf7e3b5c92fb9ea13df 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -746,14 +746,7 @@ Command.new( bandwidth_tn_repo: EmptyRepo.new(BandwidthTnRepo.new) # No CNAM in admin ) - Command.reply { |reply| - reply.allowed_actions = [:next] - reply.command << FormTemplate.render("customer_picker") - }.then { |response| - CustomerInfoForm.new(customer_repo).find_customer(response) - }.then do |target_customer| - AdminCommand.new(target_customer, customer_repo).start - end + AdminCommand::NoUser.new(customer_repo).start end }.register(self).then(&CommandList.method(:register)) diff --git a/test/test_admin_command.rb b/test/test_admin_command.rb index 1b31f3dc530cfe91f1079e0335eb683ac16d76ad..506cc3a1b5fe1dc2a1f8ad1f9dc4791de2abf734 100644 --- a/test/test_admin_command.rb +++ b/test/test_admin_command.rb @@ -4,6 +4,7 @@ require "admin_command" BackendSgx::IQ_MANAGER = Minitest::Mock.new Customer::BLATHER = Minitest::Mock.new +AdminActionRepo::REDIS = Minitest::Mock.new class AdminCommandTest < Minitest::Test def admin_command(tel="+15556667777") diff --git a/test/test_customer_info.rb b/test/test_customer_info.rb index 19e2454043001e958927285981e0b5b1da48d18c..98144d4a9b420f8cb6a5baaf8d6bbcaebeea39f3 100644 --- a/test/test_customer_info.rb +++ b/test/test_customer_info.rb @@ -129,10 +129,4 @@ class CustomerInfoTest < Minitest::Test assert_mock trust_repo end em :test_inactive_admin_info_does_not_crash - - def test_missing_customer_admin_info_does_not_crash - cust = CustomerInfoForm::NoCustomer.new - assert cust.admin_info.form - end - em :test_missing_customer_admin_info_does_not_crash end diff --git a/test/test_customer_info_form.rb b/test/test_customer_info_form.rb index 8d1d0cf2a4de3d2f686044a315ccbbdf2e6e419e..7d19e1ab617a31dc795b09f3401b9e595e372b17 100644 --- a/test/test_customer_info_form.rb +++ b/test/test_customer_info_form.rb @@ -48,10 +48,7 @@ class CustomerInfoFormTest < Minitest::Test end def test_nothing - assert_kind_of( - CustomerInfoForm::NoCustomer, - @info_form.parse_something("").sync - ) + assert_nil(@info_form.parse_something("").sync) end em :test_nothing @@ -101,19 +98,13 @@ class CustomerInfoFormTest < Minitest::Test def test_missing_customer_by_phone result = @info_form.parse_something("+17778889999").sync - assert_kind_of( - CustomerInfoForm::NoCustomer, - result - ) + assert_nil(result) end em :test_missing_customer_by_phone def test_garbage result = @info_form.parse_something("garbage").sync - assert_kind_of( - CustomerInfoForm::NoCustomer, - result - ) + assert_nil(result) end em :test_garbage end diff --git a/test/test_registration.rb b/test/test_registration.rb index dbd26191538c387fcc8f0e2e000ca3b642097e28..c2561d3af146680540921bd6288a86f88f1816f6 100644 --- a/test/test_registration.rb +++ b/test/test_registration.rb @@ -551,7 +551,7 @@ class RegistrationTest < Minitest::Test ["jmp_invite_tries-test"] ) Registration::Payment::InviteCode::DB.expect(:transaction, []) do - raise Registration::Payment::InviteCode::Invalid, "wut" + raise InvitesRepo::Invalid, "wut" end Registration::Payment::InviteCode::REDIS.expect( :incr,