1use super::history::JobHistory;
65use super::{
66 ApiResponse, FilterParams, PaginatedResponse, PaginationMeta, PaginationParams, error_reply,
67 internal_error, json_reply, with_shared,
68};
69use hammerwork::{
70 JobId, JobStatus,
71 archive::{ArchivalConfig, ArchivalPolicy, ArchivalReason, ArchivalStats, ArchivedJob},
72};
73use serde::{Deserialize, Serialize};
74use std::sync::Arc;
75use warp::http::StatusCode;
76use warp::{Filter, Reply};
77
78#[derive(Debug, Deserialize)]
80pub struct ArchiveRequest {
81 pub queue_name: Option<String>,
83 pub reason: ArchivalReason,
85 pub archived_by: Option<String>,
87 pub dry_run: bool,
89 pub policy: Option<ArchivalPolicy>,
91 pub config: Option<ArchivalConfig>,
93}
94
95impl Default for ArchiveRequest {
96 fn default() -> Self {
97 Self {
98 queue_name: None,
99 reason: ArchivalReason::Manual,
100 archived_by: None,
101 dry_run: true,
102 policy: None,
103 config: None,
104 }
105 }
106}
107
108#[derive(Debug, Serialize)]
110pub struct ArchiveResponse {
111 pub stats: ArchivalStats,
113 pub dry_run: bool,
115 pub policy_used: ArchivalPolicy,
117 pub config_used: ArchivalConfig,
119}
120
121#[derive(Debug, Deserialize)]
123pub struct RestoreRequest {
124 pub reason: Option<String>,
126 pub restored_by: Option<String>,
128}
129
130#[derive(Debug, Serialize)]
132pub struct RestoreResponse {
133 pub job: hammerwork::Job,
135 pub restored_at: chrono::DateTime<chrono::Utc>,
137 pub restored_by: Option<String>,
139}
140
141#[derive(Debug, Deserialize)]
143pub struct PurgeRequest {
144 pub older_than: chrono::DateTime<chrono::Utc>,
146 pub dry_run: bool,
148 pub purged_by: Option<String>,
150}
151
152#[derive(Debug, Serialize)]
154pub struct PurgeResponse {
155 pub jobs_purged: u64,
157 pub dry_run: bool,
159 pub executed_at: chrono::DateTime<chrono::Utc>,
161}
162
163#[derive(Debug, Serialize)]
165pub struct ArchivedJobInfo {
166 pub id: JobId,
168 pub queue_name: String,
170 pub status: JobStatus,
172 pub created_at: chrono::DateTime<chrono::Utc>,
174 pub archived_at: chrono::DateTime<chrono::Utc>,
176 pub archival_reason: ArchivalReason,
178 pub original_payload_size: Option<usize>,
180 pub payload_compressed: bool,
182 pub archived_by: Option<String>,
184}
185
186impl From<ArchivedJob> for ArchivedJobInfo {
187 fn from(archived_job: ArchivedJob) -> Self {
188 Self {
189 id: archived_job.id,
190 queue_name: archived_job.queue_name,
191 status: archived_job.status,
192 created_at: archived_job.created_at,
193 archived_at: archived_job.archived_at,
194 archival_reason: archived_job.archival_reason,
195 original_payload_size: archived_job.original_payload_size,
196 payload_compressed: archived_job.payload_compressed,
197 archived_by: archived_job.archived_by,
198 }
199 }
200}
201
202#[derive(Debug, Serialize)]
204pub struct StatsResponse {
205 pub stats: ArchivalStats,
207 pub by_queue: std::collections::HashMap<String, ArchivalStats>,
209 pub recent_operations: Vec<RecentOperation>,
211}
212
213#[derive(Debug, Serialize)]
215pub struct RecentOperation {
216 pub operation_type: String,
218 pub queue_name: Option<String>,
220 pub jobs_affected: u64,
222 pub executed_at: chrono::DateTime<chrono::Utc>,
224 pub executed_by: Option<String>,
226 pub reason: Option<String>,
228}
229
230#[derive(Debug, Deserialize, Default)]
232pub struct ArchiveFilterParams {
233 pub queue: Option<String>,
235 pub reason: Option<String>,
237 pub archived_after: Option<chrono::DateTime<chrono::Utc>>,
239 pub archived_before: Option<chrono::DateTime<chrono::Utc>>,
241 pub archived_by: Option<String>,
243 pub compressed: Option<bool>,
245 pub original_status: Option<String>,
247}
248
249pub fn archive_routes<Q>(
251 queue: Arc<Q>,
252) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
253where
254 Q: JobHistory + 'static,
255{
256 let archive_jobs = warp::path!("archive" / "jobs")
257 .and(warp::post())
258 .and(crate::security::json_body())
259 .and(with_shared(queue.clone()))
260 .and_then(handle_archive_jobs);
261
262 let list_archived = warp::path!("archive" / "jobs")
263 .and(warp::get())
264 .and(super::with_pagination())
265 .and(with_archive_filters())
266 .and(with_shared(queue.clone()))
267 .and_then(handle_list_archived_jobs);
268
269 let restore_job = warp::path!("archive" / "jobs" / String / "restore")
270 .and(warp::post())
271 .and(crate::security::json_body())
272 .and(with_shared(queue.clone()))
273 .and_then(handle_restore_job);
274
275 let purge_jobs = warp::path!("archive" / "purge")
276 .and(warp::delete())
277 .and(crate::security::json_body())
278 .and(with_shared(queue.clone()))
279 .and_then(handle_purge_jobs);
280
281 let archive_stats = warp::path!("archive" / "stats")
282 .and(warp::get())
283 .and(warp::query::<FilterParams>())
284 .and(with_shared(queue.clone()))
285 .and_then(handle_archive_stats);
286
287 archive_jobs
288 .or(list_archived)
289 .or(restore_job)
290 .or(purge_jobs)
291 .or(archive_stats)
292}
293
294fn with_archive_filters()
296-> impl Filter<Extract = (ArchiveFilterParams,), Error = warp::Rejection> + Clone {
297 warp::query::<ArchiveFilterParams>()
298}
299
300async fn handle_archive_jobs<Q>(
302 request: ArchiveRequest,
303 queue: Arc<Q>,
304) -> Result<impl Reply, warp::Rejection>
305where
306 Q: JobHistory + 'static,
307{
308 let policy = request.policy.unwrap_or_default();
309 let config = request.config.unwrap_or_default();
310
311 if request.dry_run {
312 let would_archive = try_api!(
315 queue
316 .count_archivable(request.queue_name.as_deref(), &policy)
317 .await,
318 "Failed to count archivable jobs"
319 );
320 let response = ArchiveResponse {
321 stats: ArchivalStats {
322 jobs_archived: would_archive,
323 ..ArchivalStats::default()
324 },
325 dry_run: true,
326 policy_used: policy,
327 config_used: config,
328 };
329 return Ok(json_reply(&ApiResponse::success(response)));
330 }
331
332 match queue
333 .archive_jobs(
334 request.queue_name.as_deref(),
335 &policy,
336 &config,
337 request.reason,
338 request.archived_by.as_deref(),
339 )
340 .await
341 {
342 Ok(stats) => {
343 let response = ArchiveResponse {
344 stats,
345 dry_run: false,
346 policy_used: policy,
347 config_used: config,
348 };
349 Ok(json_reply(&ApiResponse::success(response)))
350 }
351 Err(e) => Ok(internal_error("Failed to archive jobs", &e)),
352 }
353}
354
355async fn handle_list_archived_jobs<Q>(
358 pagination: PaginationParams,
359 filters: ArchiveFilterParams,
360 queue: Arc<Q>,
361) -> Result<impl Reply, warp::Rejection>
362where
363 Q: JobHistory + 'static,
364{
365 let limit = pagination.get_limit();
366 let offset = pagination.get_offset();
367
368 let (page, total) = try_api!(
369 queue.archived_jobs(&filters, limit, offset).await,
370 "Failed to list archived jobs"
371 );
372
373 let response = PaginatedResponse {
374 items: page.into_iter().map(ArchivedJobInfo::from).collect(),
375 pagination: PaginationMeta::new(&pagination, total),
376 };
377 Ok(json_reply(&ApiResponse::success(response)))
378}
379
380async fn handle_restore_job<Q>(
382 job_id_str: String,
383 request: RestoreRequest,
384 queue: Arc<Q>,
385) -> Result<impl Reply, warp::Rejection>
386where
387 Q: JobHistory + 'static,
388{
389 let job_id = match uuid::Uuid::parse_str(&job_id_str) {
390 Ok(id) => id,
391 Err(_) => {
392 return Ok(error_reply(
393 StatusCode::BAD_REQUEST,
394 "Invalid job ID format",
395 ));
396 }
397 };
398
399 match queue.restore_archived_job(job_id).await {
400 Ok(job) => {
401 let response = RestoreResponse {
402 job,
403 restored_at: chrono::Utc::now(),
404 restored_by: request.restored_by,
405 };
406 Ok(json_reply(&ApiResponse::success(response)))
407 }
408 Err(e) => Ok(super::queue_error_reply("Failed to restore job", &e)),
409 }
410}
411
412async fn handle_purge_jobs<Q>(
414 request: PurgeRequest,
415 queue: Arc<Q>,
416) -> Result<impl Reply, warp::Rejection>
417where
418 Q: JobHistory + 'static,
419{
420 if request.dry_run {
421 let cutoff = ArchiveFilterParams {
424 archived_before: Some(request.older_than),
425 ..ArchiveFilterParams::default()
426 };
427 let count = try_api!(
428 queue.archived_jobs(&cutoff, 0, 0).await,
429 "Failed to count archived jobs"
430 )
431 .1;
432
433 let response = PurgeResponse {
434 jobs_purged: count,
435 dry_run: true,
436 executed_at: chrono::Utc::now(),
437 };
438 return Ok(json_reply(&ApiResponse::success(response)));
439 }
440
441 match queue.purge_archived_jobs(request.older_than).await {
442 Ok(jobs_purged) => {
443 let response = PurgeResponse {
444 jobs_purged,
445 dry_run: false,
446 executed_at: chrono::Utc::now(),
447 };
448 Ok(json_reply(&ApiResponse::success(response)))
449 }
450 Err(e) => Ok(internal_error("Failed to purge archived jobs", &e)),
451 }
452}
453
454async fn handle_archive_stats<Q>(
456 filters: FilterParams,
457 queue: Arc<Q>,
458) -> Result<impl Reply, warp::Rejection>
459where
460 Q: JobHistory + 'static,
461{
462 match queue.get_archival_stats(filters.queue.as_deref()).await {
463 Ok(stats) => {
464 let mut by_queue = std::collections::HashMap::new();
466
467 if filters.queue.is_none() {
468 let queue_stats = try_api!(
470 queue.get_all_queue_stats().await,
471 "Failed to get queue stats"
472 );
473 for queue_stat in queue_stats {
474 match queue.get_archival_stats(Some(&queue_stat.queue_name)).await {
475 Ok(queue_archival_stats) => {
476 by_queue.insert(queue_stat.queue_name, queue_archival_stats);
477 }
478 Err(e) => {
479 return Ok(internal_error(
480 &format!(
481 "Failed to get archive stats for queue '{}'",
482 queue_stat.queue_name
483 ),
484 &e,
485 ));
486 }
487 }
488 }
489 }
490
491 let recent_operations: Vec<RecentOperation> = Vec::new();
494
495 let response = StatsResponse {
496 stats,
497 by_queue,
498 recent_operations,
499 };
500 Ok(json_reply(&ApiResponse::success(response)))
501 }
502 Err(e) => Ok(internal_error("Failed to get archive stats", &e)),
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::api::test_support::{body_json, unreachable_queue};
510
511 #[tokio::test]
512 async fn test_dry_run_purge_returns_500_instead_of_zero_when_database_is_down() {
513 let request: PurgeRequest = serde_json::from_value(serde_json::json!({
514 "older_than": "2024-01-01T00:00:00Z",
515 "dry_run": true
516 }))
517 .unwrap();
518 let response = handle_purge_jobs(request, unreachable_queue())
519 .await
520 .unwrap()
521 .into_response();
522 let (status, body) = body_json(response).await;
523 assert_eq!(status, 500);
524 assert_eq!(body["success"], false);
525 assert!(body["data"].is_null());
526 }
527
528 #[tokio::test]
529 async fn test_archive_stats_returns_500_when_database_is_down() {
530 let filters: FilterParams = serde_json::from_value(serde_json::json!({})).unwrap();
531 let response = handle_archive_stats(filters, unreachable_queue())
532 .await
533 .unwrap()
534 .into_response();
535 let (status, _) = body_json(response).await;
536 assert_eq!(status, 500);
537 }
538 use chrono::Duration;
539
540 #[test]
541 fn test_archive_request_default() {
542 let request = ArchiveRequest::default();
543 assert!(request.dry_run);
544 assert_eq!(request.reason, ArchivalReason::Manual);
545 assert!(request.queue_name.is_none());
546 }
547
548 #[test]
549 fn test_archived_job_info_conversion() {
550 let archived_job = ArchivedJob {
551 id: uuid::Uuid::new_v4(),
552 queue_name: "test_queue".to_string(),
553 status: JobStatus::Completed,
554 created_at: chrono::Utc::now(),
555 archived_at: chrono::Utc::now(),
556 archival_reason: ArchivalReason::Automatic,
557 original_payload_size: Some(1024),
558 payload_compressed: true,
559 archived_by: Some("system".to_string()),
560 };
561
562 let info: ArchivedJobInfo = archived_job.into();
563 assert_eq!(info.queue_name, "test_queue");
564 assert!(info.payload_compressed);
565 assert_eq!(info.archival_reason, ArchivalReason::Automatic);
566 }
567
568 #[test]
569 fn test_purge_request_validation() {
570 let request = PurgeRequest {
571 older_than: chrono::Utc::now() - Duration::days(365),
572 dry_run: true,
573 purged_by: Some("admin".to_string()),
574 };
575
576 assert!(request.dry_run);
577 assert_eq!(request.purged_by, Some("admin".to_string()));
578 }
579}