Skip to main content

hammerwork_web/api/
jobs.rs

1//! Job management API endpoints.
2//!
3//! This module provides comprehensive REST API endpoints for managing Hammerwork jobs,
4//! including creating, listing, searching, and performing actions on jobs.
5//!
6//! # API Endpoints
7//!
8//! - `GET /api/jobs` - List jobs with filtering, pagination, and sorting
9//! - `POST /api/jobs` - Create a new job
10//! - `GET /api/jobs/{id}` - Get details of a specific job
11//! - `POST /api/jobs/{id}/actions` - Perform actions on a job (retry, cancel, delete)
12//! - `POST /api/jobs/bulk` - Perform bulk actions on multiple jobs
13//! - `POST /api/jobs/search` - Search jobs with full-text queries
14//!
15//! # Examples
16//!
17//! ## Creating a Job
18//!
19//! ```rust
20//! use hammerwork_web::api::jobs::CreateJobRequest;
21//! use serde_json::json;
22//!
23//! let create_request = CreateJobRequest {
24//!     queue_name: "email_queue".to_string(),
25//!     payload: json!({
26//!         "to": "user@example.com",
27//!         "subject": "Welcome!",
28//!         "template": "welcome_email"
29//!     }),
30//!     priority: Some("high".to_string()),
31//!     scheduled_at: None,
32//!     max_attempts: Some(3),
33//!     cron_schedule: None,
34//!     trace_id: Some("trace-123".to_string()),
35//!     correlation_id: Some("corr-456".to_string()),
36//! };
37//!
38//! // This would be sent as JSON in a POST request to /api/jobs
39//! let json_payload = serde_json::to_string(&create_request).unwrap();
40//! assert!(json_payload.contains("email_queue"));
41//! assert!(json_payload.contains("high"));
42//! ```
43//!
44//! ## Job Actions
45//!
46//! ```rust
47//! use hammerwork_web::api::jobs::JobActionRequest;
48//!
49//! let retry_request = JobActionRequest {
50//!     action: "retry".to_string(),
51//!     reason: Some("Network issue resolved".to_string()),
52//! };
53//!
54//! let cancel_request = JobActionRequest {
55//!     action: "cancel".to_string(),
56//!     reason: Some("No longer needed".to_string()),
57//! };
58//!
59//! assert_eq!(retry_request.action, "retry");
60//! assert_eq!(cancel_request.action, "cancel");
61//! ```
62//!
63//! ## Bulk Operations
64//!
65//! ```rust
66//! use hammerwork_web::api::jobs::BulkJobActionRequest;
67//!
68//! let bulk_delete = BulkJobActionRequest {
69//!     job_ids: vec![
70//!         "550e8400-e29b-41d4-a716-446655440000".to_string(),
71//!         "550e8400-e29b-41d4-a716-446655440001".to_string(),
72//!     ],
73//!     action: "delete".to_string(),
74//!     reason: Some("Cleanup old failed jobs".to_string()),
75//! };
76//!
77//! assert_eq!(bulk_delete.job_ids.len(), 2);
78//! assert_eq!(bulk_delete.action, "delete");
79//! ```
80
81use super::{
82    ApiResponse, FilterParams, PaginatedResponse, PaginationMeta, PaginationParams, SortParams,
83    with_filters, with_pagination, with_sort,
84};
85use super::{error_reply, json_reply, with_shared};
86use hammerwork::queue::DatabaseQueue;
87use serde::{Deserialize, Serialize};
88use std::sync::Arc;
89use warp::http::StatusCode;
90use warp::{Filter, Reply};
91
92/// Job information for API responses
93#[derive(Debug, Serialize)]
94pub struct JobInfo {
95    pub id: String,
96    pub queue_name: String,
97    pub status: String,
98    pub priority: String,
99    pub attempts: i32,
100    pub max_attempts: i32,
101    pub payload: serde_json::Value,
102    pub created_at: chrono::DateTime<chrono::Utc>,
103    pub scheduled_at: chrono::DateTime<chrono::Utc>,
104    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
105    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
106    pub failed_at: Option<chrono::DateTime<chrono::Utc>>,
107    pub error_message: Option<String>,
108    pub processing_time_ms: Option<i64>,
109    pub cron_schedule: Option<String>,
110    pub is_recurring: bool,
111    pub trace_id: Option<String>,
112    pub correlation_id: Option<String>,
113}
114
115/// Job creation request
116#[derive(Debug, Deserialize, Serialize)]
117pub struct CreateJobRequest {
118    pub queue_name: String,
119    pub payload: serde_json::Value,
120    pub priority: Option<String>,
121    pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
122    pub max_attempts: Option<i32>,
123    pub cron_schedule: Option<String>,
124    pub trace_id: Option<String>,
125    pub correlation_id: Option<String>,
126}
127
128/// Job action request
129#[derive(Debug, Deserialize)]
130pub struct JobActionRequest {
131    pub action: String, // "retry", "cancel", "delete"
132    pub reason: Option<String>,
133}
134
135/// The most job IDs one bulk action may name.
136pub const MAX_BULK_JOB_IDS: usize = 1000;
137
138/// Bulk job action request (at most [`MAX_BULK_JOB_IDS`] IDs)
139#[derive(Debug, Deserialize)]
140pub struct BulkJobActionRequest {
141    pub job_ids: Vec<String>,
142    pub action: String,
143    pub reason: Option<String>,
144}
145
146/// Job search request
147#[derive(Debug, Deserialize)]
148pub struct JobSearchRequest {
149    pub query: String,
150    pub queues: Option<Vec<String>>,
151    pub statuses: Option<Vec<String>>,
152    pub priorities: Option<Vec<String>>,
153    pub created_after: Option<chrono::DateTime<chrono::Utc>>,
154    pub created_before: Option<chrono::DateTime<chrono::Utc>>,
155}
156
157/// Create job routes
158pub fn routes<T>(
159    queue: Arc<T>,
160) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
161where
162    T: DatabaseQueue + Send + Sync + 'static,
163{
164    let queue_filter = with_shared(queue);
165
166    let list_jobs = warp::path("jobs")
167        .and(warp::path::end())
168        .and(warp::get())
169        .and(queue_filter.clone())
170        .and(with_pagination())
171        .and(with_filters())
172        .and(with_sort())
173        .and_then(list_jobs_handler);
174
175    let create_job = warp::path("jobs")
176        .and(warp::path::end())
177        .and(warp::post())
178        .and(queue_filter.clone())
179        .and(crate::security::json_body())
180        .and_then(create_job_handler);
181
182    let get_job = warp::path("jobs")
183        .and(warp::path::param::<String>())
184        .and(warp::path::end())
185        .and(warp::get())
186        .and(queue_filter.clone())
187        .and_then(get_job_handler);
188
189    let job_action = warp::path("jobs")
190        .and(warp::path::param::<String>())
191        .and(warp::path("actions"))
192        .and(warp::path::end())
193        .and(warp::post())
194        .and(queue_filter.clone())
195        .and(crate::security::json_body())
196        .and_then(job_action_handler);
197
198    let bulk_action = warp::path("jobs")
199        .and(warp::path("bulk"))
200        .and(warp::path::end())
201        .and(warp::post())
202        .and(queue_filter.clone())
203        .and(crate::security::json_body())
204        .and_then(bulk_job_action_handler);
205
206    let search_jobs = warp::path("jobs")
207        .and(warp::path("search"))
208        .and(warp::path::end())
209        .and(warp::post())
210        .and(queue_filter)
211        .and(crate::security::json_body())
212        .and(with_pagination())
213        .and_then(search_jobs_handler);
214
215    list_jobs
216        .or(create_job)
217        .or(get_job)
218        .or(job_action)
219        .or(bulk_action)
220        .or(search_jobs)
221}
222
223/// The API representation of a job.
224fn job_info(job: &hammerwork::Job) -> JobInfo {
225    let end = job.completed_at.or(job.failed_at).or(job.timed_out_at);
226    JobInfo {
227        id: job.id.to_string(),
228        queue_name: job.queue_name.clone(),
229        status: job.status.as_str().to_string(),
230        priority: job.priority.to_string(),
231        attempts: job.attempts,
232        max_attempts: job.max_attempts,
233        payload: job.payload.clone(),
234        created_at: job.created_at,
235        scheduled_at: job.scheduled_at,
236        started_at: job.started_at,
237        completed_at: job.completed_at,
238        failed_at: job.failed_at,
239        error_message: job.error_message.clone(),
240        processing_time_ms: job
241            .started_at
242            .zip(end)
243            .map(|(start, end)| (end - start).num_milliseconds()),
244        cron_schedule: job.cron_schedule.clone(),
245        is_recurring: job.is_recurring(),
246        trace_id: job.trace_id.clone(),
247        correlation_id: job.correlation_id.clone(),
248    }
249}
250
251/// Whether a job satisfies the `status` filter of the listing: `failed` covers every
252/// unsuccessful terminal status, `recurring` selects recurring jobs, anything else must equal
253/// the job's status (ignoring case).
254fn status_matches(filter: &str, job: &JobInfo) -> bool {
255    let status = job.status.to_lowercase();
256    match filter {
257        "failed" => matches!(status.as_str(), "failed" | "dead" | "timedout"),
258        "recurring" => job.is_recurring,
259        other => status == other,
260    }
261}
262
263/// Order of priorities for sorting (`background` lowest).
264fn priority_rank(priority: &str) -> i32 {
265    priority
266        .parse::<hammerwork::JobPriority>()
267        .map(|p| p.as_i32())
268        .unwrap_or(-1)
269}
270
271/// The jobs of `queue_name` the API can enumerate: pending jobs ready to run, dead jobs
272/// and recurring jobs, without duplicates (a recurring job can also be ready).
273async fn collect_jobs<T>(
274    queue: &T,
275    queue_name: &str,
276    include_ready: bool,
277    include_dead: bool,
278    include_recurring: bool,
279    limit: u32,
280) -> hammerwork::Result<Vec<hammerwork::Job>>
281where
282    T: DatabaseQueue + Send + Sync,
283{
284    let mut jobs = Vec::new();
285    if include_ready {
286        jobs.extend(queue.get_ready_jobs(queue_name, limit).await?);
287    }
288    if include_dead {
289        jobs.extend(
290            queue
291                .get_dead_jobs_by_queue(queue_name, Some(limit), Some(0))
292                .await?,
293        );
294    }
295    if include_recurring {
296        jobs.extend(queue.get_recurring_jobs(queue_name).await?);
297    }
298    let mut seen = std::collections::HashSet::new();
299    jobs.retain(|job| seen.insert(job.id));
300    Ok(jobs)
301}
302
303/// Page `items` according to `pagination` (default 20 per page, at most 100).
304fn paginate<I>(items: Vec<I>, pagination: &PaginationParams) -> PaginatedResponse<I> {
305    let limit = pagination.limit.unwrap_or(20).clamp(1, 100);
306    let page = pagination.page.unwrap_or(1).max(1);
307    let offset = pagination
308        .offset
309        .unwrap_or_else(|| (page - 1).saturating_mul(limit));
310    let total = items.len() as u64;
311    let items = items
312        .into_iter()
313        .skip(offset as usize)
314        .take(limit as usize)
315        .collect();
316    // Describe the page that was actually served, not the requested limit.
317    let served = PaginationParams {
318        page: Some((offset / limit).saturating_add(1)),
319        limit: Some(limit),
320        offset: Some(offset),
321    };
322    PaginatedResponse {
323        items,
324        pagination: PaginationMeta::new(&served, total),
325    }
326}
327
328/// Handler for listing jobs
329pub(crate) async fn list_jobs_handler<T>(
330    queue: Arc<T>,
331    pagination: PaginationParams,
332    filters: FilterParams,
333    sort: SortParams,
334) -> Result<impl Reply, warp::Rejection>
335where
336    T: DatabaseQueue + Send + Sync,
337{
338    // Since DatabaseQueue doesn't provide direct list methods with filters,
339    // we'll use the available methods to gather jobs
340    let mut all_jobs = Vec::new();
341
342    // Get queue stats to find available queues
343    let queue_stats = match queue.get_all_queue_stats().await {
344        Ok(stats) => stats,
345        Err(e) => {
346            return Ok(error_reply(
347                StatusCode::INTERNAL_SERVER_ERROR,
348                format!("Failed to get queue stats: {}", e),
349            ));
350        }
351    };
352
353    // Filter by queue if specified
354    let target_queues: Vec<String> = if let Some(ref queue_name) = filters.queue {
355        vec![queue_name.clone()]
356    } else {
357        queue_stats.iter().map(|s| s.queue_name.clone()).collect()
358    };
359
360    let status_filter = filters.status.as_deref().map(str::to_lowercase);
361    let search_term = filters
362        .search
363        .as_deref()
364        .map(str::trim)
365        .filter(|term| !term.is_empty())
366        .map(str::to_lowercase);
367    let wants = |names: &[&str]| {
368        status_filter
369            .as_deref()
370            .is_none_or(|status| names.contains(&status))
371    };
372
373    // For each queue, get jobs from different sources based on status filter
374    for queue_name in &target_queues {
375        let queue_jobs = try_api!(
376            collect_jobs(
377                queue.as_ref(),
378                queue_name,
379                wants(&["pending"]),
380                wants(&["failed", "dead"]),
381                wants(&["recurring"]),
382                100
383            )
384            .await,
385            "Failed to list jobs"
386        );
387
388        for job in &queue_jobs {
389            if filters
390                .created_after
391                .is_some_and(|after| job.created_at < after)
392                || filters
393                    .created_before
394                    .is_some_and(|before| job.created_at > before)
395            {
396                continue;
397            }
398            if let Some(ref term) = search_term
399                && !job_matches_search(job, term)
400            {
401                continue;
402            }
403
404            let job_info = job_info(job);
405
406            // Apply status filter
407            if let Some(ref status) = status_filter
408                && !status_matches(status, &job_info)
409            {
410                continue;
411            }
412
413            // Apply priority filter
414            if let Some(ref priority) = filters.priority
415                && !job_info.priority.eq_ignore_ascii_case(priority)
416            {
417                continue;
418            }
419
420            all_jobs.push(job_info);
421        }
422    }
423
424    // Sort jobs ascending, then reverse for descending order (the default). Without a
425    // known `sort_by` the jobs are sorted by creation time, as `SortParams::get_order_by`
426    // describes, still honouring `sort_order`.
427    match sort.sort_by.as_deref() {
428        Some("scheduled_at") => all_jobs.sort_by_key(|j| j.scheduled_at),
429        Some("priority") => all_jobs.sort_by_key(|j| priority_rank(&j.priority)),
430        _ => all_jobs.sort_by_key(|j| j.created_at),
431    }
432    if !sort.is_ascending() {
433        all_jobs.reverse();
434    }
435
436    Ok(json_reply(&ApiResponse::success(paginate(
437        all_jobs,
438        &pagination,
439    ))))
440}
441
442/// Handler for creating a new job
443async fn create_job_handler<T>(
444    queue: Arc<T>,
445    request: CreateJobRequest,
446) -> Result<impl Reply, warp::Rejection>
447where
448    T: DatabaseQueue + Send + Sync,
449{
450    use hammerwork::{CronSchedule, Job, JobPriority};
451
452    if request.queue_name.trim().is_empty() {
453        return Ok(error_reply(
454            StatusCode::BAD_REQUEST,
455            "queue_name must not be empty",
456        ));
457    }
458
459    let priority = match request.priority.as_deref() {
460        None => JobPriority::Normal,
461        Some(name) => match name.parse::<JobPriority>() {
462            Ok(priority) => priority,
463            Err(_) => {
464                return Ok(error_reply(
465                    StatusCode::BAD_REQUEST,
466                    format!(
467                        "Invalid priority '{}'. Valid options: background, low, normal, high, critical",
468                        name
469                    ),
470                ));
471            }
472        },
473    };
474
475    if let Some(max_attempts) = request.max_attempts
476        && max_attempts < 1
477    {
478        return Ok(error_reply(
479            StatusCode::BAD_REQUEST,
480            "max_attempts must be at least 1",
481        ));
482    }
483
484    let mut job = Job::new(request.queue_name, request.payload).with_priority(priority);
485
486    if let Some(expression) = request.cron_schedule.as_deref() {
487        let schedule = match CronSchedule::new(expression) {
488            Ok(schedule) => schedule,
489            Err(e) => {
490                return Ok(error_reply(
491                    StatusCode::BAD_REQUEST,
492                    format!("Invalid cron schedule: {}", e),
493                ));
494            }
495        };
496        job = match job.with_cron(schedule) {
497            Ok(job) => job,
498            Err(e) => {
499                return Ok(error_reply(
500                    StatusCode::BAD_REQUEST,
501                    format!("Invalid cron schedule: {}", e),
502                ));
503            }
504        };
505    } else if let Some(scheduled_at) = request.scheduled_at {
506        job.scheduled_at = scheduled_at;
507    }
508
509    if let Some(max_attempts) = request.max_attempts {
510        job = job.with_max_attempts(max_attempts);
511    }
512
513    if let Some(trace_id) = request.trace_id {
514        job.trace_id = Some(trace_id);
515    }
516
517    if let Some(correlation_id) = request.correlation_id {
518        job.correlation_id = Some(correlation_id);
519    }
520
521    match queue.enqueue(job).await {
522        Ok(job_id) => {
523            let response = ApiResponse::success(serde_json::json!({
524                "message": "Job created successfully",
525                "job_id": job_id.to_string()
526            }));
527            Ok(json_reply(&response))
528        }
529        Err(e) => Ok(error_reply(
530            StatusCode::INTERNAL_SERVER_ERROR,
531            format!("Failed to create job: {}", e),
532        )),
533    }
534}
535
536/// Handler for getting a specific job
537async fn get_job_handler<T>(job_id: String, queue: Arc<T>) -> Result<impl Reply, warp::Rejection>
538where
539    T: DatabaseQueue + Send + Sync,
540{
541    let job_uuid = match uuid::Uuid::parse_str(&job_id) {
542        Ok(uuid) => uuid,
543        Err(_) => {
544            return Ok(error_reply(
545                StatusCode::BAD_REQUEST,
546                "Invalid job ID format".to_string(),
547            ));
548        }
549    };
550
551    match queue.get_job(job_uuid).await {
552        Ok(Some(job)) => Ok(json_reply(&ApiResponse::success(job_info(&job)))),
553        Ok(None) => Ok(error_reply(
554            StatusCode::NOT_FOUND,
555            format!("Job '{}' not found", job_id),
556        )),
557        Err(e) => Ok(error_reply(
558            StatusCode::INTERNAL_SERVER_ERROR,
559            format!("Failed to get job: {}", e),
560        )),
561    }
562}
563
564/// Handler for job actions
565async fn job_action_handler<T>(
566    job_id: String,
567    queue: Arc<T>,
568    action_request: JobActionRequest,
569) -> Result<impl Reply, warp::Rejection>
570where
571    T: DatabaseQueue + Send + Sync,
572{
573    let job_uuid = match uuid::Uuid::parse_str(&job_id) {
574        Ok(uuid) => uuid,
575        Err(_) => {
576            return Ok(error_reply(
577                StatusCode::BAD_REQUEST,
578                "Invalid job ID format".to_string(),
579            ));
580        }
581    };
582
583    match action_request.action.as_str() {
584        "retry" => match retry_job_action(queue.as_ref(), job_uuid).await {
585            Ok(()) => {
586                let response = ApiResponse::success(serde_json::json!({
587                    "message": format!("Job '{}' scheduled for retry", job_id)
588                }));
589                Ok(json_reply(&response))
590            }
591            Err(e) => Ok(super::queue_error_reply("Failed to retry job", &e)),
592        },
593        "cancel" | "delete" => match delete_job_action(queue.as_ref(), job_uuid).await {
594            Ok(()) => {
595                let response = ApiResponse::success(serde_json::json!({
596                    "message": format!("Job '{}' deleted", job_id)
597                }));
598                Ok(json_reply(&response))
599            }
600            Err(e) => Ok(super::queue_error_reply("Failed to delete job", &e)),
601        },
602        _ => Ok(error_reply(
603            StatusCode::BAD_REQUEST,
604            format!("Unknown action: {}", action_request.action),
605        )),
606    }
607}
608
609/// Delete a job, reporting a missing job as `JobNotFound` instead of silently succeeding.
610async fn delete_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
611where
612    T: DatabaseQueue + Send + Sync,
613{
614    if queue.get_job(job_id).await?.is_none() {
615        return Err(hammerwork::HammerworkError::JobNotFound {
616            id: job_id.to_string(),
617        });
618    }
619    queue.delete_job(job_id).await
620}
621
622/// Re-run a job now: `Dead` and `TimedOut` jobs go through `retry_dead_job` (which also
623/// resets their attempts); other retryable statuses through `retry_job`. Statuses that
624/// cannot be retried (e.g. `Completed`) return an `InvalidJobTransition` error, and a
625/// missing job `JobNotFound`.
626async fn retry_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
627where
628    T: DatabaseQueue + Send + Sync,
629{
630    let job =
631        queue
632            .get_job(job_id)
633            .await?
634            .ok_or_else(|| hammerwork::HammerworkError::JobNotFound {
635                id: job_id.to_string(),
636            })?;
637    match job.status {
638        hammerwork::JobStatus::Dead | hammerwork::JobStatus::TimedOut => {
639            queue.retry_dead_job(job_id).await
640        }
641        _ => queue.retry_job(job_id, chrono::Utc::now()).await,
642    }
643}
644
645/// Handler for bulk job actions
646async fn bulk_job_action_handler<T>(
647    queue: Arc<T>,
648    request: BulkJobActionRequest,
649) -> Result<impl Reply, warp::Rejection>
650where
651    T: DatabaseQueue + Send + Sync,
652{
653    if !matches!(request.action.as_str(), "retry" | "delete") {
654        return Ok(error_reply(
655            StatusCode::BAD_REQUEST,
656            format!("Unknown action: {}", request.action),
657        ));
658    }
659    if request.job_ids.len() > MAX_BULK_JOB_IDS {
660        return Ok(error_reply(
661            StatusCode::BAD_REQUEST,
662            format!(
663                "Too many job IDs: {} (at most {} per request)",
664                request.job_ids.len(),
665                MAX_BULK_JOB_IDS
666            ),
667        ));
668    }
669
670    let mut successful = 0;
671    let mut failed = 0;
672    let mut errors = Vec::new();
673
674    for job_id_str in &request.job_ids {
675        let job_uuid = match uuid::Uuid::parse_str(job_id_str) {
676            Ok(uuid) => uuid,
677            Err(_) => {
678                failed += 1;
679                errors.push(format!("Invalid job ID: {}", job_id_str));
680                continue;
681            }
682        };
683
684        let result = match request.action.as_str() {
685            "retry" => retry_job_action(queue.as_ref(), job_uuid).await,
686            _ => delete_job_action(queue.as_ref(), job_uuid).await,
687        };
688
689        match result {
690            Ok(()) => successful += 1,
691            Err(e) => {
692                failed += 1;
693                errors.push(format!("Job {}: {}", job_id_str, e));
694            }
695        }
696    }
697
698    let response = ApiResponse::success(serde_json::json!({
699        "successful": successful,
700        "failed": failed,
701        "errors": errors,
702        "message": format!("Bulk {} completed: {} successful, {} failed", request.action, successful, failed)
703    }));
704
705    Ok(json_reply(&response))
706}
707
708/// Whether `job` matches the lowercase search `term` (id, queue, payload, error, trace ids).
709fn job_matches_search(job: &hammerwork::Job, term: &str) -> bool {
710    job.id.to_string().contains(term)
711        || job.queue_name.to_lowercase().contains(term)
712        || payload_search_text(&job.payload).contains(term)
713        || [&job.error_message, &job.trace_id, &job.correlation_id]
714            .into_iter()
715            .flatten()
716            .any(|text| text.to_lowercase().contains(term))
717}
718
719/// Handler for searching jobs
720async fn search_jobs_handler<T>(
721    queue: Arc<T>,
722    search_request: JobSearchRequest,
723    pagination: PaginationParams,
724) -> Result<impl Reply, warp::Rejection>
725where
726    T: DatabaseQueue + Send + Sync,
727{
728    // Since we don't have direct search methods, we'll gather jobs and filter in memory
729    let mut matching_jobs = Vec::new();
730    let search_term = search_request.query.to_lowercase();
731
732    // Get queue stats to find available queues
733    let queue_stats = match queue.get_all_queue_stats().await {
734        Ok(stats) => stats,
735        Err(e) => {
736            return Ok(error_reply(
737                StatusCode::INTERNAL_SERVER_ERROR,
738                format!("Failed to get queue stats: {}", e),
739            ));
740        }
741    };
742
743    // Filter by specified queues or use all
744    let target_queues: Vec<String> = if let Some(ref queue_names) = search_request.queues {
745        queue_names.clone()
746    } else {
747        queue_stats.iter().map(|s| s.queue_name.clone()).collect()
748    };
749
750    for queue_name in &target_queues {
751        // Search every source: ready, dead and recurring jobs
752        let queue_jobs = try_api!(
753            collect_jobs(queue.as_ref(), queue_name, true, true, true, 200).await,
754            "Failed to list jobs"
755        );
756
757        for job in &queue_jobs {
758            if !job_matches_search(job, &search_term) {
759                continue;
760            }
761
762            // Apply status filter
763            if let Some(ref statuses) = search_request.statuses
764                && !statuses
765                    .iter()
766                    .any(|s| s.eq_ignore_ascii_case(job.status.as_str()))
767            {
768                continue;
769            }
770
771            // Apply priority filter
772            if let Some(ref priorities) = search_request.priorities {
773                let job_priority = job.priority.to_string();
774                if !priorities
775                    .iter()
776                    .any(|p| p.eq_ignore_ascii_case(&job_priority))
777                {
778                    continue;
779                }
780            }
781
782            // Apply date filters
783            if let Some(ref created_after) = search_request.created_after
784                && job.created_at < *created_after
785            {
786                continue;
787            }
788
789            if let Some(ref created_before) = search_request.created_before
790                && job.created_at > *created_before
791            {
792                continue;
793            }
794
795            matching_jobs.push(job_info(job));
796        }
797    }
798
799    // Sort by created_at desc by default
800    matching_jobs.sort_by_key(|j| std::cmp::Reverse(j.created_at));
801
802    Ok(json_reply(&ApiResponse::success(paginate(
803        matching_jobs,
804        &pagination,
805    ))))
806}
807
808/// Lowercased text of a job payload used for substring search.
809///
810/// `serde_json::Value` always serializes, so this does not lose data; it is
811/// kept infallible so a (theoretical) failure cannot make a job unsearchable
812/// silently: such a payload falls back to `Value`'s `Display` output.
813fn payload_search_text(payload: &serde_json::Value) -> String {
814    match serde_json::to_string(payload) {
815        Ok(text) => text.to_lowercase(),
816        Err(e) => {
817            tracing::warn!(error = %e, "failed to serialize job payload for search");
818            payload.to_string().to_lowercase()
819        }
820    }
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use crate::api::test_support::{body_json, unreachable_queue};
827
828    #[tokio::test]
829    async fn test_list_jobs_returns_500_when_database_is_down() {
830        let response = list_jobs_handler(
831            unreachable_queue(),
832            PaginationParams::default(),
833            serde_json::from_value(serde_json::json!({})).unwrap(),
834            SortParams {
835                sort_by: None,
836                sort_order: None,
837            },
838        )
839        .await
840        .unwrap()
841        .into_response();
842        let (status, body) = body_json(response).await;
843        assert_eq!(status, 500);
844        assert_eq!(body["success"], false);
845        assert!(
846            body["error"]
847                .as_str()
848                .unwrap()
849                .contains("Failed to get queue stats")
850        );
851    }
852
853    /// Calls `list_jobs_handler` with `query` (filter and sort parameters) and returns
854    /// the ids of the listed jobs, in order.
855    async fn listed_ids(
856        queue: Arc<hammerwork::queue::test::TestQueue>,
857        query: serde_json::Value,
858    ) -> Vec<String> {
859        let response = list_jobs_handler(
860            queue,
861            PaginationParams::default(),
862            serde_json::from_value(query.clone()).unwrap(),
863            serde_json::from_value(query).unwrap(),
864        )
865        .await
866        .unwrap()
867        .into_response();
868        let (status, body) = body_json(response).await;
869        assert_eq!(status, 200, "{body}");
870        body["data"]["items"]
871            .as_array()
872            .unwrap()
873            .iter()
874            .map(|item| item["id"].as_str().unwrap().to_string())
875            .collect()
876    }
877
878    /// Two pending jobs in queue `q`, the second created a minute after the first.
879    async fn two_jobs() -> (
880        Arc<hammerwork::queue::test::TestQueue>,
881        String,
882        String,
883        chrono::DateTime<chrono::Utc>,
884    ) {
885        use hammerwork::{Job, queue::test::TestQueue};
886        let queue = Arc::new(TestQueue::new());
887        let first = queue
888            .enqueue(Job::new("q".into(), serde_json::json!({"name": "alpha"})))
889            .await
890            .unwrap();
891        let between = queue.clock().now() + chrono::Duration::seconds(30);
892        queue.clock().advance(chrono::Duration::minutes(1));
893        let second = queue
894            .enqueue(Job::new("q".into(), serde_json::json!({"name": "beta"})))
895            .await
896            .unwrap();
897        (queue, first.to_string(), second.to_string(), between)
898    }
899
900    #[tokio::test]
901    async fn test_list_jobs_applies_created_and_search_filters() {
902        let (queue, first, second, between) = two_jobs().await;
903        let ids = |query| listed_ids(queue.clone(), query);
904
905        assert_eq!(
906            ids(serde_json::json!({"queue": "q", "created_after": between})).await,
907            vec![second.clone()]
908        );
909        assert_eq!(
910            ids(serde_json::json!({"queue": "q", "created_before": between})).await,
911            vec![first.clone()]
912        );
913        assert_eq!(
914            ids(serde_json::json!({"queue": "q", "search": "ALPHA"})).await,
915            vec![first]
916        );
917        assert!(
918            ids(serde_json::json!({"queue": "q", "search": "no such job"}))
919                .await
920                .is_empty()
921        );
922    }
923
924    #[tokio::test]
925    async fn test_list_jobs_sort_order_applies_to_the_default_sort() {
926        let (queue, first, second, _) = two_jobs().await;
927        let ids = |query| listed_ids(queue.clone(), query);
928
929        // Newest first by default
930        assert_eq!(
931            ids(serde_json::json!({"queue": "q"})).await,
932            vec![second.clone(), first.clone()]
933        );
934        // `sort_order` without `sort_by` sorts by creation time in that order
935        assert_eq!(
936            ids(serde_json::json!({"queue": "q", "sort_order": "asc"})).await,
937            vec![first.clone(), second.clone()]
938        );
939        // An unknown field falls back to creation time, in the requested order
940        assert_eq!(
941            ids(serde_json::json!({"queue": "q", "sort_by": "attempts", "sort_order": "ASC"}))
942                .await,
943            vec![first.clone(), second.clone()]
944        );
945        assert_eq!(
946            ids(serde_json::json!({"queue": "q", "sort_by": "created_at", "sort_order": "desc"}))
947                .await,
948            vec![second, first]
949        );
950    }
951
952    #[tokio::test]
953    async fn test_invalid_job_id_is_400() {
954        let response = get_job_handler("not-a-uuid".to_string(), unreachable_queue())
955            .await
956            .unwrap()
957            .into_response();
958        let (status, _) = body_json(response).await;
959        assert_eq!(status, 400);
960    }
961
962    #[test]
963    fn test_payload_search_text_lowercases_json() {
964        let payload = serde_json::json!({"To": "User@Example.com"});
965        let text = payload_search_text(&payload);
966        assert!(text.contains("user@example.com"));
967        assert!(text.contains("\"to\""));
968    }
969
970    #[test]
971    fn test_create_job_request_deserialization() {
972        let json = r#"{
973            "queue_name": "email",
974            "payload": {"to": "user@example.com", "subject": "Hello"},
975            "priority": "high",
976            "max_attempts": 5
977        }"#;
978
979        let request: CreateJobRequest = serde_json::from_str(json).unwrap();
980        assert_eq!(request.queue_name, "email");
981        assert_eq!(request.priority, Some("high".to_string()));
982        assert_eq!(request.max_attempts, Some(5));
983    }
984
985    #[test]
986    fn test_job_action_request_deserialization() {
987        let json = r#"{"action": "retry", "reason": "Network error resolved"}"#;
988        let request: JobActionRequest = serde_json::from_str(json).unwrap();
989        assert_eq!(request.action, "retry");
990        assert_eq!(request.reason, Some("Network error resolved".to_string()));
991    }
992
993    #[test]
994    fn test_bulk_job_action_request() {
995        let json = r#"{
996            "job_ids": ["job-1", "job-2", "job-3"],
997            "action": "delete",
998            "reason": "Cleanup old jobs"
999        }"#;
1000
1001        let request: BulkJobActionRequest = serde_json::from_str(json).unwrap();
1002        assert_eq!(request.job_ids.len(), 3);
1003        assert_eq!(request.action, "delete");
1004    }
1005}