1use super::DbPool;
7use crate::error::StorageError;
8
9type ApprovalRow = (
11 i64,
12 String,
13 String,
14 String,
15 String,
16 String,
17 String,
18 f64,
19 String,
20 String,
21);
22
23#[derive(Debug, Clone, serde::Serialize)]
25pub struct ApprovalItem {
26 pub id: i64,
27 pub action_type: String,
28 pub target_tweet_id: String,
29 pub target_author: String,
30 pub generated_content: String,
31 pub topic: String,
32 pub archetype: String,
33 pub score: f64,
34 pub status: String,
35 pub created_at: String,
36}
37
38impl From<ApprovalRow> for ApprovalItem {
39 fn from(r: ApprovalRow) -> Self {
40 Self {
41 id: r.0,
42 action_type: r.1,
43 target_tweet_id: r.2,
44 target_author: r.3,
45 generated_content: r.4,
46 topic: r.5,
47 archetype: r.6,
48 score: r.7,
49 status: r.8,
50 created_at: r.9,
51 }
52 }
53}
54
55#[allow(clippy::too_many_arguments)]
57pub async fn enqueue(
58 pool: &DbPool,
59 action_type: &str,
60 target_tweet_id: &str,
61 target_author: &str,
62 generated_content: &str,
63 topic: &str,
64 archetype: &str,
65 score: f64,
66) -> Result<i64, StorageError> {
67 let result = sqlx::query(
68 "INSERT INTO approval_queue (action_type, target_tweet_id, target_author, generated_content, topic, archetype, score)
69 VALUES (?, ?, ?, ?, ?, ?, ?)",
70 )
71 .bind(action_type)
72 .bind(target_tweet_id)
73 .bind(target_author)
74 .bind(generated_content)
75 .bind(topic)
76 .bind(archetype)
77 .bind(score)
78 .execute(pool)
79 .await
80 .map_err(|e| StorageError::Query { source: e })?;
81
82 Ok(result.last_insert_rowid())
83}
84
85pub async fn get_pending(pool: &DbPool) -> Result<Vec<ApprovalItem>, StorageError> {
87 let rows: Vec<ApprovalRow> = sqlx::query_as(
88 "SELECT id, action_type, target_tweet_id, target_author, generated_content, topic, archetype, score, status, created_at
89 FROM approval_queue
90 WHERE status = 'pending'
91 ORDER BY created_at ASC",
92 )
93 .fetch_all(pool)
94 .await
95 .map_err(|e| StorageError::Query { source: e })?;
96
97 Ok(rows.into_iter().map(ApprovalItem::from).collect())
98}
99
100pub async fn pending_count(pool: &DbPool) -> Result<i64, StorageError> {
102 let row: (i64,) =
103 sqlx::query_as("SELECT COUNT(*) FROM approval_queue WHERE status = 'pending'")
104 .fetch_one(pool)
105 .await
106 .map_err(|e| StorageError::Query { source: e })?;
107
108 Ok(row.0)
109}
110
111pub async fn update_status(pool: &DbPool, id: i64, status: &str) -> Result<(), StorageError> {
113 sqlx::query(
114 "UPDATE approval_queue SET status = ?, reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
115 )
116 .bind(status)
117 .bind(id)
118 .execute(pool)
119 .await
120 .map_err(|e| StorageError::Query { source: e })?;
121
122 Ok(())
123}
124
125pub async fn update_content_and_approve(
127 pool: &DbPool,
128 id: i64,
129 new_content: &str,
130) -> Result<(), StorageError> {
131 sqlx::query(
132 "UPDATE approval_queue SET generated_content = ?, status = 'approved', reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
133 )
134 .bind(new_content)
135 .bind(id)
136 .execute(pool)
137 .await
138 .map_err(|e| StorageError::Query { source: e })?;
139
140 Ok(())
141}
142
143pub async fn get_by_id(pool: &DbPool, id: i64) -> Result<Option<ApprovalItem>, StorageError> {
145 let row: Option<ApprovalRow> = sqlx::query_as(
146 "SELECT id, action_type, target_tweet_id, target_author, generated_content, topic, archetype, score, status, created_at
147 FROM approval_queue
148 WHERE id = ?",
149 )
150 .bind(id)
151 .fetch_optional(pool)
152 .await
153 .map_err(|e| StorageError::Query { source: e })?;
154
155 Ok(row.map(ApprovalItem::from))
156}
157
158pub async fn expire_old_items(pool: &DbPool, hours: u32) -> Result<u64, StorageError> {
160 let result = sqlx::query(
161 "UPDATE approval_queue SET status = 'expired', reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
162 WHERE status = 'pending'
163 AND created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?)",
164 )
165 .bind(format!("-{hours} hours"))
166 .execute(pool)
167 .await
168 .map_err(|e| StorageError::Query { source: e })?;
169
170 Ok(result.rows_affected())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::storage::init_test_db;
177
178 #[tokio::test]
179 async fn enqueue_and_get_pending() {
180 let pool = init_test_db().await.expect("init db");
181
182 let id = enqueue(
183 &pool,
184 "reply",
185 "tweet123",
186 "@testuser",
187 "Great point about Rust!",
188 "Rust",
189 "AgreeAndExpand",
190 85.0,
191 )
192 .await
193 .expect("enqueue");
194
195 assert!(id > 0);
196
197 let pending = get_pending(&pool).await.expect("get pending");
198 assert_eq!(pending.len(), 1);
199 assert_eq!(pending[0].action_type, "reply");
200 assert_eq!(pending[0].target_tweet_id, "tweet123");
201 assert_eq!(pending[0].generated_content, "Great point about Rust!");
202 }
203
204 #[tokio::test]
205 async fn pending_count_works() {
206 let pool = init_test_db().await.expect("init db");
207
208 assert_eq!(pending_count(&pool).await.expect("count"), 0);
209
210 enqueue(&pool, "tweet", "", "", "Hello world", "General", "", 0.0)
211 .await
212 .expect("enqueue");
213 enqueue(&pool, "reply", "t1", "@u", "Nice!", "Rust", "", 50.0)
214 .await
215 .expect("enqueue");
216
217 assert_eq!(pending_count(&pool).await.expect("count"), 2);
218 }
219
220 #[tokio::test]
221 async fn update_status_marks_approved() {
222 let pool = init_test_db().await.expect("init db");
223
224 let id = enqueue(&pool, "tweet", "", "", "Hello", "General", "", 0.0)
225 .await
226 .expect("enqueue");
227
228 update_status(&pool, id, "approved").await.expect("update");
229
230 let pending = get_pending(&pool).await.expect("get pending");
231 assert!(pending.is_empty());
232
233 let item = get_by_id(&pool, id).await.expect("get").expect("found");
234 assert_eq!(item.status, "approved");
235 }
236
237 #[tokio::test]
238 async fn update_status_marks_rejected() {
239 let pool = init_test_db().await.expect("init db");
240
241 let id = enqueue(&pool, "tweet", "", "", "Hello", "General", "", 0.0)
242 .await
243 .expect("enqueue");
244
245 update_status(&pool, id, "rejected").await.expect("update");
246
247 let item = get_by_id(&pool, id).await.expect("get").expect("found");
248 assert_eq!(item.status, "rejected");
249 }
250
251 #[tokio::test]
252 async fn update_content_and_approve_works() {
253 let pool = init_test_db().await.expect("init db");
254
255 let id = enqueue(&pool, "tweet", "", "", "Draft", "General", "", 0.0)
256 .await
257 .expect("enqueue");
258
259 update_content_and_approve(&pool, id, "Final version")
260 .await
261 .expect("update");
262
263 let item = get_by_id(&pool, id).await.expect("get").expect("found");
264 assert_eq!(item.status, "approved");
265 assert_eq!(item.generated_content, "Final version");
266 }
267
268 #[tokio::test]
269 async fn get_by_id_not_found() {
270 let pool = init_test_db().await.expect("init db");
271 let item = get_by_id(&pool, 99999).await.expect("get");
272 assert!(item.is_none());
273 }
274
275 #[tokio::test]
276 async fn pending_ordered_by_creation_time() {
277 let pool = init_test_db().await.expect("init db");
278
279 enqueue(&pool, "tweet", "", "", "First", "A", "", 0.0)
280 .await
281 .expect("enqueue");
282 enqueue(&pool, "tweet", "", "", "Second", "B", "", 0.0)
283 .await
284 .expect("enqueue");
285 enqueue(&pool, "tweet", "", "", "Third", "C", "", 0.0)
286 .await
287 .expect("enqueue");
288
289 let pending = get_pending(&pool).await.expect("get pending");
290 assert_eq!(pending.len(), 3);
291 assert_eq!(pending[0].generated_content, "First");
292 assert_eq!(pending[1].generated_content, "Second");
293 assert_eq!(pending[2].generated_content, "Third");
294 }
295}