tuitbot_core/storage/analytics/
engagement.rs1use super::super::accounts::DEFAULT_ACCOUNT_ID;
4use super::super::DbPool;
5use crate::error::StorageError;
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct EngagementMetric {
12 pub post_id: String,
13 pub text: Option<String>,
14 pub engagement_rate: f64,
15 pub impressions: i64,
16 pub likes: i64,
17 pub retweets: i64,
18 pub replies: i64,
19 pub bookmarks: i64,
20 pub posted_at: Option<String>,
21}
22
23pub async fn get_engagement_rate_for(
25 pool: &DbPool,
26 account_id: &str,
27 limit: u32,
28) -> Result<Vec<EngagementMetric>, StorageError> {
29 let rows = sqlx::query_as::<_, (String, Option<String>, f64, i64, i64, i64, i64, i64, Option<String>)>(
30 "SELECT post_id, NULL as text, engagement_rate, impressions, likes, retweets, replies, bookmarks, posted_at \
31 FROM engagement_metrics \
32 WHERE account_id = ? \
33 ORDER BY engagement_rate DESC \
34 LIMIT ?",
35 )
36 .bind(account_id)
37 .bind(limit as i64)
38 .fetch_all(pool)
39 .await
40 .map_err(|e| StorageError::Query { source: e })?;
41
42 Ok(rows
43 .into_iter()
44 .map(
45 |(
46 post_id,
47 text,
48 engagement_rate,
49 impressions,
50 likes,
51 retweets,
52 replies,
53 bookmarks,
54 posted_at,
55 )| {
56 EngagementMetric {
57 post_id,
58 text,
59 engagement_rate,
60 impressions,
61 likes,
62 retweets,
63 replies,
64 bookmarks,
65 posted_at,
66 }
67 },
68 )
69 .collect())
70}
71
72pub async fn get_engagement_rate(
74 pool: &DbPool,
75 limit: u32,
76) -> Result<Vec<EngagementMetric>, StorageError> {
77 get_engagement_rate_for(pool, DEFAULT_ACCOUNT_ID, limit).await
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ReachSnapshot {
83 pub date: String,
84 pub total_reach: i64,
85 pub avg_reach_per_post: f64,
86 pub post_count: i64,
87}
88
89pub async fn get_reach_for(
91 pool: &DbPool,
92 account_id: &str,
93 days: u32,
94) -> Result<Vec<ReachSnapshot>, StorageError> {
95 let rows = sqlx::query_as::<_, (String, i64, f64, i64)>(
96 "SELECT snapshot_date, total_reach, avg_reach_per_post, post_count \
97 FROM reach_snapshots \
98 WHERE account_id = ? \
99 AND snapshot_date >= date('now', '-' || ? || ' days') \
100 ORDER BY snapshot_date ASC",
101 )
102 .bind(account_id)
103 .bind(days as i64)
104 .fetch_all(pool)
105 .await
106 .map_err(|e| StorageError::Query { source: e })?;
107
108 Ok(rows
109 .into_iter()
110 .map(
111 |(date, total_reach, avg_reach_per_post, post_count)| ReachSnapshot {
112 date,
113 total_reach,
114 avg_reach_per_post,
115 post_count,
116 },
117 )
118 .collect())
119}
120
121pub async fn get_reach(pool: &DbPool, days: u32) -> Result<Vec<ReachSnapshot>, StorageError> {
123 get_reach_for(pool, DEFAULT_ACCOUNT_ID, days).await
124}
125
126pub struct UpsertEngagementInput<'a> {
129 pub post_id: &'a str,
130 pub impressions: i64,
131 pub likes: i64,
132 pub retweets: i64,
133 pub replies: i64,
134 pub bookmarks: i64,
135 pub posted_at: Option<&'a str>,
136}
137
138pub async fn upsert_engagement_metric_for(
139 pool: &DbPool,
140 account_id: &str,
141 input: UpsertEngagementInput<'_>,
142) -> Result<(), StorageError> {
143 let engagement_rate = if input.impressions > 0 {
144 (input.likes + input.retweets + input.replies + input.bookmarks) as f64
145 / input.impressions as f64
146 } else {
147 0.0
148 };
149
150 let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
151
152 sqlx::query(
153 "INSERT INTO engagement_metrics \
154 (account_id, post_id, impressions, likes, retweets, replies, bookmarks, engagement_rate, posted_at, fetched_at, created_at) \
155 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
156 ON CONFLICT(account_id, post_id) DO UPDATE SET \
157 impressions = excluded.impressions, \
158 likes = excluded.likes, \
159 retweets = excluded.retweets, \
160 replies = excluded.replies, \
161 bookmarks = excluded.bookmarks, \
162 engagement_rate = excluded.engagement_rate, \
163 fetched_at = excluded.fetched_at",
164 )
165 .bind(account_id)
166 .bind(input.post_id)
167 .bind(input.impressions)
168 .bind(input.likes)
169 .bind(input.retweets)
170 .bind(input.replies)
171 .bind(input.bookmarks)
172 .bind(engagement_rate)
173 .bind(input.posted_at)
174 .bind(&now)
175 .bind(&now)
176 .execute(pool)
177 .await
178 .map_err(|e| StorageError::Query { source: e })?;
179
180 Ok(())
181}
182
183pub async fn aggregate_reach_for(pool: &DbPool, account_id: &str) -> Result<(), StorageError> {
185 let today = Utc::now().format("%Y-%m-%d").to_string();
186
187 let row = sqlx::query_as::<_, (i64, i64)>(
189 "SELECT COALESCE(SUM(impressions), 0) as total, COUNT(*) as count \
190 FROM engagement_metrics \
191 WHERE account_id = ? AND DATE(posted_at) = ?",
192 )
193 .bind(account_id)
194 .bind(&today)
195 .fetch_optional(pool)
196 .await
197 .map_err(|e| StorageError::Query { source: e })?;
198
199 let (total_reach, post_count) = match row {
200 Some((t, c)) => (t, c),
201 None => (0, 0),
202 };
203
204 let avg_reach_per_post = if post_count > 0 {
205 total_reach as f64 / post_count as f64
206 } else {
207 0.0
208 };
209
210 let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
211
212 sqlx::query(
213 "INSERT INTO reach_snapshots \
214 (account_id, snapshot_date, total_reach, avg_reach_per_post, post_count, created_at) \
215 VALUES (?, ?, ?, ?, ?, ?) \
216 ON CONFLICT(account_id, snapshot_date) DO UPDATE SET \
217 total_reach = excluded.total_reach, \
218 avg_reach_per_post = excluded.avg_reach_per_post, \
219 post_count = excluded.post_count",
220 )
221 .bind(account_id)
222 .bind(&today)
223 .bind(total_reach)
224 .bind(avg_reach_per_post)
225 .bind(post_count)
226 .bind(&now)
227 .execute(pool)
228 .await
229 .map_err(|e| StorageError::Query { source: e })?;
230
231 Ok(())
232}
233
234#[cfg(test)]
235mod tests {
236 #[test]
237 fn engagement_rate_calculation() {
238 let likes = 10;
239 let retweets = 5;
240 let replies = 3;
241 let bookmarks = 2;
242 let impressions = 100;
243
244 let engagement_rate = (likes + retweets + replies + bookmarks) as f64 / impressions as f64;
245 assert!((engagement_rate - 0.2).abs() < 0.001);
246 }
247
248 #[test]
249 fn engagement_rate_zero_impressions() {
250 let engagement_rate = if 0 > 0 { 1.0 } else { 0.0 };
251 assert_eq!(engagement_rate, 0.0);
252 }
253
254 #[test]
255 fn reach_per_post_calculation() {
256 let total_reach = 1000;
257 let post_count = 5;
258 let avg = total_reach as f64 / post_count as f64;
259 assert_eq!(avg, 200.0);
260 }
261
262 #[test]
263 fn reach_per_post_zero_posts() {
264 let avg = if 0 > 0 { 1.0 } else { 0.0 };
265 assert_eq!(avg, 0.0);
266 }
267}