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