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