AddInvites Command

Christopher Vollick created

This one adds a certain number of invites to a user, and then records
which ones were added so they can be removed in the undo case.

Change summary

forms/admin_add_invites.rb       |  10 ++
forms/admin_menu.rb              |   3 
lib/admin_actions/add_invites.rb | 116 ++++++++++++++++++++++++++++++++++
lib/admin_command.rb             |   4 
lib/invites_repo.rb              |  49 ++++++++++++++
5 files changed, 180 insertions(+), 2 deletions(-)

Detailed changes

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"
+)

forms/admin_menu.rb 🔗

@@ -14,6 +14,7 @@ field(
 		{ 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: "set_trust_level", label: "Set Trust Level" },
+		{ value: "add_invites", label: "Add Invites" }
 	]
 )

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

lib/admin_command.rb 🔗

@@ -1,6 +1,7 @@
 # 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"
@@ -178,7 +179,8 @@ class AdminCommand
 		[:financial, Simple.new(AdminAction::Financial)],
 		[:undo, Undoable.new(Undo)],
 		[:reset_declines, Undoable.new(AdminAction::ResetDeclines::Command)],
-		[:set_trust_level, Undoable.new(AdminAction::SetTrustLevel::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(

lib/invites_repo.rb 🔗

@@ -27,4 +27,53 @@ class InvitesRepo
 			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