1use 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#[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
27pub 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
42pub 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
54pub 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
67pub 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
90fn 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 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}