Skip to main content

hammerwork_web/api/
archive.rs

1//! Archive management API endpoints.
2//!
3//! This module provides REST API endpoints for managing job archiving operations,
4//! including archiving jobs, restoring archived jobs, listing archived jobs
5//! and purging old archived jobs.
6//!
7//! # Endpoints
8//!
9//! - `POST /api/archive/jobs` - Archive jobs based on policy
10//! - `GET /api/archive/jobs` - List archived jobs with pagination and filtering
11//! - `POST /api/archive/jobs/{id}/restore` - Restore an archived job
12//! - `DELETE /api/archive/purge` - Purge old archived jobs
13//! - `GET /api/archive/stats` - Get archival statistics
14//!
15//! # Examples
16//!
17//! ## Archive Jobs
18//!
19//! ```rust
20//! use hammerwork_web::api::archive::{ArchiveRequest, ArchiveResponse};
21//! use hammerwork::archive::{ArchivalReason, ArchivalPolicy};
22//! use chrono::Duration;
23//!
24//! let request = ArchiveRequest {
25//!     queue_name: Some("completed_jobs".to_string()),
26//!     reason: ArchivalReason::Automatic,
27//!     archived_by: Some("scheduler".to_string()),
28//!     dry_run: false,
29//!     policy: Some(ArchivalPolicy::new()
30//!         .archive_completed_after(Duration::days(7))),
31//!     config: None,
32//! };
33//!
34//! // This would be sent to POST /api/archive/jobs
35//! assert_eq!(request.queue_name, Some("completed_jobs".to_string()));
36//! assert!(!request.dry_run);
37//! ```
38//!
39//! ## List Archived Jobs
40//!
41//! ```rust
42//! use hammerwork_web::api::archive::ArchivedJobInfo;
43//! use hammerwork::archive::{ArchivalReason, ArchivedJob};
44//! use hammerwork::{JobId, JobStatus};
45//! use chrono::Utc;
46//! use uuid::Uuid;
47//!
48//! let archived_job = ArchivedJobInfo {
49//!     id: Uuid::new_v4(),
50//!     queue_name: "email_queue".to_string(),
51//!     status: JobStatus::Completed,
52//!     created_at: Utc::now(),
53//!     archived_at: Utc::now(),
54//!     archival_reason: ArchivalReason::Automatic,
55//!     original_payload_size: Some(1024),
56//!     payload_compressed: true,
57//!     archived_by: Some("system".to_string()),
58//! };
59//!
60//! assert_eq!(archived_job.queue_name, "email_queue");
61//! assert!(archived_job.payload_compressed);
62//! ```
63
64use 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/// Request to archive jobs
79#[derive(Debug, Deserialize)]
80pub struct ArchiveRequest {
81    /// Optional queue name to limit archival to specific queue
82    pub queue_name: Option<String>,
83    /// Reason for archival
84    pub reason: ArchivalReason,
85    /// Who initiated the archival
86    pub archived_by: Option<String>,
87    /// Whether this is a dry run (don't actually archive)
88    pub dry_run: bool,
89    /// Archival policy to use (optional, uses default if not provided)
90    pub policy: Option<ArchivalPolicy>,
91    /// Archival configuration (optional, uses default if not provided)
92    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/// Response from archiving jobs
109#[derive(Debug, Serialize)]
110pub struct ArchiveResponse {
111    /// Statistics from the archival operation
112    pub stats: ArchivalStats,
113    /// Whether this was a dry run
114    pub dry_run: bool,
115    /// Policy used for archival
116    pub policy_used: ArchivalPolicy,
117    /// Configuration used for archival
118    pub config_used: ArchivalConfig,
119}
120
121/// Request to restore an archived job
122#[derive(Debug, Deserialize)]
123pub struct RestoreRequest {
124    /// Optional reason for restoration
125    pub reason: Option<String>,
126    /// Who initiated the restoration
127    pub restored_by: Option<String>,
128}
129
130/// Response from restoring a job
131#[derive(Debug, Serialize)]
132pub struct RestoreResponse {
133    /// The restored job
134    pub job: hammerwork::Job,
135    /// When the job was restored
136    pub restored_at: chrono::DateTime<chrono::Utc>,
137    /// Who restored the job
138    pub restored_by: Option<String>,
139}
140
141/// Request to purge archived jobs
142#[derive(Debug, Deserialize)]
143pub struct PurgeRequest {
144    /// Delete archived jobs older than this date
145    pub older_than: chrono::DateTime<chrono::Utc>,
146    /// Whether this is a dry run
147    pub dry_run: bool,
148    /// Who initiated the purge
149    pub purged_by: Option<String>,
150}
151
152/// Response from purging archived jobs
153#[derive(Debug, Serialize)]
154pub struct PurgeResponse {
155    /// Number of jobs that would be (or were) purged
156    pub jobs_purged: u64,
157    /// Whether this was a dry run
158    pub dry_run: bool,
159    /// When the purge was executed
160    pub executed_at: chrono::DateTime<chrono::Utc>,
161}
162
163/// Archived job information for API responses
164#[derive(Debug, Serialize)]
165pub struct ArchivedJobInfo {
166    /// Job ID
167    pub id: JobId,
168    /// Queue name
169    pub queue_name: String,
170    /// Original job status
171    pub status: JobStatus,
172    /// When the job was created
173    pub created_at: chrono::DateTime<chrono::Utc>,
174    /// When the job was archived
175    pub archived_at: chrono::DateTime<chrono::Utc>,
176    /// Reason for archival
177    pub archival_reason: ArchivalReason,
178    /// Original payload size in bytes
179    pub original_payload_size: Option<usize>,
180    /// Whether the payload was compressed
181    pub payload_compressed: bool,
182    /// Who archived the job
183    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/// Archive statistics response
203#[derive(Debug, Serialize)]
204pub struct StatsResponse {
205    /// Overall archival statistics
206    pub stats: ArchivalStats,
207    /// Statistics by queue
208    pub by_queue: std::collections::HashMap<String, ArchivalStats>,
209    /// Recent archival operations
210    pub recent_operations: Vec<RecentOperation>,
211}
212
213/// Information about recent archival operations
214#[derive(Debug, Serialize)]
215pub struct RecentOperation {
216    /// Type of operation (archive, restore, purge)
217    pub operation_type: String,
218    /// Queue affected (if applicable)
219    pub queue_name: Option<String>,
220    /// Number of jobs affected
221    pub jobs_affected: u64,
222    /// When the operation occurred
223    pub executed_at: chrono::DateTime<chrono::Utc>,
224    /// Who executed the operation
225    pub executed_by: Option<String>,
226    /// Operation reason
227    pub reason: Option<String>,
228}
229
230/// Archive filter parameters
231#[derive(Debug, Deserialize, Default)]
232pub struct ArchiveFilterParams {
233    /// Filter by queue name
234    pub queue: Option<String>,
235    /// Filter by archival reason
236    pub reason: Option<String>,
237    /// Filter by archived after date
238    pub archived_after: Option<chrono::DateTime<chrono::Utc>>,
239    /// Filter by archived before date
240    pub archived_before: Option<chrono::DateTime<chrono::Utc>>,
241    /// Filter by who archived
242    pub archived_by: Option<String>,
243    /// Filter by compression status
244    pub compressed: Option<bool>,
245    /// Filter by original job status
246    pub original_status: Option<String>,
247}
248
249/// Create archive API routes
250pub 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
294/// Extract archive filter parameters from query string
295fn with_archive_filters()
296-> impl Filter<Extract = (ArchiveFilterParams,), Error = warp::Rejection> + Clone {
297    warp::query::<ArchiveFilterParams>()
298}
299
300/// Handle archive jobs request
301async 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        // Count the jobs a real run would archive now; nothing is written, so the other
313        // statistics (sizes, compression, duration) stay at their defaults.
314        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
355/// Handle list archived jobs request. Filtering, counting and paging happen in the database,
356/// so a request reads one page, never the whole archive.
357async 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
380/// Handle restore job request
381async 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
412/// Handle purge jobs request
413async 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        // Count the archived jobs the real purge would delete: those archived at or before
422        // the cutoff. Only the count is read.
423        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
454/// Handle archive stats request
455async 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            // Collect per-queue stats if no specific queue is filtered
465            let mut by_queue = std::collections::HashMap::new();
466
467            if filters.queue.is_none() {
468                // Get queue list and collect stats for each
469                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            // Archive/purge operations are not recorded anywhere, so there is no history to
492            // report; an empty list is the truth.
493            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}