Skip to main content

tuitbot_server/routes/
approval.rs

1//! Approval queue 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::config::Config;
10use tuitbot_core::storage::{action_log, approval_queue};
11
12use crate::account::{require_approve, AccountContext};
13use crate::error::ApiError;
14use crate::state::AppState;
15use crate::ws::{AccountWsEvent, WsEvent};
16
17/// Query parameters for listing approval items.
18#[derive(Deserialize)]
19pub struct ApprovalQuery {
20    /// Comma-separated status values (default: "pending").
21    #[serde(default = "default_status")]
22    pub status: String,
23    /// Filter by action type (reply, tweet, thread_tweet).
24    #[serde(rename = "type")]
25    pub action_type: Option<String>,
26    /// Filter by reviewer name.
27    pub reviewed_by: Option<String>,
28    /// Filter by items created since this ISO-8601 timestamp.
29    pub since: Option<String>,
30}
31
32fn default_status() -> String {
33    "pending".to_string()
34}
35
36/// `GET /api/approval` — list approval items with optional status/type/reviewer/date filters.
37pub async fn list_items(
38    State(state): State<Arc<AppState>>,
39    ctx: AccountContext,
40    Query(params): Query<ApprovalQuery>,
41) -> Result<Json<Value>, ApiError> {
42    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
43    let action_type = params.action_type.as_deref();
44    let reviewed_by = params.reviewed_by.as_deref();
45    let since = params.since.as_deref();
46
47    let items = approval_queue::get_filtered_for(
48        &state.db,
49        &ctx.account_id,
50        &statuses,
51        action_type,
52        reviewed_by,
53        since,
54    )
55    .await?;
56    Ok(Json(json!(items)))
57}
58
59/// `GET /api/approval/stats` — counts by status.
60pub async fn stats(
61    State(state): State<Arc<AppState>>,
62    ctx: AccountContext,
63) -> Result<Json<Value>, ApiError> {
64    let stats = approval_queue::get_stats_for(&state.db, &ctx.account_id).await?;
65    Ok(Json(json!(stats)))
66}
67
68/// Request body for editing approval item content.
69#[derive(Deserialize)]
70pub struct EditContentRequest {
71    pub content: String,
72    /// Optional updated media paths.
73    #[serde(default)]
74    pub media_paths: Option<Vec<String>>,
75    /// Who made the edit (default: "dashboard").
76    #[serde(default = "default_editor")]
77    pub editor: String,
78}
79
80fn default_editor() -> String {
81    "dashboard".to_string()
82}
83
84/// `PATCH /api/approval/:id` — edit content before approving.
85pub async fn edit_item(
86    State(state): State<Arc<AppState>>,
87    ctx: AccountContext,
88    Path(id): Path<i64>,
89    Json(body): Json<EditContentRequest>,
90) -> Result<Json<Value>, ApiError> {
91    require_approve(&ctx)?;
92
93    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
94    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
95
96    let content = body.content.trim();
97    if content.is_empty() {
98        return Err(ApiError::BadRequest("content cannot be empty".to_string()));
99    }
100
101    // Record edit history before updating (queries by PK, implicitly scoped).
102    if content != item.generated_content {
103        let _ = approval_queue::record_edit(
104            &state.db,
105            id,
106            &body.editor,
107            "generated_content",
108            &item.generated_content,
109            content,
110        )
111        .await;
112    }
113
114    approval_queue::update_content_for(&state.db, &ctx.account_id, id, content).await?;
115
116    if let Some(media_paths) = &body.media_paths {
117        let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
118
119        // Record media_paths edit if changed.
120        if media_json != item.media_paths {
121            let _ = approval_queue::record_edit(
122                &state.db,
123                id,
124                &body.editor,
125                "media_paths",
126                &item.media_paths,
127                &media_json,
128            )
129            .await;
130        }
131
132        approval_queue::update_media_paths_for(&state.db, &ctx.account_id, id, &media_json).await?;
133    }
134
135    // Log to action log.
136    let metadata = json!({
137        "approval_id": id,
138        "editor": body.editor,
139        "field": "generated_content",
140    });
141    let _ = action_log::log_action_for(
142        &state.db,
143        &ctx.account_id,
144        "approval_edited",
145        "success",
146        Some(&format!("Edited approval item {id}")),
147        Some(&metadata.to_string()),
148    )
149    .await;
150
151    let updated = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id)
152        .await?
153        .expect("item was just verified to exist");
154    Ok(Json(json!(updated)))
155}
156
157/// `POST /api/approval/:id/approve` — approve a queued item.
158pub async fn approve_item(
159    State(state): State<Arc<AppState>>,
160    ctx: AccountContext,
161    Path(id): Path<i64>,
162    body: Option<Json<approval_queue::ReviewAction>>,
163) -> Result<Json<Value>, ApiError> {
164    require_approve(&ctx)?;
165
166    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
167    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
168
169    // Verify X auth tokens exist before allowing approval.
170    let token_path =
171        tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
172    if !token_path.exists() {
173        return Err(ApiError::BadRequest(
174            "Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
175        ));
176    }
177
178    let review = body.map(|b| b.0).unwrap_or_default();
179    approval_queue::update_status_with_review_for(
180        &state.db,
181        &ctx.account_id,
182        id,
183        "approved",
184        &review,
185    )
186    .await?;
187
188    // Log to action log.
189    let metadata = json!({
190        "approval_id": id,
191        "actor": review.actor,
192        "notes": review.notes,
193        "action_type": item.action_type,
194    });
195    let _ = action_log::log_action_for(
196        &state.db,
197        &ctx.account_id,
198        "approval_approved",
199        "success",
200        Some(&format!("Approved item {id}")),
201        Some(&metadata.to_string()),
202    )
203    .await;
204
205    let _ = state.event_tx.send(AccountWsEvent {
206        account_id: ctx.account_id.clone(),
207        event: WsEvent::ApprovalUpdated {
208            id,
209            status: "approved".to_string(),
210            action_type: item.action_type,
211            actor: review.actor,
212        },
213    });
214
215    Ok(Json(json!({"status": "approved", "id": id})))
216}
217
218/// `POST /api/approval/:id/reject` — reject a queued item.
219pub async fn reject_item(
220    State(state): State<Arc<AppState>>,
221    ctx: AccountContext,
222    Path(id): Path<i64>,
223    body: Option<Json<approval_queue::ReviewAction>>,
224) -> Result<Json<Value>, ApiError> {
225    require_approve(&ctx)?;
226
227    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
228    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
229
230    let review = body.map(|b| b.0).unwrap_or_default();
231    approval_queue::update_status_with_review_for(
232        &state.db,
233        &ctx.account_id,
234        id,
235        "rejected",
236        &review,
237    )
238    .await?;
239
240    // Log to action log.
241    let metadata = json!({
242        "approval_id": id,
243        "actor": review.actor,
244        "notes": review.notes,
245        "action_type": item.action_type,
246    });
247    let _ = action_log::log_action_for(
248        &state.db,
249        &ctx.account_id,
250        "approval_rejected",
251        "success",
252        Some(&format!("Rejected item {id}")),
253        Some(&metadata.to_string()),
254    )
255    .await;
256
257    let _ = state.event_tx.send(AccountWsEvent {
258        account_id: ctx.account_id.clone(),
259        event: WsEvent::ApprovalUpdated {
260            id,
261            status: "rejected".to_string(),
262            action_type: item.action_type,
263            actor: review.actor,
264        },
265    });
266
267    Ok(Json(json!({"status": "rejected", "id": id})))
268}
269
270/// Request body for batch approve.
271#[derive(Deserialize)]
272pub struct BatchApproveRequest {
273    /// Maximum number of items to approve (clamped to server config).
274    #[serde(default)]
275    pub max: Option<usize>,
276    /// Specific IDs to approve (if provided, `max` is ignored).
277    #[serde(default)]
278    pub ids: Option<Vec<i64>>,
279    /// Review metadata.
280    #[serde(default)]
281    pub review: approval_queue::ReviewAction,
282}
283
284/// `POST /api/approval/approve-all` — batch-approve pending items.
285pub async fn approve_all(
286    State(state): State<Arc<AppState>>,
287    ctx: AccountContext,
288    body: Option<Json<BatchApproveRequest>>,
289) -> Result<Json<Value>, ApiError> {
290    require_approve(&ctx)?;
291
292    // Verify X auth tokens exist before allowing approval.
293    let token_path =
294        tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
295    if !token_path.exists() {
296        return Err(ApiError::BadRequest(
297            "Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
298        ));
299    }
300
301    let config = read_config(&state);
302    let max_batch = config.max_batch_approve;
303
304    let body = body.map(|b| b.0);
305    let review = body.as_ref().map(|b| b.review.clone()).unwrap_or_default();
306
307    let approved_ids = if let Some(ids) = body.as_ref().and_then(|b| b.ids.as_ref()) {
308        // Approve specific IDs (still clamped to max_batch).
309        let clamped: Vec<&i64> = ids.iter().take(max_batch).collect();
310        let mut approved = Vec::with_capacity(clamped.len());
311        for &id in &clamped {
312            if let Ok(Some(_)) =
313                approval_queue::get_by_id_for(&state.db, &ctx.account_id, *id).await
314            {
315                if approval_queue::update_status_with_review_for(
316                    &state.db,
317                    &ctx.account_id,
318                    *id,
319                    "approved",
320                    &review,
321                )
322                .await
323                .is_ok()
324                {
325                    approved.push(*id);
326                }
327            }
328        }
329        approved
330    } else {
331        // Approve oldest N pending items.
332        let effective_max = body
333            .as_ref()
334            .and_then(|b| b.max)
335            .map(|m| m.min(max_batch))
336            .unwrap_or(max_batch);
337
338        approval_queue::batch_approve_for(&state.db, &ctx.account_id, effective_max, &review)
339            .await?
340    };
341
342    let count = approved_ids.len();
343
344    // Log to action log.
345    let metadata = json!({
346        "count": count,
347        "ids": approved_ids,
348        "actor": review.actor,
349        "max_configured": max_batch,
350    });
351    let _ = action_log::log_action_for(
352        &state.db,
353        &ctx.account_id,
354        "approval_batch_approved",
355        "success",
356        Some(&format!("Batch approved {count} items")),
357        Some(&metadata.to_string()),
358    )
359    .await;
360
361    let _ = state.event_tx.send(AccountWsEvent {
362        account_id: ctx.account_id.clone(),
363        event: WsEvent::ApprovalUpdated {
364            id: 0,
365            status: "approved_all".to_string(),
366            action_type: String::new(),
367            actor: review.actor,
368        },
369    });
370
371    Ok(Json(
372        json!({"status": "approved", "count": count, "ids": approved_ids, "max_batch": max_batch}),
373    ))
374}
375
376/// Query parameters for the approval export endpoint.
377#[derive(Deserialize)]
378pub struct ExportQuery {
379    /// Export format: "csv" or "json" (default: "csv").
380    #[serde(default = "default_csv")]
381    pub format: String,
382    /// Comma-separated status values (default: all).
383    #[serde(default = "default_export_status")]
384    pub status: String,
385    /// Filter by action type.
386    #[serde(rename = "type")]
387    pub action_type: Option<String>,
388}
389
390fn default_csv() -> String {
391    "csv".to_string()
392}
393
394fn default_export_status() -> String {
395    "pending,approved,rejected,posted".to_string()
396}
397
398/// `GET /api/approval/export` — export approval items as CSV or JSON.
399pub async fn export_items(
400    State(state): State<Arc<AppState>>,
401    ctx: AccountContext,
402    Query(params): Query<ExportQuery>,
403) -> Result<axum::response::Response, ApiError> {
404    use axum::response::IntoResponse;
405
406    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
407    let action_type = params.action_type.as_deref();
408
409    let items =
410        approval_queue::get_by_statuses_for(&state.db, &ctx.account_id, &statuses, action_type)
411            .await?;
412
413    if params.format == "json" {
414        let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
415        Ok((
416            [
417                (
418                    axum::http::header::CONTENT_TYPE,
419                    "application/json; charset=utf-8",
420                ),
421                (
422                    axum::http::header::CONTENT_DISPOSITION,
423                    "attachment; filename=\"approval_export.json\"",
424                ),
425            ],
426            body,
427        )
428            .into_response())
429    } else {
430        let mut csv = String::from(
431            "id,action_type,target_author,generated_content,topic,score,status,reviewed_by,review_notes,created_at\n",
432        );
433        for item in &items {
434            csv.push_str(&format!(
435                "{},{},{},{},{},{},{},{},{},{}\n",
436                item.id,
437                escape_csv(&item.action_type),
438                escape_csv(&item.target_author),
439                escape_csv(&item.generated_content),
440                escape_csv(&item.topic),
441                item.score,
442                escape_csv(&item.status),
443                escape_csv(item.reviewed_by.as_deref().unwrap_or("")),
444                escape_csv(item.review_notes.as_deref().unwrap_or("")),
445                escape_csv(&item.created_at),
446            ));
447        }
448        Ok((
449            [
450                (axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
451                (
452                    axum::http::header::CONTENT_DISPOSITION,
453                    "attachment; filename=\"approval_export.csv\"",
454                ),
455            ],
456            csv,
457        )
458            .into_response())
459    }
460}
461
462/// Escape a value for CSV output.
463fn escape_csv(value: &str) -> String {
464    if value.contains(',') || value.contains('"') || value.contains('\n') {
465        format!("\"{}\"", value.replace('"', "\"\""))
466    } else {
467        value.to_string()
468    }
469}
470
471/// `GET /api/approval/:id/history` — get edit history for an item.
472pub async fn get_edit_history(
473    State(state): State<Arc<AppState>>,
474    _ctx: AccountContext,
475    Path(id): Path<i64>,
476) -> Result<Json<Value>, ApiError> {
477    // Query by approval_id PK is already implicitly scoped.
478    let history = approval_queue::get_edit_history(&state.db, id).await?;
479    Ok(Json(json!(history)))
480}
481
482/// Read the config from disk (best-effort, returns defaults on failure).
483fn read_config(state: &AppState) -> Config {
484    std::fs::read_to_string(&state.config_path)
485        .ok()
486        .and_then(|s| toml::from_str(&s).ok())
487        .unwrap_or_default()
488}