invites_repo.rb

  1# frozen_string_literal: true
  2
  3require "multibases"
  4require "securerandom"
  5
  6require_relative "trust_level_repo"
  7
  8class InvitesRepo
  9	class Invalid < StandardError; end
 10
 11	def initialize(db=DB, redis=REDIS)
 12		@db = db
 13		@redis = redis
 14		@trust_level_repo = TrustLevelRepo.new(db: db, redis: redis)
 15	end
 16
 17	def unused_invites(customer)
 18		@trust_level_repo.find(customer).then { |tl|
 19			next [] unless tl.invite?
 20
 21			@db.query_defer(<<~SQL, [customer.customer_id])
 22				SELECT code FROM unused_invites WHERE creator_id=$1
 23			SQL
 24		}.then { |result| result.map { |row| row["code"] } }
 25	end
 26
 27	def find_or_create_group_code(customer_id)
 28		@redis.get("jmp_customer_group_code-#{customer_id}").then do |code|
 29			next code if code
 30
 31			code = Multibases.pack("base32upper", SecureRandom.bytes(4)).to_s
 32			EMPromise.all([
 33				@redis.set("jmp_customer_group_code-#{customer_id}", code),
 34				@redis.hset("jmp_group_codes", code, customer_id)
 35			]).then { code }
 36		end
 37	end
 38
 39	def stash_code(customer_id, code)
 40		return EMPromise.resolve(nil) if code.to_s.strip == ""
 41
 42		@redis.set("jmp_customer_pending_invite-#{customer_id}", code)
 43	end
 44
 45	def use_pending_group_code(customer_id)
 46		@redis.get("jmp_customer_pending_invite-#{customer_id}").then do |code|
 47			EMPromise.all([
 48				@redis.del("jmp_customer_pending_invite-#{customer_id}"),
 49				@redis.hget("jmp_group_codes", code)
 50			]).then do |(_, credit_to)|
 51				next false if credit_to.to_s.strip == ""
 52
 53				create_claimed_code(credit_to, customer_id, trusted: false)
 54				credit_to
 55			end
 56		end
 57	end
 58
 59	CLAIM_SQL = <<~SQL
 60		UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP
 61		WHERE code=$2 AND used_by_id IS NULL
 62		RETURNING creator_id
 63	SQL
 64
 65	def claim_code(customer_id, code, &blk)
 66		raise Invalid, "No code provided" if code.to_s.strip == ""
 67
 68		guard_too_many_tries(customer_id).then do
 69			@db.transaction do
 70				claimed = @db.exec(CLAIM_SQL, [customer_id, code]).first
 71				invalid_code(customer_id, code).sync unless claimed
 72
 73				blk.call(claimed)
 74			end
 75		end
 76	end
 77
 78	def create_claimed_code(creator_id, used_by_id, trusted: true)
 79		@db.exec(<<~SQL, [creator_id, used_by_id, trusted])
 80			INSERT INTO invites (creator_id, used_by_id, used_at, trusted)
 81			VALUES ($1, $2, LOCALTIMESTAMP, $3)
 82		SQL
 83	end
 84
 85	CREATE_N_SQL = <<~SQL
 86		INSERT INTO invites
 87			SELECT unnest(array_fill($1::text, array[$2::int]))
 88		RETURNING code
 89	SQL
 90
 91	def create_n_codes(customer_id, num)
 92		EMPromise.resolve(nil).then {
 93			codes = @db.exec(CREATE_N_SQL, [customer_id, num])
 94			raise Invalid, "Failed to fetch codes" unless codes.cmd_tuples.positive?
 95
 96			codes.map { |row| row["code"] }
 97		}
 98	end
 99
100	def any_existing?(codes)
101		promise = @db.query_one(<<~SQL, [codes])
102			SELECT count(1) FROM invites WHERE code = ANY($1)
103		SQL
104		promise.then { |result| result[:count].positive? }
105	end
106
107	def any_claimed?(codes)
108		promise = @db.query_one(<<~SQL, [codes])
109			SELECT count(1) FROM invites WHERE code = ANY($1) AND used_by_id IS NOT NULL
110		SQL
111		promise.then { |result| result[:count].positive? }
112	end
113
114	def create_codes(customer_id, codes)
115		custs = [customer_id] * codes.length
116		EMPromise.resolve(nil).then {
117			@db.transaction do
118				valid = @db.exec(<<~SQL, [custs, codes]).cmd_tuples.positive?
119					INSERT INTO invites(creator_id, code) SELECT unnest($1), unnest($2)
120				SQL
121				raise Invalid, "Failed to insert one of: #{codes}" unless valid
122			end
123		}
124	end
125
126	def delete_codes(codes)
127		EMPromise.resolve(nil).then {
128			@db.exec(<<~SQL, [codes])
129				DELETE FROM invites WHERE code = ANY($1)
130			SQL
131		}
132	end
133
134protected
135
136	def guard_too_many_tries(customer_id)
137		@redis.get("jmp_invite_tries-#{customer_id}").then do |t|
138			raise Invalid, "Too many wrong attempts" if t.to_i > 10
139		end
140	end
141
142	def invalid_code(customer_id, code)
143		stash_code(customer_id, code).then {
144			@redis.incr("jmp_invite_tries-#{customer_id}")
145		}.then {
146			@redis.expire("jmp_invite_tries-#{customer_id}", 60 * 60)
147		}.then {
148			@redis.hexists("jmp_group_codes", code)
149		}.then { |is_group|
150			raise Invalid, "#{code} is a post-payment referral" if is_group.to_i == 1
151
152			raise Invalid, "Not a valid invite code: #{code}"
153		}
154	end
155end