Skip to main content

tuitbot_server/routes/approval/
mod.rs

1//! Approval queue route handlers.
2//!
3//! Split by concern:
4//! - mod.rs: shared types + list/stats (read-only endpoints)
5//! - handlers.rs: edit/approve/reject/approve_all (write endpoints)
6//! - bulk_handlers.rs: bulk approve/reject (batch write endpoints)
7//! - export.rs: CSV/JSON export, edit history, internal helpers
8
9pub mod bulk_handlers;
10pub mod export;
11pub mod handlers;
12
13pub use bulk_handlers::{bulk_approve, bulk_reject};
14pub use export::{export_items, get_edit_history};
15pub use handlers::{approve_all, approve_item, edit_item, reject_item};
16
17use std::sync::Arc;
18
19use axum::extract::{Query, State};
20use axum::Json;
21use serde::Deserialize;
22use serde_json::{json, Value};
23use tuitbot_core::storage::approval_queue;
24
25use crate::account::AccountContext;
26use crate::error::ApiError;
27use crate::state::AppState;
28
29/// Query parameters for listing approval items.
30#[derive(Deserialize)]
31pub struct ApprovalQuery {
32    /// Comma-separated status values (default: "pending").
33    #[serde(default = "default_status")]
34    pub status: String,
35    /// Filter by action type (reply, tweet, thread_tweet).
36    #[serde(rename = "type")]
37    pub action_type: Option<String>,
38    /// Filter by reviewer name.
39    pub reviewed_by: Option<String>,
40    /// Filter by items created since this ISO-8601 timestamp.
41    pub since: Option<String>,
42    /// Override the account to filter by (defaults to X-Account-Id header).
43    /// Must match the authenticated account; ignored if it differs.
44    pub account_id: Option<String>,
45}
46
47fn default_status() -> String {
48    "pending".to_string()
49}
50
51/// `GET /api/approval` — list approval items with optional status/type/reviewer/date filters.
52///
53/// Accepts an optional `account_id` query param to scope results. The param is
54/// validated against the authenticated account — if it differs, the header
55/// account takes precedence (prevents cross-account data leakage).
56pub async fn list_items(
57    State(state): State<Arc<AppState>>,
58    ctx: AccountContext,
59    Query(params): Query<ApprovalQuery>,
60) -> Result<Json<Value>, ApiError> {
61    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
62    let action_type = params.action_type.as_deref();
63    let reviewed_by = params.reviewed_by.as_deref();
64    let since = params.since.as_deref();
65
66    // account_id query param is accepted for dashboard convenience but must
67    // match the authenticated account (ctx.account_id) to prevent leakage.
68    let effective_account_id = match params.account_id.as_deref() {
69        Some(qid) if qid == ctx.account_id => qid,
70        Some(_) => &ctx.account_id, // silently use header account
71        None => &ctx.account_id,
72    };
73
74    let items = approval_queue::get_filtered_for(
75        &state.db,
76        effective_account_id,
77        &statuses,
78        action_type,
79        reviewed_by,
80        since,
81    )
82    .await?;
83    Ok(Json(json!(items)))
84}
85
86/// `GET /api/approval/stats` — counts by status.
87pub async fn stats(
88    State(state): State<Arc<AppState>>,
89    ctx: AccountContext,
90) -> Result<Json<Value>, ApiError> {
91    let stats = approval_queue::get_stats_for(&state.db, &ctx.account_id).await?;
92    Ok(Json(json!(stats)))
93}
94
95#[cfg(test)]
96mod tests;