nexus_common/models/user/influencers.rs
1use crate::db::kv::SortOrder;
2use crate::types::DynError;
3use crate::types::StreamReach;
4use crate::types::Timeframe;
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7use std::ops::Deref;
8use tracing::debug;
9use utoipa::ToSchema;
10
11use crate::db::{queries, retrieve_from_graph, RedisOps};
12
13const GLOBAL_INFLUENCERS_PREFIX: &str = "Cache:Influencers";
14
15#[derive(Serialize, Deserialize, Debug, ToSchema, Default, Clone)]
16pub struct Influencers(pub Vec<(String, f64)>); // (user_id, score)
17
18impl RedisOps for Influencers {}
19
20// Create a Influencers instance directly from an iterator of Influencer items
21// Need it in collect()
22impl FromIterator<(String, f64)> for Influencers {
23 fn from_iter<I: IntoIterator<Item = (String, f64)>>(iter: I) -> Self {
24 Influencers(iter.into_iter().collect())
25 }
26}
27
28// Implement Deref so Influencers can be used like Vec<String>
29impl Deref for Influencers {
30 type Target = Vec<(String, f64)>;
31
32 fn deref(&self) -> &Self::Target {
33 &self.0
34 }
35}
36
37impl Influencers {
38 /// Retrieves a list of influencers based on the provided context.
39 ///
40 /// If a `user_id` is provided, the function returns influencers relevant to the user
41 /// using the specified `reach` level (e.g., friends, followers). Otherwise, it returns
42 /// global influencers. When `preview` mode is enabled, it overrides the `skip` and `limit`
43 /// values with pseudo-random values to support randomized previews
44 ///
45 /// # Arguments
46 ///
47 /// * `user_id` - Optional user ID to fetch influencers relative to the user
48 /// * `reach` - Optional reach filter (e.g., Friends, Followers) for user-scoped queries
49 /// * `skip` - Number of results to skip (ignored in preview mode)
50 /// * `limit` - Maximum number of results to return (ignored in preview mode)
51 /// * `timeframe` - Time range to filter influencer activity
52 /// * `preview` - If true, uses pseudo-random pagination to return a small randomized subset
53 ///
54 pub async fn get_influencers(
55 user_id: Option<&str>,
56 reach: Option<StreamReach>,
57 skip: usize,
58 limit: usize,
59 timeframe: Timeframe,
60 preview: bool,
61 ) -> Result<Option<Influencers>, DynError> {
62 let (skip, limit) = if preview {
63 // Generate a pseudo-random number between 0 and 97
64 // We cache 100 influencers, and pick 3 starting from this number
65 // Using modulo 98 ensures we always have room for 3 without going out of bounds
66 let skip = Utc::now().timestamp_subsec_micros() % 98;
67 debug!("Influencer preview active: skip number {}", skip);
68 (skip as usize, 3)
69 } else {
70 (skip, limit)
71 };
72 if let Some(user) = user_id.filter(|_| timeframe != Timeframe::AllTime) {
73 return Influencers::get_influencers_by_reach(
74 user,
75 reach.unwrap_or(StreamReach::Friends),
76 skip,
77 limit,
78 &timeframe,
79 )
80 .await;
81 }
82 Influencers::get_global_influencers(skip, limit, &timeframe).await
83 }
84
85 /// It first attempts to fetch a subset of global influencers from cache
86 /// based on the provided `skip` and `limit`. If the cache is empty or unavailable,
87 /// it queries the graph database for up to 100 global influencers, stores the result
88 /// in cache, and then retrieves the requested subset again from cache.
89 ///
90 /// # Arguments
91 ///
92 /// * `skip` - Number of entries to skip (for pagination)
93 /// * `limit` - Maximum number of influencers to return
94 /// * `timeframe` - The time range to filter influencer activity
95 ///
96 async fn get_global_influencers(
97 skip: usize,
98 limit: usize,
99 timeframe: &Timeframe,
100 ) -> Result<Option<Influencers>, DynError> {
101 let cached_influencers = Influencers::get_from_global_cache(skip, limit, timeframe).await?;
102 if cached_influencers.is_some() {
103 return Ok(cached_influencers);
104 }
105
106 let query = queries::get::get_global_influencers(0, 100, timeframe);
107 let result = retrieve_from_graph::<Influencers>(query, "influencers").await?;
108
109 let influencers = match result {
110 Some(influencers) => influencers,
111 None => return Ok(None),
112 };
113
114 if !influencers.is_empty() {
115 Influencers::put_to_global_cache(influencers.clone(), timeframe).await?;
116 }
117
118 Influencers::get_from_global_cache(skip, limit, timeframe).await
119 }
120
121 /// Retrieves a paginated list of global influencers from the cache for the given timeframe
122 ///
123 /// # Arguments
124 ///
125 /// * `skip` - Number of entries to skip in the sorted set
126 /// * `limit` - Maximum number of influencers to return
127 /// * `timeframe` - The time window to filter influencer rankings, used to generate the cache key
128 async fn get_from_global_cache(
129 skip: usize,
130 limit: usize,
131 timeframe: &Timeframe,
132 ) -> Result<Option<Influencers>, DynError> {
133 let ranking = match timeframe {
134 // When timeframe is AllTime, we get the influencer list directly from Sorted::Users::Influencers,
135 // which is dynamically updated with each user action and therefore needs no TTL.
136 // Had we used the cache with TTL, it would have meant a random user gets hit with
137 // a full graph lookup, if they query this right after the TTL expires.
138 Timeframe::AllTime => {
139 Influencers::try_from_index_sorted_set(
140 super::USER_INFLUENCERS_KEY_PARTS.as_slice(),
141 None,
142 None,
143 Some(skip),
144 Some(limit),
145 SortOrder::Descending,
146 None,
147 )
148 .await?
149 }
150
151 // For all other timeframes, we fallback to the cache with TTL (Cache::Influencers::Timeframe)
152 _ => {
153 let key_parts = Influencers::get_cache_key_parts(timeframe);
154 let key_parts_vector: Vec<&str> = key_parts.iter().map(|s| s.as_str()).collect();
155
156 Influencers::try_from_index_sorted_set(
157 key_parts_vector.as_slice(),
158 None,
159 None,
160 Some(skip),
161 Some(limit),
162 SortOrder::Descending,
163 Some(GLOBAL_INFLUENCERS_PREFIX),
164 )
165 .await?
166 }
167 };
168
169 Ok(ranking.map(Influencers))
170 }
171
172 /// Stores a list of global influencers in the cache as a sorted set for the given timeframe
173 ///
174 /// # Arguments
175 /// * `result` - The list of influencers with their scores to cache
176 /// * `timeframe` - The timeframe used to generate the cache key and expiry
177 async fn put_to_global_cache(
178 result: Influencers,
179 timeframe: &Timeframe,
180 ) -> Result<(), DynError> {
181 let key_parts = Influencers::get_cache_key_parts(timeframe);
182 let key_parts_vector: Vec<&str> =
183 key_parts.iter().map(|s| s.as_str()).collect::<Vec<&str>>();
184
185 // store the ranking as sorted set in cache
186 Influencers::put_index_sorted_set(
187 key_parts_vector.as_slice(),
188 result
189 .iter()
190 .map(|influencer| (influencer.1, influencer.0.as_str()))
191 .collect::<Vec<(f64, &str)>>()
192 .as_slice(),
193 Some(GLOBAL_INFLUENCERS_PREFIX),
194 Some(timeframe.to_cache_period()),
195 )
196 .await?;
197 Ok(())
198 }
199
200 /// Retrieves influencers for a user based on the given `reach` and `timeframe` from the graph
201 ///
202 /// # Arguments
203 /// * `user_id` - The ID of the user to scope the influencer query
204 /// * `reach` - The reach filter (e.g., Friends, Followers)
205 /// * `skip` - Number of results to skip (for pagination)
206 /// * `limit` - Maximum number of influencers to return
207 /// * `timeframe` - Time window to filter influencer activity
208 async fn get_influencers_by_reach(
209 user_id: &str,
210 reach: StreamReach,
211 skip: usize,
212 limit: usize,
213 timeframe: &Timeframe,
214 ) -> Result<Option<Influencers>, DynError> {
215 let query = queries::get::get_influencers_by_reach(user_id, reach, skip, limit, timeframe);
216 retrieve_from_graph::<Influencers>(query, "influencers").await
217 }
218
219 fn get_cache_key_parts(timeframe: &Timeframe) -> Vec<String> {
220 vec![timeframe.to_string()]
221 }
222
223 /// Rebuilds the global influencer cache for `AllTime` and `ThisMonth` timeframes
224 ///
225 pub async fn reindex() -> Result<(), DynError> {
226 Influencers::get_global_influencers(0, 100, &Timeframe::AllTime).await?;
227 Influencers::get_global_influencers(0, 100, &Timeframe::ThisMonth).await?;
228 Ok(())
229 }
230}