invites_repo.rb

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