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, scheduled_content};
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
180 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 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 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 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 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
286pub 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 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#[derive(Deserialize)]
340pub struct BatchApproveRequest {
341 #[serde(default)]
343 pub max: Option<usize>,
344 #[serde(default)]
346 pub ids: Option<Vec<i64>>,
347 #[serde(default)]
349 pub review: approval_queue::ReviewAction,
350}
351
352pub 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 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 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 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 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#[derive(Deserialize)]
447pub struct ExportQuery {
448 #[serde(default = "default_csv")]
450 pub format: String,
451 #[serde(default = "default_export_status")]
453 pub status: String,
454 #[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
467pub 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
531fn 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
540pub 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 let history = approval_queue::get_edit_history(&state.db, id).await?;
548 Ok(Json(json!(history)))
549}
550
551async 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
593fn 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}