Skip to main content

tuitbot_server/routes/
activity.rs

1//! Activity feed 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::{action_log, rate_limits};
10
11use crate::account::AccountContext;
12use crate::error::ApiError;
13use crate::state::AppState;
14
15/// Query parameters for the activity endpoint.
16#[derive(Deserialize)]
17pub struct ActivityQuery {
18    /// Maximum number of actions to return (default: 50).
19    #[serde(default = "default_limit")]
20    pub limit: u32,
21    /// Offset for pagination (default: 0).
22    #[serde(default)]
23    pub offset: u32,
24    /// Filter by action type. Use "all" or omit for no filter.
25    #[serde(rename = "type")]
26    pub action_type: Option<String>,
27    /// Filter by status (e.g. "failure" for errors).
28    pub status: Option<String>,
29}
30
31fn default_limit() -> u32 {
32    50
33}
34
35/// `GET /api/activity` — paginated, filterable action log.
36pub async fn list_activity(
37    State(state): State<Arc<AppState>>,
38    ctx: AccountContext,
39    Query(params): Query<ActivityQuery>,
40) -> Result<Json<Value>, ApiError> {
41    let type_filter =
42        params
43            .action_type
44            .as_deref()
45            .and_then(|t| if t == "all" { None } else { Some(t) });
46    let status_filter = params.status.as_deref();
47
48    let actions = action_log::get_actions_paginated_for(
49        &state.db,
50        &ctx.account_id,
51        params.limit,
52        params.offset,
53        type_filter,
54        status_filter,
55    )
56    .await?;
57
58    let total =
59        action_log::get_actions_count_for(&state.db, &ctx.account_id, type_filter, status_filter)
60            .await?;
61
62    Ok(Json(json!({
63        "actions": actions,
64        "total": total,
65        "limit": params.limit,
66        "offset": params.offset,
67    })))
68}
69
70/// Query parameters for the activity export endpoint.
71#[derive(Deserialize)]
72pub struct ExportQuery {
73    /// Export format: "csv" or "json" (default: "csv").
74    #[serde(default = "default_csv")]
75    pub format: String,
76    /// Filter by action type.
77    #[serde(rename = "type")]
78    pub action_type: Option<String>,
79    /// Filter by status.
80    pub status: Option<String>,
81}
82
83fn default_csv() -> String {
84    "csv".to_string()
85}
86
87/// `GET /api/activity/export` — export activity log as CSV or JSON.
88pub async fn export_activity(
89    State(state): State<Arc<AppState>>,
90    ctx: AccountContext,
91    Query(params): Query<ExportQuery>,
92) -> Result<axum::response::Response, ApiError> {
93    use axum::response::IntoResponse;
94
95    let type_filter =
96        params
97            .action_type
98            .as_deref()
99            .and_then(|t| if t == "all" { None } else { Some(t) });
100    let status_filter = params.status.as_deref();
101
102    let actions = action_log::get_actions_paginated_for(
103        &state.db,
104        &ctx.account_id,
105        10_000,
106        0,
107        type_filter,
108        status_filter,
109    )
110    .await?;
111
112    if params.format == "json" {
113        let body = serde_json::to_string(&actions).unwrap_or_else(|_| "[]".to_string());
114        Ok((
115            [
116                (
117                    axum::http::header::CONTENT_TYPE,
118                    "application/json; charset=utf-8",
119                ),
120                (
121                    axum::http::header::CONTENT_DISPOSITION,
122                    "attachment; filename=\"activity_export.json\"",
123                ),
124            ],
125            body,
126        )
127            .into_response())
128    } else {
129        let mut csv = String::from("id,action_type,status,message,created_at\n");
130        for a in &actions {
131            csv.push_str(&format!(
132                "{},{},{},{},{}\n",
133                a.id,
134                escape_csv(&a.action_type),
135                escape_csv(&a.status),
136                escape_csv(a.message.as_deref().unwrap_or("")),
137                escape_csv(&a.created_at),
138            ));
139        }
140        Ok((
141            [
142                (axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
143                (
144                    axum::http::header::CONTENT_DISPOSITION,
145                    "attachment; filename=\"activity_export.csv\"",
146                ),
147            ],
148            csv,
149        )
150            .into_response())
151    }
152}
153
154/// Escape a value for CSV output. Wraps in quotes if it contains commas,
155/// quotes, or newlines.
156fn escape_csv(value: &str) -> String {
157    if value.contains(',') || value.contains('"') || value.contains('\n') {
158        format!("\"{}\"", value.replace('"', "\"\""))
159    } else {
160        value.to_string()
161    }
162}
163
164/// `GET /api/activity/rate-limits` — current daily rate limit usage.
165pub async fn rate_limit_usage(
166    State(state): State<Arc<AppState>>,
167    ctx: AccountContext,
168) -> Result<Json<Value>, ApiError> {
169    let usage = rate_limits::get_daily_usage_for(&state.db, &ctx.account_id).await?;
170    Ok(Json(json!(usage)))
171}