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