linkio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LinkIO
6
+ module Rack
7
+ # Rack middleware that serves the LinkIO endpoints without any framework.
8
+ # Mount it in a config.ru or any Rack app:
9
+ #
10
+ # use LinkIO::Rack::Middleware, client: my_client
11
+ # run MyApp
12
+ #
13
+ # Requests it does not recognise are passed through to the wrapped app.
14
+ # Handled routes:
15
+ # GET /.well-known/apple-app-site-association
16
+ # GET /.well-known/assetlinks.json
17
+ # GET /link
18
+ # GET /pending-link
19
+ # GET /pending-link/:device_id
20
+ # POST /track-referral
21
+ # GET /referrals/:referrer_id
22
+ class Middleware
23
+ # @param app [#call] the downstream Rack app
24
+ # @param client [LinkIO::Client] defaults to the global {LinkIO.client}
25
+ def initialize(app, client: nil)
26
+ @app = app
27
+ @client = client
28
+ end
29
+
30
+ def call(env)
31
+ request = ::Rack::Request.new(env) if defined?(::Rack::Request)
32
+ method = env["REQUEST_METHOD"]
33
+ path = env["PATH_INFO"].to_s
34
+
35
+ result = route(method, path, env, request)
36
+ result ? result.to_rack : @app.call(env)
37
+ end
38
+
39
+ private
40
+
41
+ def client
42
+ @client || LinkIO.client
43
+ end
44
+
45
+ def route(method, path, env, request)
46
+ return client.well_known(path) if path.start_with?("/.well-known/")
47
+
48
+ case method
49
+ when "GET"
50
+ get_routes(path, env)
51
+ when "POST"
52
+ post_routes(path, request, env)
53
+ end
54
+ end
55
+
56
+ def get_routes(path, env)
57
+ if path == "/link"
58
+ client.handle_deep_link(Request.from_rack(env))
59
+ elsif path == "/pending-link"
60
+ fingerprint_pending_link(env)
61
+ elsif (device_id = capture(path, %r{\A/pending-link/(.+)\z}))
62
+ not_found_or(client.pending_link(device_id))
63
+ elsif (referrer_id = capture(path, %r{\A/referrals/(.+)\z}))
64
+ Result.json({ "referrals" => client.referrals(referrer_id).map(&:to_h) })
65
+ end
66
+ end
67
+
68
+ def post_routes(path, request, _env)
69
+ return unless path == "/track-referral"
70
+
71
+ body = parse_body(request)
72
+ client.track_referral(body["referralCode"], body["userId"], body["metadata"])
73
+ Result.json({ "success" => true })
74
+ end
75
+
76
+ def fingerprint_pending_link(env)
77
+ ip = Utils.client_ip(
78
+ forwarded_for: env["HTTP_X_FORWARDED_FOR"],
79
+ remote_address: env["REMOTE_ADDR"]
80
+ )
81
+ not_found_or(client.pending_link_by_fingerprint(ip, env["HTTP_USER_AGENT"]))
82
+ end
83
+
84
+ def not_found_or(deep_link)
85
+ return Result.json({ "error" => "No pending link found" }, status: 404) if deep_link.nil?
86
+
87
+ Result.json(deep_link.to_h)
88
+ end
89
+
90
+ def capture(path, regex)
91
+ match = regex.match(path)
92
+ match && match[1]
93
+ end
94
+
95
+ def parse_body(request)
96
+ raw = request&.body&.read.to_s
97
+ return {} if raw.empty?
98
+
99
+ JSON.parse(raw)
100
+ rescue JSON::ParserError
101
+ {}
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkIO
4
+ # A referral relationship between a referrer and a referee.
5
+ # `timestamp` is epoch milliseconds, matching the Node.js backend.
6
+ class ReferralData
7
+ attr_reader :referrer_id, :referee_id, :referral_code, :timestamp, :metadata
8
+
9
+ # @param referrer_id [String]
10
+ # @param referee_id [String]
11
+ # @param referral_code [String]
12
+ # @param timestamp [Integer] epoch milliseconds
13
+ # @param metadata [Hash, nil]
14
+ def initialize(referrer_id:, referee_id:, referral_code:, timestamp:, metadata: nil)
15
+ @referrer_id = referrer_id
16
+ @referee_id = referee_id
17
+ @referral_code = referral_code
18
+ @timestamp = timestamp
19
+ @metadata = metadata
20
+ end
21
+
22
+ # @return [Hash]
23
+ def to_h
24
+ {
25
+ "referrerId" => referrer_id,
26
+ "refereeId" => referee_id,
27
+ "referralCode" => referral_code,
28
+ "timestamp" => timestamp,
29
+ "metadata" => metadata
30
+ }
31
+ end
32
+
33
+ # Build from a hash using either camelCase or snake_case keys.
34
+ #
35
+ # @param hash [Hash]
36
+ # @return [ReferralData]
37
+ def self.from_h(hash)
38
+ h = hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
39
+ new(
40
+ referrer_id: h["referrerId"] || h["referrer_id"],
41
+ referee_id: h["refereeId"] || h["referee_id"],
42
+ referral_code: h["referralCode"] || h["referral_code"],
43
+ timestamp: h["timestamp"],
44
+ metadata: h["metadata"]
45
+ )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkIO
4
+ # A framework-agnostic view of an incoming HTTP request, containing only what
5
+ # {LinkIO::Client#handle_deep_link} needs. Build one directly, from a Rack
6
+ # env with {from_rack}, or let the Rails engine construct it for you.
7
+ class Request
8
+ attr_reader :user_agent, :full_url, :query_params, :path_params, :client_ip, :device_id
9
+
10
+ # @param user_agent [String, nil]
11
+ # @param full_url [String] absolute request URL including query string
12
+ # @param query_params [Hash] parsed query parameters
13
+ # @param path_params [Hash] route/path parameters to merge into params
14
+ # @param client_ip [String] resolved client IP (proxy-aware)
15
+ # @param device_id [String, nil] explicit device id (query "deviceId" or X-Device-Id)
16
+ def initialize(full_url:, client_ip:, user_agent: nil, query_params: {}, path_params: {}, device_id: nil)
17
+ @user_agent = user_agent.to_s
18
+ @full_url = full_url
19
+ @query_params = stringify(query_params)
20
+ @path_params = stringify(path_params)
21
+ @client_ip = client_ip
22
+ @device_id = device_id
23
+ end
24
+
25
+ # Merged query + path params (path params win), matching the Node backend's
26
+ # behaviour of overlaying `req.params` onto the parsed query params.
27
+ #
28
+ # @return [Hash{String => Object}]
29
+ def params
30
+ merged = query_params.dup
31
+ path_params.each { |k, v| merged[k] = v unless v.nil? || v.to_s.empty? }
32
+ merged
33
+ end
34
+
35
+ # Build a Request from a Rack environment hash.
36
+ #
37
+ # @param env [Hash] a Rack env
38
+ # @param path_params [Hash] optional route params (e.g. from a router)
39
+ # @return [LinkIO::Request]
40
+ def self.from_rack(env, path_params: {})
41
+ user_agent = env["HTTP_USER_AGENT"]
42
+ full_url = rack_full_url(env)
43
+ query = Utils.parse_query_params(full_url)
44
+ ip = Utils.client_ip(
45
+ forwarded_for: env["HTTP_X_FORWARDED_FOR"],
46
+ remote_address: env["REMOTE_ADDR"]
47
+ )
48
+ device_id = query["deviceId"] || env["HTTP_X_DEVICE_ID"]
49
+
50
+ new(
51
+ user_agent: user_agent,
52
+ full_url: full_url,
53
+ query_params: query,
54
+ path_params: path_params,
55
+ client_ip: ip,
56
+ device_id: device_id
57
+ )
58
+ end
59
+
60
+ def self.rack_full_url(env)
61
+ scheme = env["rack.url_scheme"] || "http"
62
+ host = env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
63
+ path = env["PATH_INFO"].to_s
64
+ query = env["QUERY_STRING"].to_s
65
+ url = "#{scheme}://#{host}#{path}"
66
+ query.empty? ? url : "#{url}?#{query}"
67
+ end
68
+ private_class_method :rack_full_url
69
+
70
+ private
71
+
72
+ def stringify(hash)
73
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LinkIO
6
+ # A framework-agnostic description of the response the caller should send.
7
+ # {LinkIO::Client} returns these so that the same logic can be wired into
8
+ # Rack, Rails, Sinatra, or anything else.
9
+ #
10
+ # @!attribute [r] type
11
+ # @return [Symbol] one of :redirect, :html, :json
12
+ class Result
13
+ # Header names are lowercase to satisfy Rack 3's strict lint (Rack 2 and
14
+ # Rails accept them too).
15
+ HTML_HEADERS = { "content-type" => "text/html; charset=utf-8" }.freeze
16
+ JSON_HEADERS = { "content-type" => "application/json; charset=utf-8" }.freeze
17
+
18
+ attr_reader :type, :status, :body, :headers
19
+
20
+ def initialize(type:, status:, body:, headers: {})
21
+ @type = type
22
+ @status = status
23
+ @body = body
24
+ @headers = headers
25
+ end
26
+
27
+ # @return [Result] a 302 redirect to +url+
28
+ def self.redirect(url, status: 302)
29
+ new(type: :redirect, status: status, body: url, headers: { "location" => url })
30
+ end
31
+
32
+ # @return [Result] an HTML response
33
+ def self.html(body, status: 200)
34
+ new(type: :html, status: status, body: body, headers: HTML_HEADERS.dup)
35
+ end
36
+
37
+ # @param object [Object] serialized to JSON for the body
38
+ # @return [Result] a JSON response (body kept as the raw object too)
39
+ def self.json(object, status: 200)
40
+ new(type: :json, status: status, body: object, headers: JSON_HEADERS.dup)
41
+ end
42
+
43
+ def redirect?
44
+ type == :redirect
45
+ end
46
+
47
+ def html?
48
+ type == :html
49
+ end
50
+
51
+ def json?
52
+ type == :json
53
+ end
54
+
55
+ # The location for redirect results.
56
+ # @return [String, nil]
57
+ def location
58
+ headers["location"]
59
+ end
60
+
61
+ # The response body as a string, JSON-encoding json results.
62
+ # @return [String]
63
+ def body_string
64
+ json? ? JSON.generate(body) : body.to_s
65
+ end
66
+
67
+ # Convert to a standard Rack response triple.
68
+ #
69
+ # @return [Array(Integer, Hash, Array<String>)]
70
+ def to_rack
71
+ [status, headers.dup, [body_string]]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkIO
4
+ # Generates the HTML page that attempts to open the native app via its custom
5
+ # scheme and falls back to the app store after a timeout. This is a faithful
6
+ # port of the Node.js backend's `generateSmartRedirectPage`.
7
+ module SmartRedirect
8
+ module_function
9
+
10
+ # @param app_scheme [String] e.g. "rokart"
11
+ # @param params [Hash] parameters forwarded to the app link
12
+ # @param store_url [String] fallback store URL
13
+ # @param platform [String] {LinkIO::Platform} value
14
+ # @param package_name [String] Android package name (for the intent:// fallback)
15
+ # @param timeout [Integer] ms before falling back to the store
16
+ # @return [String] a complete HTML document
17
+ def page(app_scheme:, params:, store_url:, platform:, package_name:, timeout:)
18
+ query_string = Utils.encode_query(params)
19
+ app_uri = "#{app_scheme}://link#{"?#{query_string}" unless query_string.empty?}"
20
+
21
+ android_intent =
22
+ if platform == Platform::ANDROID
23
+ "intent://link#{"?#{query_string}" unless query_string.empty?}" \
24
+ "#Intent;scheme=#{app_scheme};package=#{package_name};end"
25
+ else
26
+ ""
27
+ end
28
+
29
+ is_android = platform == Platform::ANDROID
30
+
31
+ <<~HTML
32
+ <!DOCTYPE html>
33
+ <html>
34
+ <head>
35
+ <meta charset="utf-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1">
37
+ <title>Opening App...</title>
38
+ <style>
39
+ body {
40
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
41
+ display: flex;
42
+ justify-content: center;
43
+ align-items: center;
44
+ min-height: 100vh;
45
+ margin: 0;
46
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
47
+ color: white;
48
+ text-align: center;
49
+ }
50
+ .container { padding: 20px; }
51
+ .spinner {
52
+ width: 50px;
53
+ height: 50px;
54
+ border: 3px solid rgba(255,255,255,0.3);
55
+ border-radius: 50%;
56
+ border-top-color: white;
57
+ animation: spin 1s linear infinite;
58
+ margin: 0 auto 20px;
59
+ }
60
+ @keyframes spin { to { transform: rotate(360deg); } }
61
+ h1 { font-size: 24px; margin-bottom: 10px; }
62
+ p { opacity: 0.9; margin-bottom: 20px; }
63
+ .btn {
64
+ display: inline-block;
65
+ padding: 12px 24px;
66
+ background: white;
67
+ color: #667eea;
68
+ text-decoration: none;
69
+ border-radius: 8px;
70
+ font-weight: 600;
71
+ margin: 5px;
72
+ }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <div class="container">
77
+ <div class="spinner"></div>
78
+ <h1>Opening App...</h1>
79
+ <p>If the app doesn't open, tap below to download</p>
80
+ <a href="#{store_url}" class="btn">Download App</a>
81
+ </div>
82
+ <script>
83
+ (function() {
84
+ var appUri = "#{app_uri}";
85
+ var storeUrl = "#{store_url}";
86
+ var timeout = #{timeout};
87
+ var androidIntent = "#{android_intent}";
88
+ var isAndroid = #{is_android};
89
+
90
+ var startTime = Date.now();
91
+ var hasFocus = true;
92
+
93
+ // Track if user leaves the page (app opened)
94
+ window.addEventListener('blur', function() { hasFocus = false; });
95
+ window.addEventListener('pagehide', function() { hasFocus = false; });
96
+ document.addEventListener('visibilitychange', function() {
97
+ if (document.hidden) hasFocus = false;
98
+ });
99
+
100
+ // Try to open app
101
+ var iframe = document.createElement('iframe');
102
+ iframe.style.display = 'none';
103
+ iframe.src = appUri;
104
+ document.body.appendChild(iframe);
105
+
106
+ // Also try direct location for some browsers
107
+ setTimeout(function() {
108
+ if (hasFocus) {
109
+ window.location.href = appUri;
110
+ }
111
+ }, 100);
112
+
113
+ // Android: try intent scheme as fallback
114
+ if (isAndroid && androidIntent) {
115
+ setTimeout(function() {
116
+ if (hasFocus && Date.now() - startTime < timeout) {
117
+ window.location.href = androidIntent;
118
+ }
119
+ }, 500);
120
+ }
121
+
122
+ // Fallback to store after timeout
123
+ setTimeout(function() {
124
+ if (hasFocus && Date.now() - startTime >= timeout - 100) {
125
+ window.location.href = storeUrl;
126
+ }
127
+ }, timeout);
128
+ })();
129
+ </script>
130
+ </body>
131
+ </html>
132
+ HTML
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkIO
4
+ module Storage
5
+ # The storage interface every adapter must implement. Mirrors the
6
+ # `LinkIOStorage` interface from the Node.js backend. Subclass this (or
7
+ # duck-type it) to plug in your own backing store.
8
+ #
9
+ # All pending-link methods deal in {LinkIO::PendingLinkData}; referral
10
+ # methods deal in {LinkIO::ReferralData}.
11
+ class Base
12
+ # @param device_id [String]
13
+ # @param data [PendingLinkData]
14
+ # @return [void]
15
+ def save_pending_link(device_id, data)
16
+ raise NotImplementedError, "#{self.class}#save_pending_link is not implemented"
17
+ end
18
+
19
+ # @param device_id [String]
20
+ # @return [PendingLinkData, nil]
21
+ def get_pending_link(device_id)
22
+ raise NotImplementedError, "#{self.class}#get_pending_link is not implemented"
23
+ end
24
+
25
+ # @param device_id [String]
26
+ # @return [void]
27
+ def delete_pending_link(device_id)
28
+ raise NotImplementedError, "#{self.class}#delete_pending_link is not implemented"
29
+ end
30
+
31
+ # @param fingerprint [String]
32
+ # @param data [PendingLinkData]
33
+ # @return [void]
34
+ def save_pending_link_by_fingerprint(fingerprint, data)
35
+ raise NotImplementedError, "#{self.class}#save_pending_link_by_fingerprint is not implemented"
36
+ end
37
+
38
+ # @param fingerprint [String]
39
+ # @return [PendingLinkData, nil]
40
+ def get_pending_link_by_fingerprint(fingerprint)
41
+ raise NotImplementedError, "#{self.class}#get_pending_link_by_fingerprint is not implemented"
42
+ end
43
+
44
+ # @param fingerprint [String]
45
+ # @return [void]
46
+ def delete_pending_link_by_fingerprint(fingerprint)
47
+ raise NotImplementedError, "#{self.class}#delete_pending_link_by_fingerprint is not implemented"
48
+ end
49
+
50
+ # @param referral [ReferralData]
51
+ # @return [void]
52
+ def save_referral(referral)
53
+ raise NotImplementedError, "#{self.class}#save_referral is not implemented"
54
+ end
55
+
56
+ # @param referrer_id [String]
57
+ # @return [Array<ReferralData>]
58
+ def get_referrals_by_referrer(referrer_id)
59
+ raise NotImplementedError, "#{self.class}#get_referrals_by_referrer is not implemented"
60
+ end
61
+
62
+ # @param referee_id [String]
63
+ # @return [ReferralData, nil]
64
+ def get_referral_by_referee(referee_id)
65
+ raise NotImplementedError, "#{self.class}#get_referral_by_referee is not implemented"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkIO
4
+ module Storage
5
+ # A process-local, thread-safe in-memory store. Great for development and
6
+ # tests. Data is lost when the process exits and is not shared between
7
+ # processes, so do not use it in production with multiple workers.
8
+ #
9
+ # Expiry is enforced lazily on read (matching the Node.js backend).
10
+ class InMemoryStorage < Base
11
+ def initialize
12
+ super
13
+ @pending_links = {}
14
+ @fingerprint_links = {}
15
+ @referrals = []
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def save_pending_link(device_id, data)
20
+ @mutex.synchronize { @pending_links[device_id] = data }
21
+ nil
22
+ end
23
+
24
+ def get_pending_link(device_id)
25
+ @mutex.synchronize { fetch_unexpired(@pending_links, device_id) }
26
+ end
27
+
28
+ def delete_pending_link(device_id)
29
+ @mutex.synchronize { @pending_links.delete(device_id) }
30
+ nil
31
+ end
32
+
33
+ def save_pending_link_by_fingerprint(fingerprint, data)
34
+ @mutex.synchronize { @fingerprint_links[fingerprint] = data }
35
+ nil
36
+ end
37
+
38
+ def get_pending_link_by_fingerprint(fingerprint)
39
+ @mutex.synchronize { fetch_unexpired(@fingerprint_links, fingerprint) }
40
+ end
41
+
42
+ def delete_pending_link_by_fingerprint(fingerprint)
43
+ @mutex.synchronize { @fingerprint_links.delete(fingerprint) }
44
+ nil
45
+ end
46
+
47
+ def save_referral(referral)
48
+ @mutex.synchronize do
49
+ exists = @referrals.any? { |r| r.referee_id == referral.referee_id }
50
+ @referrals << referral unless exists
51
+ end
52
+ nil
53
+ end
54
+
55
+ def get_referrals_by_referrer(referrer_id)
56
+ @mutex.synchronize { @referrals.select { |r| r.referrer_id == referrer_id } }
57
+ end
58
+
59
+ def get_referral_by_referee(referee_id)
60
+ @mutex.synchronize { @referrals.find { |r| r.referee_id == referee_id } }
61
+ end
62
+
63
+ private
64
+
65
+ # Caller must hold the mutex.
66
+ def fetch_unexpired(store, key)
67
+ data = store[key]
68
+ return nil if data.nil?
69
+
70
+ if data.expired?
71
+ store.delete(key)
72
+ return nil
73
+ end
74
+
75
+ data
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LinkIO
6
+ module Storage
7
+ # A Redis-backed store suitable for production. Pass any client that
8
+ # responds to the standard `redis` gem methods (`get`, `set`, `del`).
9
+ # Pending links are written with a TTL so they expire automatically.
10
+ #
11
+ # require "redis"
12
+ # storage = LinkIO::Storage::RedisStorage.new(Redis.new)
13
+ #
14
+ # Keys are namespaced identically to the Node.js backend
15
+ # (`linkio:pending:*`, `linkio:fingerprint:*`, `linkio:referral:*`,
16
+ # `linkio:referrer:*`) so the two implementations can share a Redis.
17
+ class RedisStorage < Base
18
+ PENDING_PREFIX = "linkio:pending:"
19
+ FINGERPRINT_PREFIX = "linkio:fingerprint:"
20
+ REFERRAL_PREFIX = "linkio:referral:"
21
+ REFERRER_PREFIX = "linkio:referrer:"
22
+
23
+ # @param redis [Object] a redis client (e.g. `Redis.new`)
24
+ def initialize(redis)
25
+ super()
26
+ @redis = redis
27
+ end
28
+
29
+ def save_pending_link(device_id, data)
30
+ write_pending("#{PENDING_PREFIX}#{device_id}", data)
31
+ end
32
+
33
+ def get_pending_link(device_id)
34
+ read_pending("#{PENDING_PREFIX}#{device_id}")
35
+ end
36
+
37
+ def delete_pending_link(device_id)
38
+ @redis.del("#{PENDING_PREFIX}#{device_id}")
39
+ nil
40
+ end
41
+
42
+ def save_pending_link_by_fingerprint(fingerprint, data)
43
+ write_pending("#{FINGERPRINT_PREFIX}#{fingerprint}", data)
44
+ end
45
+
46
+ def get_pending_link_by_fingerprint(fingerprint)
47
+ read_pending("#{FINGERPRINT_PREFIX}#{fingerprint}")
48
+ end
49
+
50
+ def delete_pending_link_by_fingerprint(fingerprint)
51
+ @redis.del("#{FINGERPRINT_PREFIX}#{fingerprint}")
52
+ nil
53
+ end
54
+
55
+ def save_referral(referral)
56
+ return if get_referral_by_referee(referral.referee_id)
57
+
58
+ @redis.set("#{REFERRAL_PREFIX}#{referral.referee_id}", JSON.generate(referral.to_h))
59
+
60
+ referrer_key = "#{REFERRER_PREFIX}#{referral.referrer_id}"
61
+ list = read_json(referrer_key) || []
62
+ list << referral.referee_id
63
+ @redis.set(referrer_key, JSON.generate(list))
64
+ nil
65
+ end
66
+
67
+ def get_referrals_by_referrer(referrer_id)
68
+ referee_ids = read_json("#{REFERRER_PREFIX}#{referrer_id}") || []
69
+ referee_ids.map do |referee_id|
70
+ raw = @redis.get("#{REFERRAL_PREFIX}#{referee_id}")
71
+ raw && ReferralData.from_h(JSON.parse(raw))
72
+ end.compact
73
+ end
74
+
75
+ def get_referral_by_referee(referee_id)
76
+ raw = @redis.get("#{REFERRAL_PREFIX}#{referee_id}")
77
+ raw ? ReferralData.from_h(JSON.parse(raw)) : nil
78
+ end
79
+
80
+ private
81
+
82
+ def write_pending(key, data)
83
+ ttl = ((data.expires_at - (Time.now.to_f * 1000)) / 1000).floor
84
+ if ttl.positive?
85
+ @redis.set(key, JSON.generate(data.to_h), ex: ttl)
86
+ else
87
+ @redis.set(key, JSON.generate(data.to_h))
88
+ end
89
+ nil
90
+ end
91
+
92
+ def read_pending(key)
93
+ raw = @redis.get(key)
94
+ return nil if raw.nil?
95
+
96
+ data = PendingLinkData.from_h(JSON.parse(raw))
97
+ if data.expired?
98
+ @redis.del(key)
99
+ return nil
100
+ end
101
+ data
102
+ end
103
+
104
+ def read_json(key)
105
+ raw = @redis.get(key)
106
+ raw ? JSON.parse(raw) : nil
107
+ end
108
+ end
109
+ end
110
+ end