1use super::accounts::DEFAULT_ACCOUNT_ID;
7use super::DbPool;
8use crate::error::StorageError;
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
12pub struct StrategyReportRow {
13 pub id: i64,
14 pub week_start: String,
15 pub week_end: String,
16 pub replies_sent: i64,
18 pub tweets_posted: i64,
19 pub threads_posted: i64,
20 pub target_replies: i64,
21 pub follower_start: i64,
23 pub follower_end: i64,
24 pub follower_delta: i64,
25 pub avg_reply_score: f64,
27 pub avg_tweet_score: f64,
28 pub reply_acceptance_rate: f64,
29 pub estimated_follow_conversion: f64,
30 pub top_topics_json: String,
32 pub bottom_topics_json: String,
33 pub top_content_json: String,
34 pub recommendations_json: String,
35 pub created_at: String,
37}
38
39pub async fn insert_strategy_report_for(
43 pool: &DbPool,
44 account_id: &str,
45 report: &StrategyReportRow,
46) -> Result<i64, StorageError> {
47 let result = sqlx::query(
48 "INSERT INTO strategy_reports \
49 (account_id, week_start, week_end, replies_sent, tweets_posted, threads_posted, target_replies, \
50 follower_start, follower_end, follower_delta, \
51 avg_reply_score, avg_tweet_score, reply_acceptance_rate, estimated_follow_conversion, \
52 top_topics_json, bottom_topics_json, top_content_json, recommendations_json) \
53 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
54 ON CONFLICT(week_start) DO UPDATE SET \
55 week_end = excluded.week_end, \
56 replies_sent = excluded.replies_sent, \
57 tweets_posted = excluded.tweets_posted, \
58 threads_posted = excluded.threads_posted, \
59 target_replies = excluded.target_replies, \
60 follower_start = excluded.follower_start, \
61 follower_end = excluded.follower_end, \
62 follower_delta = excluded.follower_delta, \
63 avg_reply_score = excluded.avg_reply_score, \
64 avg_tweet_score = excluded.avg_tweet_score, \
65 reply_acceptance_rate = excluded.reply_acceptance_rate, \
66 estimated_follow_conversion = excluded.estimated_follow_conversion, \
67 top_topics_json = excluded.top_topics_json, \
68 bottom_topics_json = excluded.bottom_topics_json, \
69 top_content_json = excluded.top_content_json, \
70 recommendations_json = excluded.recommendations_json",
71 )
72 .bind(account_id)
73 .bind(&report.week_start)
74 .bind(&report.week_end)
75 .bind(report.replies_sent)
76 .bind(report.tweets_posted)
77 .bind(report.threads_posted)
78 .bind(report.target_replies)
79 .bind(report.follower_start)
80 .bind(report.follower_end)
81 .bind(report.follower_delta)
82 .bind(report.avg_reply_score)
83 .bind(report.avg_tweet_score)
84 .bind(report.reply_acceptance_rate)
85 .bind(report.estimated_follow_conversion)
86 .bind(&report.top_topics_json)
87 .bind(&report.bottom_topics_json)
88 .bind(&report.top_content_json)
89 .bind(&report.recommendations_json)
90 .execute(pool)
91 .await
92 .map_err(|e| StorageError::Query { source: e })?;
93
94 Ok(result.last_insert_rowid())
95}
96
97pub async fn insert_strategy_report(
101 pool: &DbPool,
102 report: &StrategyReportRow,
103) -> Result<i64, StorageError> {
104 insert_strategy_report_for(pool, DEFAULT_ACCOUNT_ID, report).await
105}
106
107pub async fn get_strategy_report_for(
109 pool: &DbPool,
110 account_id: &str,
111 week_start: &str,
112) -> Result<Option<StrategyReportRow>, StorageError> {
113 sqlx::query_as::<_, StrategyReportRow>(
114 "SELECT * FROM strategy_reports WHERE week_start = ? AND account_id = ?",
115 )
116 .bind(week_start)
117 .bind(account_id)
118 .fetch_optional(pool)
119 .await
120 .map_err(|e| StorageError::Query { source: e })
121}
122
123pub async fn get_strategy_report(
125 pool: &DbPool,
126 week_start: &str,
127) -> Result<Option<StrategyReportRow>, StorageError> {
128 get_strategy_report_for(pool, DEFAULT_ACCOUNT_ID, week_start).await
129}
130
131pub async fn get_recent_reports_for(
133 pool: &DbPool,
134 account_id: &str,
135 limit: u32,
136) -> Result<Vec<StrategyReportRow>, StorageError> {
137 sqlx::query_as::<_, StrategyReportRow>(
138 "SELECT * FROM strategy_reports WHERE account_id = ? ORDER BY week_start DESC LIMIT ?",
139 )
140 .bind(account_id)
141 .bind(limit)
142 .fetch_all(pool)
143 .await
144 .map_err(|e| StorageError::Query { source: e })
145}
146
147pub async fn get_recent_reports(
149 pool: &DbPool,
150 limit: u32,
151) -> Result<Vec<StrategyReportRow>, StorageError> {
152 get_recent_reports_for(pool, DEFAULT_ACCOUNT_ID, limit).await
153}
154
155pub async fn delete_strategy_report_for(
157 pool: &DbPool,
158 account_id: &str,
159 week_start: &str,
160) -> Result<(), StorageError> {
161 sqlx::query("DELETE FROM strategy_reports WHERE week_start = ? AND account_id = ?")
162 .bind(week_start)
163 .bind(account_id)
164 .execute(pool)
165 .await
166 .map_err(|e| StorageError::Query { source: e })?;
167 Ok(())
168}
169
170pub async fn delete_strategy_report(pool: &DbPool, week_start: &str) -> Result<(), StorageError> {
172 delete_strategy_report_for(pool, DEFAULT_ACCOUNT_ID, week_start).await
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::storage::init_test_db;
179
180 fn sample_report(week_start: &str, week_end: &str) -> StrategyReportRow {
181 StrategyReportRow {
182 id: 0,
183 week_start: week_start.to_string(),
184 week_end: week_end.to_string(),
185 replies_sent: 42,
186 tweets_posted: 10,
187 threads_posted: 2,
188 target_replies: 5,
189 follower_start: 1000,
190 follower_end: 1050,
191 follower_delta: 50,
192 avg_reply_score: 65.5,
193 avg_tweet_score: 72.3,
194 reply_acceptance_rate: 0.25,
195 estimated_follow_conversion: 0.012,
196 top_topics_json: r#"[{"topic":"rust","avg_score":80}]"#.to_string(),
197 bottom_topics_json: "[]".to_string(),
198 top_content_json: "[]".to_string(),
199 recommendations_json: "[]".to_string(),
200 created_at: String::new(),
201 }
202 }
203
204 #[tokio::test]
205 async fn insert_and_get_report() {
206 let pool = init_test_db().await.expect("init db");
207 let report = sample_report("2026-02-24", "2026-03-02");
208
209 let id = insert_strategy_report(&pool, &report)
210 .await
211 .expect("insert");
212 assert!(id > 0);
213
214 let fetched = get_strategy_report(&pool, "2026-02-24")
215 .await
216 .expect("get")
217 .expect("should exist");
218
219 assert_eq!(fetched.week_start, "2026-02-24");
220 assert_eq!(fetched.replies_sent, 42);
221 assert_eq!(fetched.follower_delta, 50);
222 }
223
224 #[tokio::test]
225 async fn upsert_overwrites_existing() {
226 let pool = init_test_db().await.expect("init db");
227 let mut report = sample_report("2026-02-24", "2026-03-02");
228
229 insert_strategy_report(&pool, &report)
230 .await
231 .expect("insert");
232
233 report.replies_sent = 100;
234 insert_strategy_report(&pool, &report)
235 .await
236 .expect("upsert");
237
238 let fetched = get_strategy_report(&pool, "2026-02-24")
239 .await
240 .expect("get")
241 .expect("should exist");
242 assert_eq!(fetched.replies_sent, 100);
243 }
244
245 #[tokio::test]
246 async fn get_nonexistent_returns_none() {
247 let pool = init_test_db().await.expect("init db");
248 let result = get_strategy_report(&pool, "2099-01-01").await.expect("get");
249 assert!(result.is_none());
250 }
251
252 #[tokio::test]
253 async fn get_recent_reports_ordering() {
254 let pool = init_test_db().await.expect("init db");
255
256 insert_strategy_report(&pool, &sample_report("2026-02-17", "2026-02-23"))
257 .await
258 .expect("insert");
259 insert_strategy_report(&pool, &sample_report("2026-02-24", "2026-03-02"))
260 .await
261 .expect("insert");
262
263 let reports = get_recent_reports(&pool, 10).await.expect("get");
264 assert_eq!(reports.len(), 2);
265 assert_eq!(reports[0].week_start, "2026-02-24"); assert_eq!(reports[1].week_start, "2026-02-17");
267 }
268
269 #[tokio::test]
270 async fn delete_strategy_report_works() {
271 let pool = init_test_db().await.expect("init db");
272 let report = sample_report("2026-02-24", "2026-03-02");
273
274 insert_strategy_report(&pool, &report)
275 .await
276 .expect("insert");
277 delete_strategy_report(&pool, "2026-02-24")
278 .await
279 .expect("delete");
280
281 let result = get_strategy_report(&pool, "2026-02-24").await.expect("get");
282 assert!(result.is_none());
283 }
284}