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