1use 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#[derive(Deserialize)]
19pub struct ApprovalQuery {
20 #[serde(default = "default_status")]
22 pub status: String,
23 #[serde(rename = "type")]
25 pub action_type: Option<String>,
26 pub reviewed_by: Option<String>,
28 pub since: Option<String>,
30}
31
32fn default_status() -> String {
33 "pending".to_string()
34}
35
36pub 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
59pub 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#[derive(Deserialize)]
70pub struct EditContentRequest {
71 pub content: String,
72 #[serde(default)]
74 pub media_paths: Option<Vec<String>>,
75 #[serde(default = "default_editor")]
77 pub editor: String,
78}
79
80fn default_editor() -> String {
81 "dashboard".to_string()
82}
83
84pub 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 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 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 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
157pub 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 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 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
218pub 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 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#[derive(Deserialize)]
272pub struct BatchApproveRequest {
273 #[serde(default)]
275 pub max: Option<usize>,
276 #[serde(default)]
278 pub ids: Option<Vec<i64>>,
279 #[serde(default)]
281 pub review: approval_queue::ReviewAction,
282}
283
284pub 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 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 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 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 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#[derive(Deserialize)]
378pub struct ExportQuery {
379 #[serde(default = "default_csv")]
381 pub format: String,
382 #[serde(default = "default_export_status")]
384 pub status: String,
385 #[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
398pub 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
462fn 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
471pub 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 let history = approval_queue::get_edit_history(&state.db, id).await?;
479 Ok(Json(json!(history)))
480}
481
482fn 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}