Skip to main content

tuitbot_server/routes/
strategy.rs

1//! Strategy endpoints — weekly reports, history, and strategy inputs.
2
3use std::sync::Arc;
4
5use axum::extract::{Query, State};
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use tuitbot_core::config::Config;
10use tuitbot_core::storage::strategy;
11
12use crate::account::{require_mutate, AccountContext};
13use crate::error::ApiError;
14use crate::state::AppState;
15
16/// Query parameters for the history endpoint.
17#[derive(Deserialize)]
18pub struct HistoryQuery {
19    #[serde(default = "default_history_limit")]
20    pub limit: u32,
21}
22
23fn default_history_limit() -> u32 {
24    12
25}
26
27/// `GET /api/strategy/current` — current week's report for the requesting account.
28pub async fn current(
29    State(state): State<Arc<AppState>>,
30    ctx: AccountContext,
31) -> Result<Json<Value>, ApiError> {
32    let config = load_config(&state)?;
33    let report = tuitbot_core::strategy::report::get_or_compute_current_for(
34        &state.db,
35        &config,
36        &ctx.account_id,
37    )
38    .await?;
39    Ok(Json(report_to_json(report)))
40}
41
42/// `GET /api/strategy/history` — recent weekly reports for trend view.
43pub async fn history(
44    State(state): State<Arc<AppState>>,
45    ctx: AccountContext,
46    Query(params): Query<HistoryQuery>,
47) -> Result<Json<Value>, ApiError> {
48    let reports =
49        strategy::get_recent_reports_for(&state.db, &ctx.account_id, params.limit).await?;
50    let items: Vec<Value> = reports.into_iter().map(report_to_json).collect();
51    Ok(Json(json!(items)))
52}
53
54/// `POST /api/strategy/refresh` — force recompute the current week's report for the requesting account.
55pub async fn refresh(
56    State(state): State<Arc<AppState>>,
57    ctx: AccountContext,
58) -> Result<Json<Value>, ApiError> {
59    require_mutate(&ctx)?;
60    let config = load_config(&state)?;
61    let report =
62        tuitbot_core::strategy::report::refresh_current_for(&state.db, &config, &ctx.account_id)
63            .await?;
64    Ok(Json(report_to_json(report)))
65}
66
67/// `GET /api/strategy/inputs` — current strategy inputs (pillars, keywords, targets, topics).
68pub async fn inputs(
69    State(state): State<Arc<AppState>>,
70    ctx: AccountContext,
71) -> Result<Json<Value>, ApiError> {
72    let config = load_config(&state)?;
73
74    let targets = tuitbot_core::storage::target_accounts::get_active_target_accounts_for(
75        &state.db,
76        &ctx.account_id,
77    )
78    .await?;
79    let target_usernames: Vec<String> = targets.into_iter().map(|t| t.username).collect();
80
81    Ok(Json(json!({
82        "content_pillars": config.business.content_pillars,
83        "industry_topics": config.business.effective_industry_topics(),
84        "product_keywords": config.business.product_keywords,
85        "competitor_keywords": config.business.competitor_keywords,
86        "target_accounts": target_usernames,
87    })))
88}
89
90// ---------------------------------------------------------------------------
91// Helpers
92// ---------------------------------------------------------------------------
93
94fn load_config(state: &AppState) -> Result<Config, ApiError> {
95    let contents = std::fs::read_to_string(&state.config_path).map_err(|e| {
96        ApiError::BadRequest(format!(
97            "could not read config file {}: {e}",
98            state.config_path.display()
99        ))
100    })?;
101    let config: Config = toml::from_str(&contents)
102        .map_err(|e| ApiError::BadRequest(format!("failed to parse config: {e}")))?;
103    Ok(config)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn default_history_limit_is_12() {
112        assert_eq!(default_history_limit(), 12);
113    }
114
115    #[test]
116    fn history_query_default_limit() {
117        let json = "{}";
118        let q: HistoryQuery = serde_json::from_str(json).expect("deser");
119        assert_eq!(q.limit, 12);
120    }
121
122    #[test]
123    fn history_query_custom_limit() {
124        let json = r#"{"limit": 5}"#;
125        let q: HistoryQuery = serde_json::from_str(json).expect("deser");
126        assert_eq!(q.limit, 5);
127    }
128
129    #[test]
130    fn report_to_json_parses_arrays() {
131        let report = strategy::StrategyReportRow {
132            id: 1,
133            week_start: "2026-03-09".into(),
134            week_end: "2026-03-15".into(),
135            replies_sent: 10,
136            tweets_posted: 5,
137            threads_posted: 2,
138            target_replies: 3,
139            follower_start: 100,
140            follower_end: 120,
141            follower_delta: 20,
142            avg_reply_score: 75.0,
143            avg_tweet_score: 80.0,
144            reply_acceptance_rate: 0.85,
145            estimated_follow_conversion: 0.02,
146            top_topics_json: r#"["rust","wasm"]"#.into(),
147            bottom_topics_json: "[]".into(),
148            top_content_json: "[]".into(),
149            recommendations_json: r#"["post more"]"#.into(),
150            created_at: "2026-03-15T10:00:00Z".into(),
151        };
152        let val = report_to_json(report);
153        assert_eq!(val["replies_sent"], 10);
154        assert_eq!(val["follower_delta"], 20);
155        assert!(val["top_topics"].is_array());
156        assert_eq!(val["top_topics"][0], "rust");
157        assert!(val["recommendations"].is_array());
158    }
159
160    #[test]
161    fn report_to_json_handles_invalid_json() {
162        let report = strategy::StrategyReportRow {
163            id: 1,
164            week_start: "2026-03-09".into(),
165            week_end: "2026-03-15".into(),
166            replies_sent: 0,
167            tweets_posted: 0,
168            threads_posted: 0,
169            target_replies: 0,
170            follower_start: 0,
171            follower_end: 0,
172            follower_delta: 0,
173            avg_reply_score: 0.0,
174            avg_tweet_score: 0.0,
175            reply_acceptance_rate: 0.0,
176            estimated_follow_conversion: 0.0,
177            top_topics_json: "invalid".into(),
178            bottom_topics_json: "also-invalid".into(),
179            top_content_json: "nope".into(),
180            recommendations_json: "bad".into(),
181            created_at: "2026-03-15T10:00:00Z".into(),
182        };
183        let val = report_to_json(report);
184        // Should fall back to empty arrays for invalid JSON
185        assert!(val["top_topics"].is_array());
186        assert_eq!(val["top_topics"].as_array().unwrap().len(), 0);
187    }
188}
189
190fn report_to_json(report: strategy::StrategyReportRow) -> Value {
191    let top_topics: Value =
192        serde_json::from_str(&report.top_topics_json).unwrap_or_else(|_| json!([]));
193    let bottom_topics: Value =
194        serde_json::from_str(&report.bottom_topics_json).unwrap_or_else(|_| json!([]));
195    let top_content: Value =
196        serde_json::from_str(&report.top_content_json).unwrap_or_else(|_| json!([]));
197    let recommendations: Value =
198        serde_json::from_str(&report.recommendations_json).unwrap_or_else(|_| json!([]));
199
200    json!({
201        "id": report.id,
202        "week_start": report.week_start,
203        "week_end": report.week_end,
204        "replies_sent": report.replies_sent,
205        "tweets_posted": report.tweets_posted,
206        "threads_posted": report.threads_posted,
207        "target_replies": report.target_replies,
208        "follower_start": report.follower_start,
209        "follower_end": report.follower_end,
210        "follower_delta": report.follower_delta,
211        "avg_reply_score": report.avg_reply_score,
212        "avg_tweet_score": report.avg_tweet_score,
213        "reply_acceptance_rate": report.reply_acceptance_rate,
214        "estimated_follow_conversion": report.estimated_follow_conversion,
215        "top_topics": top_topics,
216        "bottom_topics": bottom_topics,
217        "top_content": top_content,
218        "recommendations": recommendations,
219        "created_at": report.created_at,
220    })
221}