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, scheduled_content};
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
180    // Check if this item has a future scheduling intent.
181    let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
182        chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
183            .ok()
184            .filter(|dt| *dt > chrono::Utc::now().naive_utc())
185            .map(|_| sched.to_string())
186    });
187
188    if let Some(ref sched) = schedule_bridge {
189        // Approve and mark as "scheduled" — the posting engine only picks up "approved" items,
190        // so "scheduled" prevents double-posting.
191        approval_queue::update_status_with_review_for(
192            &state.db,
193            &ctx.account_id,
194            id,
195            "scheduled",
196            &review,
197        )
198        .await?;
199
200        // Bridge to scheduled_content so the scheduler posts at the intended time.
201        let sc_id = scheduled_content::insert_for(
202            &state.db,
203            &ctx.account_id,
204            &item.action_type,
205            &item.generated_content,
206            Some(sched),
207        )
208        .await?;
209
210        let metadata = json!({
211            "approval_id": id,
212            "scheduled_content_id": sc_id,
213            "scheduled_for": sched,
214            "actor": review.actor,
215            "notes": review.notes,
216            "action_type": item.action_type,
217        });
218        let _ = action_log::log_action_for(
219            &state.db,
220            &ctx.account_id,
221            "approval_approved_scheduled",
222            "success",
223            Some(&format!("Approved item {id} → scheduled for {sched}")),
224            Some(&metadata.to_string()),
225        )
226        .await;
227
228        let _ = state.event_tx.send(AccountWsEvent {
229            account_id: ctx.account_id.clone(),
230            event: WsEvent::ApprovalUpdated {
231                id,
232                status: "scheduled".to_string(),
233                action_type: item.action_type,
234                actor: review.actor,
235            },
236        });
237
238        return Ok(Json(json!({
239            "status": "scheduled",
240            "id": id,
241            "scheduled_content_id": sc_id,
242            "scheduled_for": sched,
243        })));
244    }
245
246    // No scheduling intent (or scheduled_for is in the past) — approve for immediate posting.
247    approval_queue::update_status_with_review_for(
248        &state.db,
249        &ctx.account_id,
250        id,
251        "approved",
252        &review,
253    )
254    .await?;
255
256    // Log to action log.
257    let metadata = json!({
258        "approval_id": id,
259        "actor": review.actor,
260        "notes": review.notes,
261        "action_type": item.action_type,
262    });
263    let _ = action_log::log_action_for(
264        &state.db,
265        &ctx.account_id,
266        "approval_approved",
267        "success",
268        Some(&format!("Approved item {id}")),
269        Some(&metadata.to_string()),
270    )
271    .await;
272
273    let _ = state.event_tx.send(AccountWsEvent {
274        account_id: ctx.account_id.clone(),
275        event: WsEvent::ApprovalUpdated {
276            id,
277            status: "approved".to_string(),
278            action_type: item.action_type,
279            actor: review.actor,
280        },
281    });
282
283    Ok(Json(json!({"status": "approved", "id": id})))
284}
285
286/// `POST /api/approval/:id/reject` — reject a queued item.
287pub async fn reject_item(
288    State(state): State<Arc<AppState>>,
289    ctx: AccountContext,
290    Path(id): Path<i64>,
291    body: Option<Json<approval_queue::ReviewAction>>,
292) -> Result<Json<Value>, ApiError> {
293    require_approve(&ctx)?;
294
295    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
296    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
297
298    let review = body.map(|b| b.0).unwrap_or_default();
299    approval_queue::update_status_with_review_for(
300        &state.db,
301        &ctx.account_id,
302        id,
303        "rejected",
304        &review,
305    )
306    .await?;
307
308    // Log to action log.
309    let metadata = json!({
310        "approval_id": id,
311        "actor": review.actor,
312        "notes": review.notes,
313        "action_type": item.action_type,
314    });
315    let _ = action_log::log_action_for(
316        &state.db,
317        &ctx.account_id,
318        "approval_rejected",
319        "success",
320        Some(&format!("Rejected item {id}")),
321        Some(&metadata.to_string()),
322    )
323    .await;
324
325    let _ = state.event_tx.send(AccountWsEvent {
326        account_id: ctx.account_id.clone(),
327        event: WsEvent::ApprovalUpdated {
328            id,
329            status: "rejected".to_string(),
330            action_type: item.action_type,
331            actor: review.actor,
332        },
333    });
334
335    Ok(Json(json!({"status": "rejected", "id": id})))
336}
337
338/// Request body for batch approve.
339#[derive(Deserialize)]
340pub struct BatchApproveRequest {
341    /// Maximum number of items to approve (clamped to server config).
342    #[serde(default)]
343    pub max: Option<usize>,
344    /// Specific IDs to approve (if provided, `max` is ignored).
345    #[serde(default)]
346    pub ids: Option<Vec<i64>>,
347    /// Review metadata.
348    #[serde(default)]
349    pub review: approval_queue::ReviewAction,
350}
351
352/// `POST /api/approval/approve-all` — batch-approve pending items.
353pub async fn approve_all(
354    State(state): State<Arc<AppState>>,
355    ctx: AccountContext,
356    body: Option<Json<BatchApproveRequest>>,
357) -> Result<Json<Value>, ApiError> {
358    require_approve(&ctx)?;
359
360    // Verify X auth tokens exist before allowing approval.
361    let token_path =
362        tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
363    if !token_path.exists() {
364        return Err(ApiError::BadRequest(
365            "Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
366        ));
367    }
368
369    let config = read_config(&state);
370    let max_batch = config.max_batch_approve;
371
372    let body = body.map(|b| b.0);
373    let review = body.as_ref().map(|b| b.review.clone()).unwrap_or_default();
374
375    let approved_ids = if let Some(ids) = body.as_ref().and_then(|b| b.ids.as_ref()) {
376        // Approve specific IDs (still clamped to max_batch).
377        let clamped: Vec<&i64> = ids.iter().take(max_batch).collect();
378        let mut approved = Vec::with_capacity(clamped.len());
379        for &id in &clamped {
380            if let Ok(Some(item)) =
381                approval_queue::get_by_id_for(&state.db, &ctx.account_id, *id).await
382            {
383                let result = approve_single_item(&state, &ctx.account_id, &item, &review).await;
384                if result.is_ok() {
385                    approved.push(*id);
386                }
387            }
388        }
389        approved
390    } else {
391        // Approve oldest N pending items, handling scheduling intent per-item.
392        let effective_max = body
393            .as_ref()
394            .and_then(|b| b.max)
395            .map(|m| m.min(max_batch))
396            .unwrap_or(max_batch);
397
398        let pending = approval_queue::get_pending_for(&state.db, &ctx.account_id).await?;
399        let mut approved = Vec::with_capacity(effective_max);
400        for item in pending.iter().take(effective_max) {
401            if approve_single_item(&state, &ctx.account_id, item, &review)
402                .await
403                .is_ok()
404            {
405                approved.push(item.id);
406            }
407        }
408        approved
409    };
410
411    let count = approved_ids.len();
412
413    // Log to action log.
414    let metadata = json!({
415        "count": count,
416        "ids": approved_ids,
417        "actor": review.actor,
418        "max_configured": max_batch,
419    });
420    let _ = action_log::log_action_for(
421        &state.db,
422        &ctx.account_id,
423        "approval_batch_approved",
424        "success",
425        Some(&format!("Batch approved {count} items")),
426        Some(&metadata.to_string()),
427    )
428    .await;
429
430    let _ = state.event_tx.send(AccountWsEvent {
431        account_id: ctx.account_id.clone(),
432        event: WsEvent::ApprovalUpdated {
433            id: 0,
434            status: "approved_all".to_string(),
435            action_type: String::new(),
436            actor: review.actor,
437        },
438    });
439
440    Ok(Json(
441        json!({"status": "approved", "count": count, "ids": approved_ids, "max_batch": max_batch}),
442    ))
443}
444
445/// Query parameters for the approval export endpoint.
446#[derive(Deserialize)]
447pub struct ExportQuery {
448    /// Export format: "csv" or "json" (default: "csv").
449    #[serde(default = "default_csv")]
450    pub format: String,
451    /// Comma-separated status values (default: all).
452    #[serde(default = "default_export_status")]
453    pub status: String,
454    /// Filter by action type.
455    #[serde(rename = "type")]
456    pub action_type: Option<String>,
457}
458
459fn default_csv() -> String {
460    "csv".to_string()
461}
462
463fn default_export_status() -> String {
464    "pending,approved,rejected,posted".to_string()
465}
466
467/// `GET /api/approval/export` — export approval items as CSV or JSON.
468pub async fn export_items(
469    State(state): State<Arc<AppState>>,
470    ctx: AccountContext,
471    Query(params): Query<ExportQuery>,
472) -> Result<axum::response::Response, ApiError> {
473    use axum::response::IntoResponse;
474
475    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
476    let action_type = params.action_type.as_deref();
477
478    let items =
479        approval_queue::get_by_statuses_for(&state.db, &ctx.account_id, &statuses, action_type)
480            .await?;
481
482    if params.format == "json" {
483        let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
484        Ok((
485            [
486                (
487                    axum::http::header::CONTENT_TYPE,
488                    "application/json; charset=utf-8",
489                ),
490                (
491                    axum::http::header::CONTENT_DISPOSITION,
492                    "attachment; filename=\"approval_export.json\"",
493                ),
494            ],
495            body,
496        )
497            .into_response())
498    } else {
499        let mut csv = String::from(
500            "id,action_type,target_author,generated_content,topic,score,status,reviewed_by,review_notes,created_at\n",
501        );
502        for item in &items {
503            csv.push_str(&format!(
504                "{},{},{},{},{},{},{},{},{},{}\n",
505                item.id,
506                escape_csv(&item.action_type),
507                escape_csv(&item.target_author),
508                escape_csv(&item.generated_content),
509                escape_csv(&item.topic),
510                item.score,
511                escape_csv(&item.status),
512                escape_csv(item.reviewed_by.as_deref().unwrap_or("")),
513                escape_csv(item.review_notes.as_deref().unwrap_or("")),
514                escape_csv(&item.created_at),
515            ));
516        }
517        Ok((
518            [
519                (axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
520                (
521                    axum::http::header::CONTENT_DISPOSITION,
522                    "attachment; filename=\"approval_export.csv\"",
523                ),
524            ],
525            csv,
526        )
527            .into_response())
528    }
529}
530
531/// Escape a value for CSV output.
532fn escape_csv(value: &str) -> String {
533    if value.contains(',') || value.contains('"') || value.contains('\n') {
534        format!("\"{}\"", value.replace('"', "\"\""))
535    } else {
536        value.to_string()
537    }
538}
539
540/// `GET /api/approval/:id/history` — get edit history for an item.
541pub async fn get_edit_history(
542    State(state): State<Arc<AppState>>,
543    _ctx: AccountContext,
544    Path(id): Path<i64>,
545) -> Result<Json<Value>, ApiError> {
546    // Query by approval_id PK is already implicitly scoped.
547    let history = approval_queue::get_edit_history(&state.db, id).await?;
548    Ok(Json(json!(history)))
549}
550
551/// Approve a single item, bridging to scheduled_content if it has a future `scheduled_for`.
552async fn approve_single_item(
553    state: &AppState,
554    account_id: &str,
555    item: &approval_queue::ApprovalItem,
556    review: &approval_queue::ReviewAction,
557) -> Result<(), ApiError> {
558    let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
559        chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
560            .ok()
561            .filter(|dt| *dt > chrono::Utc::now().naive_utc())
562            .map(|_| sched.to_string())
563    });
564
565    if let Some(ref sched) = schedule_bridge {
566        approval_queue::update_status_with_review_for(
567            &state.db,
568            account_id,
569            item.id,
570            "scheduled",
571            review,
572        )
573        .await?;
574
575        scheduled_content::insert_for(
576            &state.db,
577            account_id,
578            &item.action_type,
579            &item.generated_content,
580            Some(sched),
581        )
582        .await?;
583    } else {
584        approval_queue::update_status_with_review_for(
585            &state.db, account_id, item.id, "approved", review,
586        )
587        .await?;
588    }
589
590    Ok(())
591}
592
593/// Read the config from disk (best-effort, returns defaults on failure).
594fn read_config(state: &AppState) -> Config {
595    std::fs::read_to_string(&state.config_path)
596        .ok()
597        .and_then(|s| toml::from_str(&s).ok())
598        .unwrap_or_default()
599}