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    let review = body.map(|b| b.0).unwrap_or_default();
170    approval_queue::update_status_with_review_for(
171        &state.db,
172        &ctx.account_id,
173        id,
174        "approved",
175        &review,
176    )
177    .await?;
178
179    // Log to action log.
180    let metadata = json!({
181        "approval_id": id,
182        "actor": review.actor,
183        "notes": review.notes,
184        "action_type": item.action_type,
185    });
186    let _ = action_log::log_action_for(
187        &state.db,
188        &ctx.account_id,
189        "approval_approved",
190        "success",
191        Some(&format!("Approved item {id}")),
192        Some(&metadata.to_string()),
193    )
194    .await;
195
196    let _ = state.event_tx.send(AccountWsEvent {
197        account_id: ctx.account_id.clone(),
198        event: WsEvent::ApprovalUpdated {
199            id,
200            status: "approved".to_string(),
201            action_type: item.action_type,
202            actor: review.actor,
203        },
204    });
205
206    Ok(Json(json!({"status": "approved", "id": id})))
207}
208
209/// `POST /api/approval/:id/reject` — reject a queued item.
210pub async fn reject_item(
211    State(state): State<Arc<AppState>>,
212    ctx: AccountContext,
213    Path(id): Path<i64>,
214    body: Option<Json<approval_queue::ReviewAction>>,
215) -> Result<Json<Value>, ApiError> {
216    require_approve(&ctx)?;
217
218    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
219    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
220
221    let review = body.map(|b| b.0).unwrap_or_default();
222    approval_queue::update_status_with_review_for(
223        &state.db,
224        &ctx.account_id,
225        id,
226        "rejected",
227        &review,
228    )
229    .await?;
230
231    // Log to action log.
232    let metadata = json!({
233        "approval_id": id,
234        "actor": review.actor,
235        "notes": review.notes,
236        "action_type": item.action_type,
237    });
238    let _ = action_log::log_action_for(
239        &state.db,
240        &ctx.account_id,
241        "approval_rejected",
242        "success",
243        Some(&format!("Rejected item {id}")),
244        Some(&metadata.to_string()),
245    )
246    .await;
247
248    let _ = state.event_tx.send(AccountWsEvent {
249        account_id: ctx.account_id.clone(),
250        event: WsEvent::ApprovalUpdated {
251            id,
252            status: "rejected".to_string(),
253            action_type: item.action_type,
254            actor: review.actor,
255        },
256    });
257
258    Ok(Json(json!({"status": "rejected", "id": id})))
259}
260
261/// Request body for batch approve.
262#[derive(Deserialize)]
263pub struct BatchApproveRequest {
264    /// Maximum number of items to approve (clamped to server config).
265    #[serde(default)]
266    pub max: Option<usize>,
267    /// Specific IDs to approve (if provided, `max` is ignored).
268    #[serde(default)]
269    pub ids: Option<Vec<i64>>,
270    /// Review metadata.
271    #[serde(default)]
272    pub review: approval_queue::ReviewAction,
273}
274
275/// `POST /api/approval/approve-all` — batch-approve pending items.
276pub async fn approve_all(
277    State(state): State<Arc<AppState>>,
278    ctx: AccountContext,
279    body: Option<Json<BatchApproveRequest>>,
280) -> Result<Json<Value>, ApiError> {
281    require_approve(&ctx)?;
282
283    let config = read_config(&state);
284    let max_batch = config.max_batch_approve;
285
286    let body = body.map(|b| b.0);
287    let review = body.as_ref().map(|b| b.review.clone()).unwrap_or_default();
288
289    let approved_ids = if let Some(ids) = body.as_ref().and_then(|b| b.ids.as_ref()) {
290        // Approve specific IDs (still clamped to max_batch).
291        let clamped: Vec<&i64> = ids.iter().take(max_batch).collect();
292        let mut approved = Vec::with_capacity(clamped.len());
293        for &id in &clamped {
294            if let Ok(Some(_)) =
295                approval_queue::get_by_id_for(&state.db, &ctx.account_id, *id).await
296            {
297                if approval_queue::update_status_with_review_for(
298                    &state.db,
299                    &ctx.account_id,
300                    *id,
301                    "approved",
302                    &review,
303                )
304                .await
305                .is_ok()
306                {
307                    approved.push(*id);
308                }
309            }
310        }
311        approved
312    } else {
313        // Approve oldest N pending items.
314        let effective_max = body
315            .as_ref()
316            .and_then(|b| b.max)
317            .map(|m| m.min(max_batch))
318            .unwrap_or(max_batch);
319
320        approval_queue::batch_approve_for(&state.db, &ctx.account_id, effective_max, &review)
321            .await?
322    };
323
324    let count = approved_ids.len();
325
326    // Log to action log.
327    let metadata = json!({
328        "count": count,
329        "ids": approved_ids,
330        "actor": review.actor,
331        "max_configured": max_batch,
332    });
333    let _ = action_log::log_action_for(
334        &state.db,
335        &ctx.account_id,
336        "approval_batch_approved",
337        "success",
338        Some(&format!("Batch approved {count} items")),
339        Some(&metadata.to_string()),
340    )
341    .await;
342
343    let _ = state.event_tx.send(AccountWsEvent {
344        account_id: ctx.account_id.clone(),
345        event: WsEvent::ApprovalUpdated {
346            id: 0,
347            status: "approved_all".to_string(),
348            action_type: String::new(),
349            actor: review.actor,
350        },
351    });
352
353    Ok(Json(
354        json!({"status": "approved", "count": count, "ids": approved_ids, "max_batch": max_batch}),
355    ))
356}
357
358/// Query parameters for the approval export endpoint.
359#[derive(Deserialize)]
360pub struct ExportQuery {
361    /// Export format: "csv" or "json" (default: "csv").
362    #[serde(default = "default_csv")]
363    pub format: String,
364    /// Comma-separated status values (default: all).
365    #[serde(default = "default_export_status")]
366    pub status: String,
367    /// Filter by action type.
368    #[serde(rename = "type")]
369    pub action_type: Option<String>,
370}
371
372fn default_csv() -> String {
373    "csv".to_string()
374}
375
376fn default_export_status() -> String {
377    "pending,approved,rejected,posted".to_string()
378}
379
380/// `GET /api/approval/export` — export approval items as CSV or JSON.
381pub async fn export_items(
382    State(state): State<Arc<AppState>>,
383    ctx: AccountContext,
384    Query(params): Query<ExportQuery>,
385) -> Result<axum::response::Response, ApiError> {
386    use axum::response::IntoResponse;
387
388    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
389    let action_type = params.action_type.as_deref();
390
391    let items =
392        approval_queue::get_by_statuses_for(&state.db, &ctx.account_id, &statuses, action_type)
393            .await?;
394
395    if params.format == "json" {
396        let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
397        Ok((
398            [
399                (
400                    axum::http::header::CONTENT_TYPE,
401                    "application/json; charset=utf-8",
402                ),
403                (
404                    axum::http::header::CONTENT_DISPOSITION,
405                    "attachment; filename=\"approval_export.json\"",
406                ),
407            ],
408            body,
409        )
410            .into_response())
411    } else {
412        let mut csv = String::from(
413            "id,action_type,target_author,generated_content,topic,score,status,reviewed_by,review_notes,created_at\n",
414        );
415        for item in &items {
416            csv.push_str(&format!(
417                "{},{},{},{},{},{},{},{},{},{}\n",
418                item.id,
419                escape_csv(&item.action_type),
420                escape_csv(&item.target_author),
421                escape_csv(&item.generated_content),
422                escape_csv(&item.topic),
423                item.score,
424                escape_csv(&item.status),
425                escape_csv(item.reviewed_by.as_deref().unwrap_or("")),
426                escape_csv(item.review_notes.as_deref().unwrap_or("")),
427                escape_csv(&item.created_at),
428            ));
429        }
430        Ok((
431            [
432                (axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
433                (
434                    axum::http::header::CONTENT_DISPOSITION,
435                    "attachment; filename=\"approval_export.csv\"",
436                ),
437            ],
438            csv,
439        )
440            .into_response())
441    }
442}
443
444/// Escape a value for CSV output.
445fn escape_csv(value: &str) -> String {
446    if value.contains(',') || value.contains('"') || value.contains('\n') {
447        format!("\"{}\"", value.replace('"', "\"\""))
448    } else {
449        value.to_string()
450    }
451}
452
453/// `GET /api/approval/:id/history` — get edit history for an item.
454pub async fn get_edit_history(
455    State(state): State<Arc<AppState>>,
456    _ctx: AccountContext,
457    Path(id): Path<i64>,
458) -> Result<Json<Value>, ApiError> {
459    // Query by approval_id PK is already implicitly scoped.
460    let history = approval_queue::get_edit_history(&state.db, id).await?;
461    Ok(Json(json!(history)))
462}
463
464/// Read the config from disk (best-effort, returns defaults on failure).
465fn read_config(state: &AppState) -> Config {
466    std::fs::read_to_string(&state.config_path)
467        .ok()
468        .and_then(|s| toml::from_str(&s).ok())
469        .unwrap_or_default()
470}