tuitbot_server/routes/
activity.rs1use 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#[derive(Deserialize)]
17pub struct ActivityQuery {
18 #[serde(default = "default_limit")]
20 pub limit: u32,
21 #[serde(default)]
23 pub offset: u32,
24 #[serde(rename = "type")]
26 pub action_type: Option<String>,
27 pub status: Option<String>,
29}
30
31fn default_limit() -> u32 {
32 50
33}
34
35pub 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#[derive(Deserialize)]
72pub struct ExportQuery {
73 #[serde(default = "default_csv")]
75 pub format: String,
76 #[serde(rename = "type")]
78 pub action_type: Option<String>,
79 pub status: Option<String>,
81}
82
83fn default_csv() -> String {
84 "csv".to_string()
85}
86
87pub 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
154fn 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
164pub 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}