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 (computed on-the-fly if missing).
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    // TODO: account-scope strategy computation (strategy metrics lack `_for` variants)
34    let report = tuitbot_core::strategy::report::get_or_compute_current(&state.db, &config).await?;
35    Ok(Json(report_to_json(report)))
36}
37
38/// `GET /api/strategy/history` — recent weekly reports for trend view.
39pub 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
50/// `POST /api/strategy/refresh` — force recompute the current week's report.
51pub 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    // TODO: account-scope strategy computation (strategy metrics lack `_for` variants)
58    let report = tuitbot_core::strategy::report::refresh_current(&state.db, &config).await?;
59    Ok(Json(report_to_json(report)))
60}
61
62/// `GET /api/strategy/inputs` — current strategy inputs (pillars, keywords, targets, topics).
63pub 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
85// ---------------------------------------------------------------------------
86// Helpers
87// ---------------------------------------------------------------------------
88
89fn 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}