Skip to main content

tuitbot_core/storage/
strategy.rs

1//! CRUD operations for the `strategy_reports` table.
2//!
3//! Stores weekly strategy reports that aggregate engagement metrics,
4//! follower growth, top/bottom topics, and actionable recommendations.
5
6use super::accounts::DEFAULT_ACCOUNT_ID;
7use super::DbPool;
8use crate::error::StorageError;
9
10/// A persisted weekly strategy report.
11#[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    // Output volume
17    pub replies_sent: i64,
18    pub tweets_posted: i64,
19    pub threads_posted: i64,
20    pub target_replies: i64,
21    // Follower metrics
22    pub follower_start: i64,
23    pub follower_end: i64,
24    pub follower_delta: i64,
25    // Engagement metrics
26    pub avg_reply_score: f64,
27    pub avg_tweet_score: f64,
28    pub reply_acceptance_rate: f64,
29    pub estimated_follow_conversion: f64,
30    // JSON blobs (callers deserialize as needed)
31    pub top_topics_json: String,
32    pub bottom_topics_json: String,
33    pub top_content_json: String,
34    pub recommendations_json: String,
35    // Metadata
36    pub created_at: String,
37}
38
39/// Insert a new strategy report (or replace if the same `week_start` exists) for a specific account.
40///
41/// Returns the row id of the inserted report.
42pub 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
97/// Insert a new strategy report (or replace if the same `week_start` exists).
98///
99/// Returns the row id of the inserted report.
100pub 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
107/// Get a strategy report by its `week_start` date for a specific account.
108pub 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
123/// Get a strategy report by its `week_start` date (ISO date string, e.g. "2026-02-24").
124pub 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
131/// Get recent strategy reports for a specific account, newest first.
132pub 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
147/// Get recent strategy reports, newest first.
148pub 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
155/// Delete a strategy report by `week_start` for a specific account.
156pub 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
170/// Delete a strategy report by `week_start` (used for refresh/recompute).
171pub 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"); // newest first
266        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}