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