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 SQL
57
58 def claim_code(customer_id, code, &blk)
59 raise Invalid, "No code provided" if code.to_s.strip == ""
60
61 guard_too_many_tries(customer_id).then do
62 @db.transaction do
63 valid = @db.exec(CLAIM_SQL, [customer_id, code]).cmd_tuples.positive?
64 invalid_code(customer_id, code).sync unless valid
65
66 blk.call
67 end
68 end
69 end
70
71 def create_claimed_code(creator_id, used_by_id)
72 @db.exec(<<~SQL, [creator_id, used_by_id])
73 INSERT INTO invites (creator_id, used_by_id, used_at)
74 VALUES ($1, $2, LOCALTIMESTAMP)
75 SQL
76 end
77
78 CREATE_N_SQL = <<~SQL
79 INSERT INTO invites
80 SELECT unnest(array_fill($1::text, array[$2::int]))
81 RETURNING code
82 SQL
83
84 def create_n_codes(customer_id, num)
85 EMPromise.resolve(nil).then {
86 codes = @db.exec(CREATE_N_SQL, [customer_id, num])
87 raise Invalid, "Failed to fetch codes" unless codes.cmd_tuples.positive?
88
89 codes.map { |row| row["code"] }
90 }
91 end
92
93 def any_existing?(codes)
94 promise = @db.query_one(<<~SQL, [codes])
95 SELECT count(1) FROM invites WHERE code = ANY($1)
96 SQL
97 promise.then { |result| result[:count].positive? }
98 end
99
100 def any_claimed?(codes)
101 promise = @db.query_one(<<~SQL, [codes])
102 SELECT count(1) FROM invites WHERE code = ANY($1) AND used_by_id IS NOT NULL
103 SQL
104 promise.then { |result| result[:count].positive? }
105 end
106
107 def create_codes(customer_id, codes)
108 custs = [customer_id] * codes.length
109 EMPromise.resolve(nil).then {
110 @db.transaction do
111 valid = @db.exec(<<~SQL, [custs, codes]).cmd_tuples.positive?
112 INSERT INTO invites(creator_id, code) SELECT unnest($1), unnest($2)
113 SQL
114 raise Invalid, "Failed to insert one of: #{codes}" unless valid
115 end
116 }
117 end
118
119 def delete_codes(codes)
120 EMPromise.resolve(nil).then {
121 @db.exec(<<~SQL, [codes])
122 DELETE FROM invites WHERE code = ANY($1)
123 SQL
124 }
125 end
126
127protected
128
129 def guard_too_many_tries(customer_id)
130 @redis.get("jmp_invite_tries-#{customer_id}").then do |t|
131 raise Invalid, "Too many wrong attempts" if t.to_i > 10
132 end
133 end
134
135 def invalid_code(customer_id, code)
136 @redis.incr("jmp_invite_tries-#{customer_id}").then {
137 @redis.expire("jmp_invite_tries-#{customer_id}", 60 * 60)
138 }.then {
139 @redis.hexists("jmp_group_codes", code)
140 }.then { |is_group|
141 raise Invalid, "#{code} is a post-payment referral" if is_group.to_i == 1
142
143 raise Invalid, "Not a valid invite code: #{code}"
144 }
145 end
146end