tuitbot_server/routes/
strategy.rs1use 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(&state.db, &config).await?;
35 Ok(Json(report_to_json(report)))
36}
37
38pub async fn history(
40 State(state): State<Arc<AppState>>,
41 ctx: AccountContext,
42 Query(params): Query<HistoryQuery>,
43) -> Result<Json<Value>, ApiError> {
44 let reports =
45 strategy::get_recent_reports_for(&state.db, &ctx.account_id, params.limit).await?;
46 let items: Vec<Value> = reports.into_iter().map(report_to_json).collect();
47 Ok(Json(json!(items)))
48}
49
50pub async fn refresh(
52 State(state): State<Arc<AppState>>,
53 _ctx: AccountContext,
54) -> Result<Json<Value>, ApiError> {
55 require_mutate(&_ctx)?;
56 let config = load_config(&state)?;
57 let report = tuitbot_core::strategy::report::refresh_current(&state.db, &config).await?;
59 Ok(Json(report_to_json(report)))
60}
61
62pub async fn inputs(
64 State(state): State<Arc<AppState>>,
65 ctx: AccountContext,
66) -> Result<Json<Value>, ApiError> {
67 let config = load_config(&state)?;
68
69 let targets = tuitbot_core::storage::target_accounts::get_active_target_accounts_for(
70 &state.db,
71 &ctx.account_id,
72 )
73 .await?;
74 let target_usernames: Vec<String> = targets.into_iter().map(|t| t.username).collect();
75
76 Ok(Json(json!({
77 "content_pillars": config.business.content_pillars,
78 "industry_topics": config.business.effective_industry_topics(),
79 "product_keywords": config.business.product_keywords,
80 "competitor_keywords": config.business.competitor_keywords,
81 "target_accounts": target_usernames,
82 })))
83}
84
85fn load_config(state: &AppState) -> Result<Config, ApiError> {
90 let contents = std::fs::read_to_string(&state.config_path).map_err(|e| {
91 ApiError::BadRequest(format!(
92 "could not read config file {}: {e}",
93 state.config_path.display()
94 ))
95 })?;
96 let config: Config = toml::from_str(&contents)
97 .map_err(|e| ApiError::BadRequest(format!("failed to parse config: {e}")))?;
98 Ok(config)
99}
100
101fn report_to_json(report: strategy::StrategyReportRow) -> Value {
102 let top_topics: Value =
103 serde_json::from_str(&report.top_topics_json).unwrap_or_else(|_| json!([]));
104 let bottom_topics: Value =
105 serde_json::from_str(&report.bottom_topics_json).unwrap_or_else(|_| json!([]));
106 let top_content: Value =
107 serde_json::from_str(&report.top_content_json).unwrap_or_else(|_| json!([]));
108 let recommendations: Value =
109 serde_json::from_str(&report.recommendations_json).unwrap_or_else(|_| json!([]));
110
111 json!({
112 "id": report.id,
113 "week_start": report.week_start,
114 "week_end": report.week_end,
115 "replies_sent": report.replies_sent,
116 "tweets_posted": report.tweets_posted,
117 "threads_posted": report.threads_posted,
118 "target_replies": report.target_replies,
119 "follower_start": report.follower_start,
120 "follower_end": report.follower_end,
121 "follower_delta": report.follower_delta,
122 "avg_reply_score": report.avg_reply_score,
123 "avg_tweet_score": report.avg_tweet_score,
124 "reply_acceptance_rate": report.reply_acceptance_rate,
125 "estimated_follow_conversion": report.estimated_follow_conversion,
126 "top_topics": top_topics,
127 "bottom_topics": bottom_topics,
128 "top_content": top_content,
129 "recommendations": recommendations,
130 "created_at": report.created_at,
131 })
132}