use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tuitbot_core::config::Config;
use tuitbot_core::storage::{action_log, approval_queue, provenance, scheduled_content};
use crate::account::{require_approve, AccountContext};
use crate::error::ApiError;
use crate::state::AppState;
use crate::ws::{AccountWsEvent, WsEvent};
#[derive(Deserialize)]
pub struct EditContentRequest {
pub content: String,
#[serde(default)]
pub media_paths: Option<Vec<String>>,
#[serde(default = "default_editor")]
pub editor: String,
}
fn default_editor() -> String {
"dashboard".to_string()
}
pub async fn edit_item(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
Json(body): Json<EditContentRequest>,
) -> Result<Json<Value>, ApiError> {
require_approve(&ctx)?;
let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
let content = body.content.trim();
if content.is_empty() {
return Err(ApiError::BadRequest("content cannot be empty".to_string()));
}
if content != item.generated_content {
let _ = approval_queue::record_edit(
&state.db,
id,
&body.editor,
"generated_content",
&item.generated_content,
content,
)
.await;
}
approval_queue::update_content_for(&state.db, &ctx.account_id, id, content).await?;
if let Some(media_paths) = &body.media_paths {
let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
if media_json != item.media_paths {
let _ = approval_queue::record_edit(
&state.db,
id,
&body.editor,
"media_paths",
&item.media_paths,
&media_json,
)
.await;
}
approval_queue::update_media_paths_for(&state.db, &ctx.account_id, id, &media_json).await?;
}
let metadata = json!({
"approval_id": id,
"editor": body.editor,
"field": "generated_content",
});
let _ = action_log::log_action_for(
&state.db,
&ctx.account_id,
"approval_edited",
"success",
Some(&format!("Edited approval item {id}")),
Some(&metadata.to_string()),
)
.await;
let updated = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id)
.await?
.expect("item was just verified to exist");
Ok(Json(json!(updated)))
}
pub async fn approve_item(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
body: Option<Json<approval_queue::ReviewAction>>,
) -> Result<Json<Value>, ApiError> {
require_approve(&ctx)?;
let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
if item.status != "pending" {
return Err(ApiError::Conflict(format!(
"cannot approve item {id}: status is '{}', expected 'pending'",
item.status
)));
}
let token_path =
tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
if !token_path.exists() {
return Err(ApiError::BadRequest(
"Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
));
}
let review = body.map(|b| b.0).unwrap_or_default();
let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
.ok()
.filter(|dt| *dt > chrono::Utc::now().naive_utc())
.map(|_| sched.to_string())
});
if let Some(ref sched) = schedule_bridge {
approval_queue::update_status_with_review_for(
&state.db,
&ctx.account_id,
id,
"scheduled",
&review,
)
.await?;
let sc_id = scheduled_content::insert_for(
&state.db,
&ctx.account_id,
&item.action_type,
&item.generated_content,
Some(sched),
)
.await?;
let _ = provenance::copy_links_for(
&state.db,
&ctx.account_id,
"approval_queue",
id,
"scheduled_content",
sc_id,
)
.await;
let metadata = json!({
"approval_id": id,
"scheduled_content_id": sc_id,
"scheduled_for": sched,
"actor": review.actor,
"notes": review.notes,
"action_type": item.action_type,
});
let _ = action_log::log_action_for(
&state.db,
&ctx.account_id,
"approval_approved_scheduled",
"success",
Some(&format!("Approved item {id} → scheduled for {sched}")),
Some(&metadata.to_string()),
)
.await;
let _ = state.event_tx.send(AccountWsEvent {
account_id: ctx.account_id.clone(),
event: WsEvent::ApprovalUpdated {
id,
status: "scheduled".to_string(),
action_type: item.action_type,
actor: review.actor,
},
});
return Ok(Json(json!({
"status": "scheduled",
"id": id,
"scheduled_content_id": sc_id,
"scheduled_for": sched,
})));
}
approval_queue::update_status_with_review_for(
&state.db,
&ctx.account_id,
id,
"approved",
&review,
)
.await?;
let metadata = json!({
"approval_id": id,
"actor": review.actor,
"notes": review.notes,
"action_type": item.action_type,
});
let _ = action_log::log_action_for(
&state.db,
&ctx.account_id,
"approval_approved",
"success",
Some(&format!("Approved item {id}")),
Some(&metadata.to_string()),
)
.await;
let _ = state.event_tx.send(AccountWsEvent {
account_id: ctx.account_id.clone(),
event: WsEvent::ApprovalUpdated {
id,
status: "approved".to_string(),
action_type: item.action_type,
actor: review.actor,
},
});
Ok(Json(json!({"status": "approved", "id": id})))
}
pub async fn reject_item(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
body: Option<Json<approval_queue::ReviewAction>>,
) -> Result<Json<Value>, ApiError> {
require_approve(&ctx)?;
let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
if item.status != "pending" {
return Err(ApiError::Conflict(format!(
"cannot reject item {id}: status is '{}', expected 'pending'",
item.status
)));
}
let review = body.map(|b| b.0).unwrap_or_default();
approval_queue::update_status_with_review_for(
&state.db,
&ctx.account_id,
id,
"rejected",
&review,
)
.await?;
let metadata = json!({
"approval_id": id,
"actor": review.actor,
"notes": review.notes,
"action_type": item.action_type,
});
let _ = action_log::log_action_for(
&state.db,
&ctx.account_id,
"approval_rejected",
"success",
Some(&format!("Rejected item {id}")),
Some(&metadata.to_string()),
)
.await;
let _ = state.event_tx.send(AccountWsEvent {
account_id: ctx.account_id.clone(),
event: WsEvent::ApprovalUpdated {
id,
status: "rejected".to_string(),
action_type: item.action_type,
actor: review.actor,
},
});
Ok(Json(json!({"status": "rejected", "id": id})))
}
#[derive(Deserialize)]
pub struct BatchApproveRequest {
#[serde(default)]
pub max: Option<usize>,
#[serde(default)]
pub ids: Option<Vec<i64>>,
#[serde(default)]
pub review: approval_queue::ReviewAction,
}
pub async fn approve_all(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
body: Option<Json<BatchApproveRequest>>,
) -> Result<Json<Value>, ApiError> {
require_approve(&ctx)?;
let token_path =
tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
if !token_path.exists() {
return Err(ApiError::BadRequest(
"Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
));
}
let config = read_config(&state);
let max_batch = config.max_batch_approve;
let body = body.map(|b| b.0);
let review = body.as_ref().map(|b| b.review.clone()).unwrap_or_default();
let approved_ids = if let Some(ids) = body.as_ref().and_then(|b| b.ids.as_ref()) {
let clamped: Vec<&i64> = ids.iter().take(max_batch).collect();
let mut approved = Vec::with_capacity(clamped.len());
for &id in &clamped {
if let Ok(Some(item)) =
approval_queue::get_by_id_for(&state.db, &ctx.account_id, *id).await
{
let result = approve_single_item(&state, &ctx.account_id, &item, &review).await;
if result.is_ok() {
approved.push(*id);
}
}
}
approved
} else {
let effective_max = body
.as_ref()
.and_then(|b| b.max)
.map(|m| m.min(max_batch))
.unwrap_or(max_batch);
let pending = approval_queue::get_pending_for(&state.db, &ctx.account_id).await?;
let mut approved = Vec::with_capacity(effective_max);
for item in pending.iter().take(effective_max) {
if approve_single_item(&state, &ctx.account_id, item, &review)
.await
.is_ok()
{
approved.push(item.id);
}
}
approved
};
let count = approved_ids.len();
let metadata = json!({
"count": count,
"ids": approved_ids,
"actor": review.actor,
"max_configured": max_batch,
});
let _ = action_log::log_action_for(
&state.db,
&ctx.account_id,
"approval_batch_approved",
"success",
Some(&format!("Batch approved {count} items")),
Some(&metadata.to_string()),
)
.await;
let _ = state.event_tx.send(AccountWsEvent {
account_id: ctx.account_id.clone(),
event: WsEvent::ApprovalUpdated {
id: 0,
status: "approved_all".to_string(),
action_type: String::new(),
actor: review.actor,
},
});
Ok(Json(
json!({"status": "approved", "count": count, "ids": approved_ids, "max_batch": max_batch}),
))
}
pub(super) async fn approve_single_item(
state: &AppState,
account_id: &str,
item: &approval_queue::ApprovalItem,
review: &approval_queue::ReviewAction,
) -> Result<(), ApiError> {
let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
.ok()
.filter(|dt| *dt > chrono::Utc::now().naive_utc())
.map(|_| sched.to_string())
});
if let Some(ref sched) = schedule_bridge {
approval_queue::update_status_with_review_for(
&state.db,
account_id,
item.id,
"scheduled",
review,
)
.await?;
let sc_id = scheduled_content::insert_for(
&state.db,
account_id,
&item.action_type,
&item.generated_content,
Some(sched),
)
.await?;
let _ = provenance::copy_links_for(
&state.db,
account_id,
"approval_queue",
item.id,
"scheduled_content",
sc_id,
)
.await;
} else {
approval_queue::update_status_with_review_for(
&state.db, account_id, item.id, "approved", review,
)
.await?;
}
Ok(())
}
pub(super) fn read_config(state: &AppState) -> Config {
std::fs::read_to_string(&state.config_path)
.ok()
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default()
}