Skip to main content

tuitbot_server/routes/
targets.rs

1//! Target accounts endpoints.
2
3use std::sync::Arc;
4
5use axum::extract::{Path, Query, State};
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use tuitbot_core::storage::target_accounts;
10
11use crate::error::ApiError;
12use crate::state::AppState;
13
14/// `GET /api/targets` — list target accounts with enriched data.
15pub async fn list_targets(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
16    let accounts = target_accounts::get_enriched_target_accounts(&state.db).await?;
17    Ok(Json(json!(accounts)))
18}
19
20/// Request body for adding a target account.
21#[derive(Deserialize)]
22pub struct AddTargetRequest {
23    /// Username of the target account (without @).
24    pub username: String,
25}
26
27/// `POST /api/targets` — add a new target account.
28pub async fn add_target(
29    State(state): State<Arc<AppState>>,
30    Json(body): Json<AddTargetRequest>,
31) -> Result<Json<Value>, ApiError> {
32    let username = body.username.trim().trim_start_matches('@');
33
34    if username.is_empty() {
35        return Err(ApiError::BadRequest("username is required".to_string()));
36    }
37
38    // Check if already exists and active.
39    if let Some(existing) =
40        target_accounts::get_target_account_by_username(&state.db, username).await?
41    {
42        if existing.status == "active" {
43            return Err(ApiError::Conflict(format!(
44                "target account @{username} already exists"
45            )));
46        }
47    }
48
49    // Use username as a placeholder account_id; the automation runtime will
50    // resolve the real X user ID when it runs target monitoring.
51    target_accounts::upsert_target_account(&state.db, username, username).await?;
52
53    Ok(Json(
54        json!({"status": "added", "username": username.to_string()}),
55    ))
56}
57
58/// `DELETE /api/targets/:username` — deactivate a target account.
59pub async fn remove_target(
60    State(state): State<Arc<AppState>>,
61    Path(username): Path<String>,
62) -> Result<Json<Value>, ApiError> {
63    let removed = target_accounts::deactivate_target_account(&state.db, &username).await?;
64
65    if !removed {
66        return Err(ApiError::NotFound(format!(
67            "active target account @{username} not found"
68        )));
69    }
70
71    Ok(Json(json!({"status": "removed", "username": username})))
72}
73
74/// Query parameters for the timeline endpoint.
75#[derive(Deserialize)]
76pub struct TimelineQuery {
77    /// Maximum number of timeline items to return (default: 50).
78    pub limit: Option<i64>,
79}
80
81/// `GET /api/targets/:username/timeline` — interaction timeline for a target.
82pub async fn target_timeline(
83    State(state): State<Arc<AppState>>,
84    Path(username): Path<String>,
85    Query(params): Query<TimelineQuery>,
86) -> Result<Json<Value>, ApiError> {
87    let limit = params.limit.unwrap_or(50).min(200);
88    let items = target_accounts::get_target_timeline(&state.db, &username, limit).await?;
89    Ok(Json(json!(items)))
90}
91
92/// `GET /api/targets/:username/stats` — aggregated stats for a target.
93pub async fn target_stats(
94    State(state): State<Arc<AppState>>,
95    Path(username): Path<String>,
96) -> Result<Json<Value>, ApiError> {
97    let stats = target_accounts::get_target_stats(&state.db, &username).await?;
98
99    match stats {
100        Some(s) => Ok(Json(json!(s))),
101        None => Err(ApiError::NotFound(format!(
102            "active target account @{username} not found"
103        ))),
104    }
105}