Skip to main content

tuitbot_server/routes/
analytics.rs

1//! Analytics endpoints.
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::storage::analytics;
10
11use crate::account::AccountContext;
12use crate::error::ApiError;
13use crate::state::AppState;
14
15/// Query parameters for the followers endpoint.
16#[derive(Deserialize)]
17pub struct FollowersQuery {
18    /// Number of days of follower snapshots to return (default: 7).
19    #[serde(default = "default_days")]
20    pub days: u32,
21}
22
23fn default_days() -> u32 {
24    7
25}
26
27/// Query parameters for the topics endpoint.
28#[derive(Deserialize)]
29pub struct TopicsQuery {
30    /// Maximum number of topics to return (default: 20).
31    #[serde(default = "default_topic_limit")]
32    pub limit: u32,
33}
34
35fn default_topic_limit() -> u32 {
36    20
37}
38
39/// Query parameters for the recent-performance endpoint.
40#[derive(Deserialize)]
41pub struct RecentPerformanceQuery {
42    /// Maximum number of items to return (default: 20).
43    #[serde(default = "default_recent_limit")]
44    pub limit: u32,
45}
46
47fn default_recent_limit() -> u32 {
48    20
49}
50
51/// `GET /api/analytics/followers` — follower snapshots over time.
52pub async fn followers(
53    State(state): State<Arc<AppState>>,
54    ctx: AccountContext,
55    Query(params): Query<FollowersQuery>,
56) -> Result<Json<Value>, ApiError> {
57    let snapshots =
58        analytics::get_follower_snapshots_for(&state.db, &ctx.account_id, params.days).await?;
59    Ok(Json(json!(snapshots)))
60}
61
62/// `GET /api/analytics/performance` — reply and tweet performance summaries.
63pub async fn performance(
64    State(state): State<Arc<AppState>>,
65    ctx: AccountContext,
66) -> Result<Json<Value>, ApiError> {
67    let avg_reply = analytics::get_avg_reply_engagement_for(&state.db, &ctx.account_id).await?;
68    let avg_tweet = analytics::get_avg_tweet_engagement_for(&state.db, &ctx.account_id).await?;
69    let (reply_count, tweet_count) =
70        analytics::get_performance_counts_for(&state.db, &ctx.account_id).await?;
71
72    Ok(Json(json!({
73        "avg_reply_engagement": avg_reply,
74        "avg_tweet_engagement": avg_tweet,
75        "measured_replies": reply_count,
76        "measured_tweets": tweet_count,
77    })))
78}
79
80/// `GET /api/analytics/topics` — topic performance scores.
81pub async fn topics(
82    State(state): State<Arc<AppState>>,
83    ctx: AccountContext,
84    Query(params): Query<TopicsQuery>,
85) -> Result<Json<Value>, ApiError> {
86    let scores = analytics::get_top_topics_for(&state.db, &ctx.account_id, params.limit).await?;
87    Ok(Json(json!(scores)))
88}
89
90/// `GET /api/analytics/summary` — combined analytics dashboard summary.
91pub async fn summary(
92    State(state): State<Arc<AppState>>,
93    ctx: AccountContext,
94) -> Result<Json<Value>, ApiError> {
95    let data = analytics::get_analytics_summary_for(&state.db, &ctx.account_id).await?;
96    Ok(Json(json!(data)))
97}
98
99/// `GET /api/analytics/recent-performance` — recent content with performance metrics.
100pub async fn recent_performance(
101    State(state): State<Arc<AppState>>,
102    ctx: AccountContext,
103    Query(params): Query<RecentPerformanceQuery>,
104) -> Result<Json<Value>, ApiError> {
105    let items =
106        analytics::get_recent_performance_items_for(&state.db, &ctx.account_id, params.limit)
107            .await?;
108    Ok(Json(json!(items)))
109}
110
111/// Query parameters for engagement-rate endpoint.
112#[derive(Deserialize)]
113pub struct EngagementRateQuery {
114    /// Maximum number of posts to return (default: 20).
115    #[serde(default = "default_engagement_limit")]
116    pub limit: u32,
117}
118
119fn default_engagement_limit() -> u32 {
120    20
121}
122
123/// `GET /api/analytics/engagement-rate` — top posts by engagement rate (for charting).
124pub async fn engagement_rate(
125    State(state): State<Arc<AppState>>,
126    ctx: AccountContext,
127    Query(params): Query<EngagementRateQuery>,
128) -> Result<Json<Value>, ApiError> {
129    let metrics =
130        analytics::get_engagement_rate_for(&state.db, &ctx.account_id, params.limit).await?;
131    Ok(Json(json!(metrics)))
132}
133
134/// Query parameters for reach endpoint.
135#[derive(Deserialize)]
136pub struct ReachQuery {
137    /// Number of days of reach data to return (default: 7).
138    #[serde(default = "default_reach_days")]
139    pub window: u32,
140}
141
142fn default_reach_days() -> u32 {
143    7
144}
145
146/// `GET /api/analytics/reach` — reach time-series by day (for charting).
147pub async fn reach(
148    State(state): State<Arc<AppState>>,
149    ctx: AccountContext,
150    Query(params): Query<ReachQuery>,
151) -> Result<Json<Value>, ApiError> {
152    let snapshots = analytics::get_reach_for(&state.db, &ctx.account_id, params.window).await?;
153    Ok(Json(json!(snapshots)))
154}
155
156/// Query parameters for follower-growth endpoint.
157#[derive(Deserialize)]
158pub struct FollowerGrowthQuery {
159    /// Number of days of follower growth data to return (default: 30).
160    #[serde(default = "default_growth_days")]
161    pub window: u32,
162}
163
164fn default_growth_days() -> u32 {
165    30
166}
167
168/// `GET /api/analytics/follower-growth` — follower growth time-series with deltas (for charting).
169pub async fn follower_growth(
170    State(state): State<Arc<AppState>>,
171    ctx: AccountContext,
172    Query(params): Query<FollowerGrowthQuery>,
173) -> Result<Json<Value>, ApiError> {
174    let snapshots =
175        analytics::get_follower_growth_for(&state.db, &ctx.account_id, params.window).await?;
176    Ok(Json(json!(snapshots)))
177}
178
179/// `GET /api/analytics/best-times` — ranked posting time slots by engagement.
180pub async fn best_times(
181    State(state): State<Arc<AppState>>,
182    ctx: AccountContext,
183) -> Result<Json<Value>, ApiError> {
184    let slots = analytics::get_best_times_for(&state.db, &ctx.account_id).await?;
185    Ok(Json(json!(slots)))
186}
187
188/// `GET /api/analytics/heatmap` — 7×24 best-time heatmap grid.
189pub async fn heatmap(
190    State(state): State<Arc<AppState>>,
191    ctx: AccountContext,
192) -> Result<Json<Value>, ApiError> {
193    let grid = analytics::get_heatmap_for(&state.db, &ctx.account_id).await?;
194    Ok(Json(json!(grid)))
195}
196
197/// `GET /api/analytics/content-breakdown` — content performance by type.
198pub async fn content_breakdown(
199    State(state): State<Arc<AppState>>,
200    ctx: AccountContext,
201) -> Result<Json<Value>, ApiError> {
202    let breakdown = analytics::get_content_breakdown_for(&state.db, &ctx.account_id).await?;
203    Ok(Json(json!(breakdown)))
204}