1use crate::error::StorageError;
4
5use super::accounts::DEFAULT_ACCOUNT_ID;
6use super::DbPool;
7
8pub fn estimate_cost(endpoint: &str, method: &str) -> f64 {
17 match (method, endpoint) {
18 ("GET", e) if e.starts_with("/tweets/search") => 0.005,
20 ("GET", e) if e.starts_with("/tweets/") || e == "/tweets" => 0.005,
21 ("GET", e) if e.contains("/mentions") => 0.005,
22 ("GET", e) if e.contains("/tweets") && e.starts_with("/users/") => 0.005,
23
24 ("GET", e) if e.contains("/bookmarks") => 0.005,
26 ("GET", e) if e.contains("/liked_tweets") => 0.005,
28 ("GET", e) if e.contains("/liking_users") => 0.005,
30
31 ("GET", "/users/me") => 0.010,
33 ("GET", "/users") => 0.010, ("GET", e) if e.starts_with("/users/by/username/") => 0.010,
35 ("GET", e)
36 if e.starts_with("/users/") && !e.contains("/tweets") && !e.contains("/mentions") =>
37 {
38 0.010
39 }
40
41 ("POST", "/tweets") => 0.010,
43
44 ("POST", e) if e.contains("/likes") => 0.010,
46 ("DELETE", e) if e.contains("/likes") => 0.010,
47
48 ("POST", e) if e.contains("/following") => 0.010,
50 ("DELETE", e) if e.contains("/following") => 0.010,
51
52 ("POST", e) if e.contains("/bookmarks") => 0.010,
54 ("DELETE", e) if e.contains("/bookmarks") => 0.010,
55
56 _ => 0.0,
58 }
59}
60
61#[derive(Debug, serde::Serialize)]
63pub struct XApiUsageSummary {
64 pub cost_today: f64,
65 pub cost_7d: f64,
66 pub cost_30d: f64,
67 pub cost_all_time: f64,
68 pub calls_today: i64,
69 pub calls_7d: i64,
70 pub calls_30d: i64,
71 pub calls_all_time: i64,
72}
73
74#[derive(Debug, serde::Serialize)]
76pub struct DailyXApiUsage {
77 pub date: String,
78 pub calls: i64,
79 pub cost: f64,
80}
81
82#[derive(Debug, serde::Serialize)]
84pub struct EndpointBreakdown {
85 pub endpoint: String,
86 pub method: String,
87 pub calls: i64,
88 pub cost: f64,
89 pub error_count: i64,
90}
91
92pub async fn insert_x_api_usage_for(
94 pool: &DbPool,
95 account_id: &str,
96 endpoint: &str,
97 method: &str,
98 status_code: i32,
99 cost_usd: f64,
100) -> Result<(), StorageError> {
101 sqlx::query(
102 "INSERT INTO x_api_usage (account_id, endpoint, method, status_code, cost_usd)
103 VALUES (?1, ?2, ?3, ?4, ?5)",
104 )
105 .bind(account_id)
106 .bind(endpoint)
107 .bind(method)
108 .bind(status_code)
109 .bind(cost_usd)
110 .execute(pool)
111 .await
112 .map_err(|e| StorageError::Query { source: e })?;
113 Ok(())
114}
115
116pub async fn insert_x_api_usage(
118 pool: &DbPool,
119 endpoint: &str,
120 method: &str,
121 status_code: i32,
122 cost_usd: f64,
123) -> Result<(), StorageError> {
124 insert_x_api_usage_for(
125 pool,
126 DEFAULT_ACCOUNT_ID,
127 endpoint,
128 method,
129 status_code,
130 cost_usd,
131 )
132 .await
133}
134
135pub async fn get_usage_summary_for(
137 pool: &DbPool,
138 account_id: &str,
139) -> Result<XApiUsageSummary, StorageError> {
140 let row: (f64, i64, f64, i64, f64, i64, f64, i64) = sqlx::query_as(
141 "SELECT
142 COALESCE(SUM(CASE WHEN created_at >= date('now') THEN cost_usd ELSE 0.0 END), 0.0),
143 COALESCE(SUM(CASE WHEN created_at >= date('now') THEN 1 ELSE 0 END), 0),
144 COALESCE(SUM(CASE WHEN created_at >= date('now', '-7 days') THEN cost_usd ELSE 0.0 END), 0.0),
145 COALESCE(SUM(CASE WHEN created_at >= date('now', '-7 days') THEN 1 ELSE 0 END), 0),
146 COALESCE(SUM(CASE WHEN created_at >= date('now', '-30 days') THEN cost_usd ELSE 0.0 END), 0.0),
147 COALESCE(SUM(CASE WHEN created_at >= date('now', '-30 days') THEN 1 ELSE 0 END), 0),
148 COALESCE(SUM(cost_usd), 0.0),
149 COUNT(*)
150 FROM x_api_usage
151 WHERE account_id = ?",
152 )
153 .bind(account_id)
154 .fetch_one(pool)
155 .await
156 .map_err(|e| StorageError::Query { source: e })?;
157
158 Ok(XApiUsageSummary {
159 cost_today: row.0,
160 calls_today: row.1,
161 cost_7d: row.2,
162 calls_7d: row.3,
163 cost_30d: row.4,
164 calls_30d: row.5,
165 cost_all_time: row.6,
166 calls_all_time: row.7,
167 })
168}
169
170pub async fn get_usage_summary(pool: &DbPool) -> Result<XApiUsageSummary, StorageError> {
172 get_usage_summary_for(pool, DEFAULT_ACCOUNT_ID).await
173}
174
175pub async fn get_daily_usage_for(
177 pool: &DbPool,
178 account_id: &str,
179 days: u32,
180) -> Result<Vec<DailyXApiUsage>, StorageError> {
181 let rows: Vec<(String, i64, f64)> = sqlx::query_as(
182 "SELECT
183 date(created_at) as day,
184 COUNT(*),
185 COALESCE(SUM(cost_usd), 0.0)
186 FROM x_api_usage
187 WHERE account_id = ? AND created_at >= date('now', '-' || ? || ' days')
188 GROUP BY day
189 ORDER BY day",
190 )
191 .bind(account_id)
192 .bind(days)
193 .fetch_all(pool)
194 .await
195 .map_err(|e| StorageError::Query { source: e })?;
196
197 Ok(rows
198 .into_iter()
199 .map(|(date, calls, cost)| DailyXApiUsage { date, calls, cost })
200 .collect())
201}
202
203pub async fn get_daily_usage(
205 pool: &DbPool,
206 days: u32,
207) -> Result<Vec<DailyXApiUsage>, StorageError> {
208 get_daily_usage_for(pool, DEFAULT_ACCOUNT_ID, days).await
209}
210
211pub async fn get_endpoint_breakdown_for(
213 pool: &DbPool,
214 account_id: &str,
215 days: u32,
216) -> Result<Vec<EndpointBreakdown>, StorageError> {
217 let rows: Vec<(String, String, i64, f64, i64)> = sqlx::query_as(
218 "SELECT
219 endpoint,
220 method,
221 COUNT(*),
222 COALESCE(SUM(cost_usd), 0.0),
223 COALESCE(SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END), 0)
224 FROM x_api_usage
225 WHERE account_id = ? AND created_at >= date('now', '-' || ? || ' days')
226 GROUP BY endpoint, method
227 ORDER BY COUNT(*) DESC",
228 )
229 .bind(account_id)
230 .bind(days)
231 .fetch_all(pool)
232 .await
233 .map_err(|e| StorageError::Query { source: e })?;
234
235 Ok(rows
236 .into_iter()
237 .map(
238 |(endpoint, method, calls, cost, error_count)| EndpointBreakdown {
239 endpoint,
240 method,
241 calls,
242 cost,
243 error_count,
244 },
245 )
246 .collect())
247}
248
249pub async fn get_endpoint_breakdown(
251 pool: &DbPool,
252 days: u32,
253) -> Result<Vec<EndpointBreakdown>, StorageError> {
254 get_endpoint_breakdown_for(pool, DEFAULT_ACCOUNT_ID, days).await
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::storage::init_test_db;
261
262 #[tokio::test]
263 async fn insert_and_query_summary() {
264 let pool = init_test_db().await.expect("init db");
265
266 insert_x_api_usage(&pool, "/tweets/search/recent", "GET", 200, 0.005)
267 .await
268 .expect("insert");
269
270 insert_x_api_usage(&pool, "/tweets", "POST", 201, 0.010)
271 .await
272 .expect("insert");
273
274 let summary = get_usage_summary(&pool).await.expect("summary");
275 assert_eq!(summary.calls_all_time, 2);
276 assert!((summary.cost_all_time - 0.015).abs() < f64::EPSILON);
277 }
278
279 #[tokio::test]
280 async fn endpoint_breakdown_groups_correctly() {
281 let pool = init_test_db().await.expect("init db");
282
283 insert_x_api_usage(&pool, "/tweets/search/recent", "GET", 200, 0.005)
284 .await
285 .expect("insert");
286 insert_x_api_usage(&pool, "/tweets/search/recent", "GET", 200, 0.005)
287 .await
288 .expect("insert");
289 insert_x_api_usage(&pool, "/tweets", "POST", 201, 0.010)
290 .await
291 .expect("insert");
292 insert_x_api_usage(&pool, "/tweets/search/recent", "GET", 429, 0.0)
293 .await
294 .expect("insert error");
295
296 let breakdown = get_endpoint_breakdown(&pool, 30).await.expect("breakdown");
297 assert_eq!(breakdown.len(), 2);
298
299 let search = breakdown
300 .iter()
301 .find(|b| b.endpoint == "/tweets/search/recent")
302 .unwrap();
303 assert_eq!(search.calls, 3);
304 assert_eq!(search.error_count, 1);
305
306 let post = breakdown.iter().find(|b| b.method == "POST").unwrap();
307 assert_eq!(post.calls, 1);
308 assert_eq!(post.error_count, 0);
309 }
310
311 #[tokio::test]
312 async fn empty_table_returns_zero_summary() {
313 let pool = init_test_db().await.expect("init db");
314
315 let summary = get_usage_summary(&pool).await.expect("summary");
316 assert_eq!(summary.calls_all_time, 0);
317 assert!(summary.cost_all_time.abs() < f64::EPSILON);
318 }
319
320 #[tokio::test]
321 async fn estimate_cost_known_endpoints() {
322 assert!((estimate_cost("/tweets/search/recent", "GET") - 0.005).abs() < f64::EPSILON);
323 assert!((estimate_cost("/tweets/12345", "GET") - 0.005).abs() < f64::EPSILON);
324 assert!((estimate_cost("/users/me", "GET") - 0.010).abs() < f64::EPSILON);
325 assert!((estimate_cost("/users/by/username/jack", "GET") - 0.010).abs() < f64::EPSILON);
326 assert!((estimate_cost("/tweets", "POST") - 0.010).abs() < f64::EPSILON);
327 assert!((estimate_cost("/unknown", "DELETE") - 0.0).abs() < f64::EPSILON);
328 }
329
330 #[tokio::test]
331 async fn daily_usage_returns_data() {
332 let pool = init_test_db().await.expect("init db");
333
334 insert_x_api_usage(&pool, "/tweets/search/recent", "GET", 200, 0.005)
335 .await
336 .expect("insert");
337
338 let daily = get_daily_usage(&pool, 30).await.expect("daily");
339 assert_eq!(daily.len(), 1);
340 assert_eq!(daily[0].calls, 1);
341 }
342}