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