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::history::JobHistory;
82use super::{
83    ApiResponse, FilterParams, PaginatedResponse, PaginationMeta, PaginationParams, SortParams,
84    with_filters, with_pagination, with_sort,
85};
86use super::{error_reply, json_reply, with_shared};
87use hammerwork::queue::DatabaseQueue;
88use serde::{Deserialize, Serialize};
89use std::sync::Arc;
90use warp::http::StatusCode;
91use warp::{Filter, Reply};
92
93/// Job information for API responses
94#[derive(Debug, Serialize)]
95pub struct JobInfo {
96    pub id: String,
97    pub queue_name: String,
98    pub status: String,
99    pub priority: String,
100    pub attempts: i32,
101    pub max_attempts: i32,
102    pub payload: serde_json::Value,
103    pub created_at: chrono::DateTime<chrono::Utc>,
104    pub scheduled_at: chrono::DateTime<chrono::Utc>,
105    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
106    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
107    pub failed_at: Option<chrono::DateTime<chrono::Utc>>,
108    pub error_message: Option<String>,
109    pub processing_time_ms: Option<i64>,
110    pub cron_schedule: Option<String>,
111    pub is_recurring: bool,
112    pub trace_id: Option<String>,
113    pub correlation_id: Option<String>,
114}
115
116/// Job creation request
117#[derive(Debug, Deserialize, Serialize)]
118pub struct CreateJobRequest {
119    pub queue_name: String,
120    pub payload: serde_json::Value,
121    pub priority: Option<String>,
122    pub scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
123    pub max_attempts: Option<i32>,
124    pub cron_schedule: Option<String>,
125    pub trace_id: Option<String>,
126    pub correlation_id: Option<String>,
127}
128
129/// Job action request
130#[derive(Debug, Deserialize)]
131pub struct JobActionRequest {
132    pub action: String, // "retry", "cancel", "delete"
133    pub reason: Option<String>,
134}
135
136/// The most job IDs one bulk action may name.
137pub const MAX_BULK_JOB_IDS: usize = 1000;
138
139/// Bulk job action request (at most [`MAX_BULK_JOB_IDS`] IDs)
140#[derive(Debug, Deserialize)]
141pub struct BulkJobActionRequest {
142    pub job_ids: Vec<String>,
143    pub action: String,
144    pub reason: Option<String>,
145}
146
147/// Job search request
148#[derive(Debug, Deserialize)]
149pub struct JobSearchRequest {
150    pub query: String,
151    pub queues: Option<Vec<String>>,
152    pub statuses: Option<Vec<String>>,
153    pub priorities: Option<Vec<String>>,
154    pub created_after: Option<chrono::DateTime<chrono::Utc>>,
155    pub created_before: Option<chrono::DateTime<chrono::Utc>>,
156}
157
158/// Create job routes
159pub fn routes<T>(
160    queue: Arc<T>,
161) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
162where
163    T: JobHistory + 'static,
164{
165    let queue_filter = with_shared(queue);
166
167    let list_jobs = warp::path("jobs")
168        .and(warp::path::end())
169        .and(warp::get())
170        .and(queue_filter.clone())
171        .and(with_pagination())
172        .and(with_filters())
173        .and(with_sort())
174        .and_then(list_jobs_handler);
175
176    let create_job = warp::path("jobs")
177        .and(warp::path::end())
178        .and(warp::post())
179        .and(queue_filter.clone())
180        .and(crate::security::json_body())
181        .and_then(create_job_handler);
182
183    let get_job = warp::path("jobs")
184        .and(warp::path::param::<String>())
185        .and(warp::path::end())
186        .and(warp::get())
187        .and(queue_filter.clone())
188        .and_then(get_job_handler);
189
190    let job_action = warp::path("jobs")
191        .and(warp::path::param::<String>())
192        .and(warp::path("actions"))
193        .and(warp::path::end())
194        .and(warp::post())
195        .and(queue_filter.clone())
196        .and(crate::security::json_body())
197        .and_then(job_action_handler);
198
199    let bulk_action = warp::path("jobs")
200        .and(warp::path("bulk"))
201        .and(warp::path::end())
202        .and(warp::post())
203        .and(queue_filter.clone())
204        .and(crate::security::json_body())
205        .and_then(bulk_job_action_handler);
206
207    let search_jobs = warp::path("jobs")
208        .and(warp::path("search"))
209        .and(warp::path::end())
210        .and(warp::post())
211        .and(queue_filter)
212        .and(crate::security::json_body())
213        .and(with_pagination())
214        .and_then(search_jobs_handler);
215
216    list_jobs
217        .or(create_job)
218        .or(get_job)
219        .or(job_action)
220        .or(bulk_action)
221        .or(search_jobs)
222}
223
224/// The API representation of a job.
225fn job_info(job: &hammerwork::Job) -> JobInfo {
226    let end = job.completed_at.or(job.failed_at).or(job.timed_out_at);
227    JobInfo {
228        id: job.id.to_string(),
229        queue_name: job.queue_name.clone(),
230        status: job.status.as_str().to_string(),
231        priority: job.priority.to_string(),
232        attempts: job.attempts,
233        max_attempts: job.max_attempts,
234        payload: job.payload.clone(),
235        created_at: job.created_at,
236        scheduled_at: job.scheduled_at,
237        started_at: job.started_at,
238        completed_at: job.completed_at,
239        failed_at: job.failed_at,
240        error_message: job.error_message.clone(),
241        processing_time_ms: job
242            .started_at
243            .zip(end)
244            .map(|(start, end)| (end - start).num_milliseconds()),
245        cron_schedule: job.cron_schedule.clone(),
246        is_recurring: job.is_recurring(),
247        trace_id: job.trace_id.clone(),
248        correlation_id: job.correlation_id.clone(),
249    }
250}
251
252/// The most jobs the listing reads per status (or per queue without a status filter).
253const LISTING_LIMIT: u32 = 100;
254
255/// The most jobs a search reads per status and queue.
256const SEARCH_LIMIT: u32 = 200;
257
258/// Every status a job can be stored in.
259const ALL_STATUSES: [&str; 7] = [
260    "Pending",
261    "Running",
262    "Completed",
263    "Failed",
264    "Dead",
265    "TimedOut",
266    "Retrying",
267];
268
269/// The stored statuses a lowercase `status` filter selects, or `None` when it is not a
270/// plain status (`recurring`, or a name that matches nothing). `failed` covers every
271/// unsuccessful terminal status.
272fn stored_statuses(filter: &str) -> Option<&'static [&'static str]> {
273    Some(match filter {
274        "pending" => &["Pending"],
275        "running" => &["Running"],
276        "completed" => &["Completed"],
277        "failed" => &["Failed", "Dead", "TimedOut"],
278        "dead" => &["Dead"],
279        "timedout" => &["TimedOut"],
280        "retrying" => &["Retrying"],
281        _ => return None,
282    })
283}
284
285/// Whether a job satisfies the `status` filter of the listing: `failed` covers every
286/// unsuccessful terminal status, `recurring` selects recurring jobs, anything else must equal
287/// the job's status (ignoring case).
288fn status_matches(filter: &str, job: &JobInfo) -> bool {
289    let status = job.status.to_lowercase();
290    match filter {
291        "failed" => matches!(status.as_str(), "failed" | "dead" | "timedout"),
292        "recurring" => job.is_recurring,
293        other => status == other,
294    }
295}
296
297/// Order of priorities for sorting (`background` lowest).
298fn priority_rank(priority: &str) -> i32 {
299    priority
300        .parse::<hammerwork::JobPriority>()
301        .map(|p| p.as_i32())
302        .unwrap_or(-1)
303}
304
305/// The jobs of `queue_name` the API can enumerate: pending jobs ready to run, dead jobs
306/// and recurring jobs, without duplicates (a recurring job can also be ready).
307async fn collect_jobs<T>(
308    queue: &T,
309    queue_name: &str,
310    include_ready: bool,
311    include_dead: bool,
312    include_recurring: bool,
313    limit: u32,
314) -> hammerwork::Result<Vec<hammerwork::Job>>
315where
316    T: DatabaseQueue + Send + Sync,
317{
318    let mut jobs = Vec::new();
319    if include_ready {
320        jobs.extend(queue.get_ready_jobs(queue_name, limit).await?);
321    }
322    if include_dead {
323        jobs.extend(
324            queue
325                .get_dead_jobs_by_queue(queue_name, Some(limit), Some(0))
326                .await?,
327        );
328    }
329    if include_recurring {
330        jobs.extend(queue.get_recurring_jobs(queue_name).await?);
331    }
332    let mut seen = std::collections::HashSet::new();
333    jobs.retain(|job| seen.insert(job.id));
334    Ok(jobs)
335}
336
337/// Page `items` according to `pagination` (default 20 per page, at most 100).
338fn paginate<I>(items: Vec<I>, pagination: &PaginationParams) -> PaginatedResponse<I> {
339    let limit = pagination.limit.unwrap_or(20).clamp(1, 100);
340    let page = pagination.page.unwrap_or(1).max(1);
341    let offset = pagination
342        .offset
343        .unwrap_or_else(|| (page - 1).saturating_mul(limit));
344    let total = items.len() as u64;
345    let items = items
346        .into_iter()
347        .skip(offset as usize)
348        .take(limit as usize)
349        .collect();
350    // Describe the page that was actually served, not the requested limit.
351    let served = PaginationParams {
352        page: Some((offset / limit).saturating_add(1)),
353        limit: Some(limit),
354        offset: Some(offset),
355    };
356    PaginatedResponse {
357        items,
358        pagination: PaginationMeta::new(&served, total),
359    }
360}
361
362/// Handler for listing jobs
363pub(crate) async fn list_jobs_handler<T>(
364    queue: Arc<T>,
365    pagination: PaginationParams,
366    filters: FilterParams,
367    sort: SortParams,
368) -> Result<impl Reply, warp::Rejection>
369where
370    T: JobHistory,
371{
372    let status_filter = filters
373        .status
374        .as_deref()
375        .map(str::to_lowercase)
376        .map(|status| {
377            if status == "timed_out" {
378                "timedout".to_string()
379            } else {
380                status
381            }
382        });
383    let search_term = filters
384        .search
385        .as_deref()
386        .map(str::trim)
387        .filter(|term| !term.is_empty())
388        .map(str::to_lowercase);
389
390    let mut all_jobs = Vec::new();
391    let queue_jobs = if let Some(statuses) = status_filter.as_deref().and_then(stored_statuses) {
392        // A plain status filter: read the jobs in those statuses, whenever they are due.
393        let mut jobs = Vec::new();
394        for status in statuses {
395            jobs.extend(try_api!(
396                queue
397                    .jobs_with_status(filters.queue.as_deref(), status, LISTING_LIMIT)
398                    .await,
399                "Failed to list jobs"
400            ));
401        }
402        jobs
403    } else {
404        // No status filter (or `recurring`): the jobs ready to run, dead jobs and
405        // recurring jobs of each queue.
406        let queue_stats = match queue.get_all_queue_stats().await {
407            Ok(stats) => stats,
408            Err(e) => {
409                return Ok(error_reply(
410                    StatusCode::INTERNAL_SERVER_ERROR,
411                    format!("Failed to get queue stats: {}", e),
412                ));
413            }
414        };
415        let target_queues: Vec<String> = if let Some(ref queue_name) = filters.queue {
416            vec![queue_name.clone()]
417        } else {
418            queue_stats.iter().map(|s| s.queue_name.clone()).collect()
419        };
420        let wants = |names: &[&str]| {
421            status_filter
422                .as_deref()
423                .is_none_or(|status| names.contains(&status))
424        };
425        let mut jobs = Vec::new();
426        for queue_name in &target_queues {
427            jobs.extend(try_api!(
428                collect_jobs(
429                    queue.as_ref(),
430                    queue_name,
431                    wants(&["pending"]),
432                    wants(&["failed", "dead"]),
433                    wants(&["recurring"]),
434                    LISTING_LIMIT
435                )
436                .await,
437                "Failed to list jobs"
438            ));
439        }
440        jobs
441    };
442
443    for job in &queue_jobs {
444        if filters
445            .created_after
446            .is_some_and(|after| job.created_at < after)
447            || filters
448                .created_before
449                .is_some_and(|before| job.created_at > before)
450        {
451            continue;
452        }
453        if let Some(ref term) = search_term
454            && !job_matches_search(job, term)
455        {
456            continue;
457        }
458
459        let job_info = job_info(job);
460
461        // Apply status filter
462        if let Some(ref status) = status_filter
463            && !status_matches(status, &job_info)
464        {
465            continue;
466        }
467
468        // Apply priority filter
469        if let Some(ref priority) = filters.priority
470            && !job_info.priority.eq_ignore_ascii_case(priority)
471        {
472            continue;
473        }
474
475        all_jobs.push(job_info);
476    }
477
478    // Sort jobs ascending, then reverse for descending order (the default). Without a
479    // known `sort_by` the jobs are sorted by creation time, as `SortParams::get_order_by`
480    // describes, still honouring `sort_order`.
481    match sort.sort_by.as_deref() {
482        Some("scheduled_at") => all_jobs.sort_by_key(|j| j.scheduled_at),
483        Some("priority") => all_jobs.sort_by_key(|j| priority_rank(&j.priority)),
484        _ => all_jobs.sort_by_key(|j| j.created_at),
485    }
486    if !sort.is_ascending() {
487        all_jobs.reverse();
488    }
489
490    Ok(json_reply(&ApiResponse::success(paginate(
491        all_jobs,
492        &pagination,
493    ))))
494}
495
496/// Handler for creating a new job
497async fn create_job_handler<T>(
498    queue: Arc<T>,
499    request: CreateJobRequest,
500) -> Result<impl Reply, warp::Rejection>
501where
502    T: DatabaseQueue + Send + Sync,
503{
504    use hammerwork::{CronSchedule, Job, JobPriority};
505
506    if request.queue_name.trim().is_empty() {
507        return Ok(error_reply(
508            StatusCode::BAD_REQUEST,
509            "queue_name must not be empty",
510        ));
511    }
512
513    let priority = match request.priority.as_deref() {
514        None => JobPriority::Normal,
515        Some(name) => match name.parse::<JobPriority>() {
516            Ok(priority) => priority,
517            Err(_) => {
518                return Ok(error_reply(
519                    StatusCode::BAD_REQUEST,
520                    format!(
521                        "Invalid priority '{}'. Valid options: background, low, normal, high, critical",
522                        name
523                    ),
524                ));
525            }
526        },
527    };
528
529    if let Some(max_attempts) = request.max_attempts
530        && max_attempts < 1
531    {
532        return Ok(error_reply(
533            StatusCode::BAD_REQUEST,
534            "max_attempts must be at least 1",
535        ));
536    }
537
538    let mut job = Job::new(request.queue_name, request.payload).with_priority(priority);
539
540    if let Some(expression) = request.cron_schedule.as_deref() {
541        let schedule = match CronSchedule::new(expression) {
542            Ok(schedule) => schedule,
543            Err(e) => {
544                return Ok(error_reply(
545                    StatusCode::BAD_REQUEST,
546                    format!("Invalid cron schedule: {}", e),
547                ));
548            }
549        };
550        job = match job.with_cron(schedule) {
551            Ok(job) => job,
552            Err(e) => {
553                return Ok(error_reply(
554                    StatusCode::BAD_REQUEST,
555                    format!("Invalid cron schedule: {}", e),
556                ));
557            }
558        };
559    } else if let Some(scheduled_at) = request.scheduled_at {
560        job.scheduled_at = scheduled_at;
561    }
562
563    if let Some(max_attempts) = request.max_attempts {
564        job = job.with_max_attempts(max_attempts);
565    }
566
567    if let Some(trace_id) = request.trace_id {
568        job.trace_id = Some(trace_id);
569    }
570
571    if let Some(correlation_id) = request.correlation_id {
572        job.correlation_id = Some(correlation_id);
573    }
574
575    match queue.enqueue(job).await {
576        Ok(job_id) => {
577            let response = ApiResponse::success(serde_json::json!({
578                "message": "Job created successfully",
579                "job_id": job_id.to_string()
580            }));
581            Ok(json_reply(&response))
582        }
583        Err(e) => Ok(error_reply(
584            StatusCode::INTERNAL_SERVER_ERROR,
585            format!("Failed to create job: {}", e),
586        )),
587    }
588}
589
590/// Handler for getting a specific job
591async fn get_job_handler<T>(job_id: String, queue: Arc<T>) -> Result<impl Reply, warp::Rejection>
592where
593    T: DatabaseQueue + Send + Sync,
594{
595    let job_uuid = match uuid::Uuid::parse_str(&job_id) {
596        Ok(uuid) => uuid,
597        Err(_) => {
598            return Ok(error_reply(
599                StatusCode::BAD_REQUEST,
600                "Invalid job ID format".to_string(),
601            ));
602        }
603    };
604
605    match queue.get_job(job_uuid).await {
606        Ok(Some(job)) => Ok(json_reply(&ApiResponse::success(job_info(&job)))),
607        Ok(None) => Ok(error_reply(
608            StatusCode::NOT_FOUND,
609            format!("Job '{}' not found", job_id),
610        )),
611        Err(e) => Ok(error_reply(
612            StatusCode::INTERNAL_SERVER_ERROR,
613            format!("Failed to get job: {}", e),
614        )),
615    }
616}
617
618/// Handler for job actions
619async fn job_action_handler<T>(
620    job_id: String,
621    queue: Arc<T>,
622    action_request: JobActionRequest,
623) -> Result<impl Reply, warp::Rejection>
624where
625    T: DatabaseQueue + Send + Sync,
626{
627    let job_uuid = match uuid::Uuid::parse_str(&job_id) {
628        Ok(uuid) => uuid,
629        Err(_) => {
630            return Ok(error_reply(
631                StatusCode::BAD_REQUEST,
632                "Invalid job ID format".to_string(),
633            ));
634        }
635    };
636
637    match action_request.action.as_str() {
638        "retry" => match retry_job_action(queue.as_ref(), job_uuid).await {
639            Ok(()) => {
640                let response = ApiResponse::success(serde_json::json!({
641                    "message": format!("Job '{}' scheduled for retry", job_id)
642                }));
643                Ok(json_reply(&response))
644            }
645            Err(e) => Ok(super::queue_error_reply("Failed to retry job", &e)),
646        },
647        "cancel" | "delete" => match delete_job_action(queue.as_ref(), job_uuid).await {
648            Ok(()) => {
649                let response = ApiResponse::success(serde_json::json!({
650                    "message": format!("Job '{}' deleted", job_id)
651                }));
652                Ok(json_reply(&response))
653            }
654            Err(e) => Ok(super::queue_error_reply("Failed to delete job", &e)),
655        },
656        _ => Ok(error_reply(
657            StatusCode::BAD_REQUEST,
658            format!("Unknown action: {}", action_request.action),
659        )),
660    }
661}
662
663/// Delete a job, reporting a missing job as `JobNotFound` instead of silently succeeding.
664async fn delete_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
665where
666    T: DatabaseQueue + Send + Sync,
667{
668    if queue.get_job(job_id).await?.is_none() {
669        return Err(hammerwork::HammerworkError::JobNotFound {
670            id: job_id.to_string(),
671        });
672    }
673    queue.delete_job(job_id).await
674}
675
676/// Re-run a job now: `Dead` and `TimedOut` jobs go through `retry_dead_job` (which also
677/// resets their attempts); other retryable statuses through `retry_job`. Statuses that
678/// cannot be retried (e.g. `Completed`) return an `InvalidJobTransition` error, and a
679/// missing job `JobNotFound`.
680async fn retry_job_action<T>(queue: &T, job_id: uuid::Uuid) -> hammerwork::Result<()>
681where
682    T: DatabaseQueue + Send + Sync,
683{
684    let job =
685        queue
686            .get_job(job_id)
687            .await?
688            .ok_or_else(|| hammerwork::HammerworkError::JobNotFound {
689                id: job_id.to_string(),
690            })?;
691    match job.status {
692        hammerwork::JobStatus::Dead | hammerwork::JobStatus::TimedOut => {
693            queue.retry_dead_job(job_id).await
694        }
695        _ => queue.retry_job(job_id, chrono::Utc::now()).await,
696    }
697}
698
699/// Handler for bulk job actions
700async fn bulk_job_action_handler<T>(
701    queue: Arc<T>,
702    request: BulkJobActionRequest,
703) -> Result<impl Reply, warp::Rejection>
704where
705    T: DatabaseQueue + Send + Sync,
706{
707    if !matches!(request.action.as_str(), "retry" | "delete") {
708        return Ok(error_reply(
709            StatusCode::BAD_REQUEST,
710            format!("Unknown action: {}", request.action),
711        ));
712    }
713    if request.job_ids.len() > MAX_BULK_JOB_IDS {
714        return Ok(error_reply(
715            StatusCode::BAD_REQUEST,
716            format!(
717                "Too many job IDs: {} (at most {} per request)",
718                request.job_ids.len(),
719                MAX_BULK_JOB_IDS
720            ),
721        ));
722    }
723
724    let mut successful = 0;
725    let mut failed = 0;
726    let mut errors = Vec::new();
727
728    for job_id_str in &request.job_ids {
729        let job_uuid = match uuid::Uuid::parse_str(job_id_str) {
730            Ok(uuid) => uuid,
731            Err(_) => {
732                failed += 1;
733                errors.push(format!("Invalid job ID: {}", job_id_str));
734                continue;
735            }
736        };
737
738        let result = match request.action.as_str() {
739            "retry" => retry_job_action(queue.as_ref(), job_uuid).await,
740            _ => delete_job_action(queue.as_ref(), job_uuid).await,
741        };
742
743        match result {
744            Ok(()) => successful += 1,
745            Err(e) => {
746                failed += 1;
747                errors.push(format!("Job {}: {}", job_id_str, e));
748            }
749        }
750    }
751
752    let response = ApiResponse::success(serde_json::json!({
753        "successful": successful,
754        "failed": failed,
755        "errors": errors,
756        "message": format!("Bulk {} completed: {} successful, {} failed", request.action, successful, failed)
757    }));
758
759    Ok(json_reply(&response))
760}
761
762/// Whether `job` matches the lowercase search `term` (id, queue, payload, error, trace ids).
763fn job_matches_search(job: &hammerwork::Job, term: &str) -> bool {
764    job.id.to_string().contains(term)
765        || job.queue_name.to_lowercase().contains(term)
766        || payload_search_text(&job.payload).contains(term)
767        || [&job.error_message, &job.trace_id, &job.correlation_id]
768            .into_iter()
769            .flatten()
770            .any(|text| text.to_lowercase().contains(term))
771}
772
773/// Handler for searching jobs
774async fn search_jobs_handler<T>(
775    queue: Arc<T>,
776    search_request: JobSearchRequest,
777    pagination: PaginationParams,
778) -> Result<impl Reply, warp::Rejection>
779where
780    T: JobHistory,
781{
782    let search_term = search_request.query.to_lowercase();
783
784    // The stored statuses to read: those requested, or every status when none is given.
785    let statuses: Vec<&'static str> = match search_request.statuses {
786        Some(ref requested) if !requested.is_empty() => {
787            let mut stored: Vec<&'static str> = Vec::new();
788            for name in requested {
789                let name = name.to_lowercase().replace('_', "");
790                for status in stored_statuses(&name).unwrap_or(&[]) {
791                    if !stored.contains(status) {
792                        stored.push(status);
793                    }
794                }
795            }
796            stored
797        }
798        _ => ALL_STATUSES.to_vec(),
799    };
800    // `None` reads across every queue.
801    let target_queues: Vec<Option<&str>> = match search_request.queues {
802        Some(ref names) => names.iter().map(|n| Some(n.as_str())).collect(),
803        None => vec![None],
804    };
805
806    let mut matching_jobs = Vec::new();
807    let mut seen = std::collections::HashSet::new();
808    for queue_name in target_queues {
809        let mut queue_jobs = Vec::new();
810        for status in &statuses {
811            queue_jobs.extend(try_api!(
812                queue
813                    .jobs_with_status(queue_name, status, SEARCH_LIMIT)
814                    .await,
815                "Failed to list jobs"
816            ));
817        }
818
819        for job in &queue_jobs {
820            if !seen.insert(job.id) || !job_matches_search(job, &search_term) {
821                continue;
822            }
823
824            // Apply priority filter
825            if let Some(ref priorities) = search_request.priorities {
826                let job_priority = job.priority.to_string();
827                if !priorities
828                    .iter()
829                    .any(|p| p.eq_ignore_ascii_case(&job_priority))
830                {
831                    continue;
832                }
833            }
834
835            // Apply date filters
836            if let Some(ref created_after) = search_request.created_after
837                && job.created_at < *created_after
838            {
839                continue;
840            }
841
842            if let Some(ref created_before) = search_request.created_before
843                && job.created_at > *created_before
844            {
845                continue;
846            }
847
848            matching_jobs.push(job_info(job));
849        }
850    }
851
852    // Sort by created_at desc by default
853    matching_jobs.sort_by_key(|j| std::cmp::Reverse(j.created_at));
854
855    Ok(json_reply(&ApiResponse::success(paginate(
856        matching_jobs,
857        &pagination,
858    ))))
859}
860
861/// Lowercased text of a job payload used for substring search.
862///
863/// `serde_json::Value` always serializes, so this does not lose data; it is
864/// kept infallible so a (theoretical) failure cannot make a job unsearchable
865/// silently: such a payload falls back to `Value`'s `Display` output.
866fn payload_search_text(payload: &serde_json::Value) -> String {
867    match serde_json::to_string(payload) {
868        Ok(text) => text.to_lowercase(),
869        Err(e) => {
870            tracing::warn!(error = %e, "failed to serialize job payload for search");
871            payload.to_string().to_lowercase()
872        }
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use crate::api::test_support::{body_json, unreachable_queue};
880
881    #[tokio::test]
882    async fn test_list_jobs_returns_500_when_database_is_down() {
883        let response = list_jobs_handler(
884            unreachable_queue(),
885            PaginationParams::default(),
886            serde_json::from_value(serde_json::json!({})).unwrap(),
887            SortParams {
888                sort_by: None,
889                sort_order: None,
890            },
891        )
892        .await
893        .unwrap()
894        .into_response();
895        let (status, body) = body_json(response).await;
896        assert_eq!(status, 500);
897        assert_eq!(body["success"], false);
898        assert!(
899            body["error"]
900                .as_str()
901                .unwrap()
902                .contains("Failed to get queue stats")
903        );
904    }
905
906    /// Calls `list_jobs_handler` with `query` (filter and sort parameters) and returns
907    /// the ids of the listed jobs, in order.
908    async fn listed_ids(
909        queue: Arc<hammerwork::queue::test::TestQueue>,
910        query: serde_json::Value,
911    ) -> Vec<String> {
912        let response = list_jobs_handler(
913            queue,
914            PaginationParams::default(),
915            serde_json::from_value(query.clone()).unwrap(),
916            serde_json::from_value(query).unwrap(),
917        )
918        .await
919        .unwrap()
920        .into_response();
921        let (status, body) = body_json(response).await;
922        assert_eq!(status, 200, "{body}");
923        body["data"]["items"]
924            .as_array()
925            .unwrap()
926            .iter()
927            .map(|item| item["id"].as_str().unwrap().to_string())
928            .collect()
929    }
930
931    /// Two pending jobs in queue `q`, the second created a minute after the first.
932    async fn two_jobs() -> (
933        Arc<hammerwork::queue::test::TestQueue>,
934        String,
935        String,
936        chrono::DateTime<chrono::Utc>,
937    ) {
938        use hammerwork::{Job, queue::test::TestQueue};
939        let queue = Arc::new(TestQueue::new());
940        let first = queue
941            .enqueue(Job::new("q".into(), serde_json::json!({"name": "alpha"})))
942            .await
943            .unwrap();
944        let between = queue.clock().now() + chrono::Duration::seconds(30);
945        queue.clock().advance(chrono::Duration::minutes(1));
946        let second = queue
947            .enqueue(Job::new("q".into(), serde_json::json!({"name": "beta"})))
948            .await
949            .unwrap();
950        (queue, first.to_string(), second.to_string(), between)
951    }
952
953    #[tokio::test]
954    async fn test_list_jobs_applies_created_and_search_filters() {
955        let (queue, first, second, between) = two_jobs().await;
956        let ids = |query| listed_ids(queue.clone(), query);
957
958        assert_eq!(
959            ids(serde_json::json!({"queue": "q", "created_after": between})).await,
960            vec![second.clone()]
961        );
962        assert_eq!(
963            ids(serde_json::json!({"queue": "q", "created_before": between})).await,
964            vec![first.clone()]
965        );
966        assert_eq!(
967            ids(serde_json::json!({"queue": "q", "search": "ALPHA"})).await,
968            vec![first]
969        );
970        assert!(
971            ids(serde_json::json!({"queue": "q", "search": "no such job"}))
972                .await
973                .is_empty()
974        );
975    }
976
977    #[tokio::test]
978    async fn test_list_jobs_sort_order_applies_to_the_default_sort() {
979        let (queue, first, second, _) = two_jobs().await;
980        let ids = |query| listed_ids(queue.clone(), query);
981
982        // Newest first by default
983        assert_eq!(
984            ids(serde_json::json!({"queue": "q"})).await,
985            vec![second.clone(), first.clone()]
986        );
987        // `sort_order` without `sort_by` sorts by creation time in that order
988        assert_eq!(
989            ids(serde_json::json!({"queue": "q", "sort_order": "asc"})).await,
990            vec![first.clone(), second.clone()]
991        );
992        // An unknown field falls back to creation time, in the requested order
993        assert_eq!(
994            ids(serde_json::json!({"queue": "q", "sort_by": "attempts", "sort_order": "ASC"}))
995                .await,
996            vec![first.clone(), second.clone()]
997        );
998        assert_eq!(
999            ids(serde_json::json!({"queue": "q", "sort_by": "created_at", "sort_order": "desc"}))
1000                .await,
1001            vec![second, first]
1002        );
1003    }
1004
1005    /// #103: the status filter reaches jobs in every status, not just the pending jobs
1006    /// ready to run and the dead ones.
1007    #[tokio::test]
1008    async fn test_list_jobs_filters_by_any_status() {
1009        use hammerwork::{Job, queue::test::TestQueue};
1010        let queue = Arc::new(TestQueue::new());
1011        let mk = |name: &str| Job::new("q".into(), serde_json::json!({ "name": name }));
1012        let ready = queue.enqueue(mk("ready")).await.unwrap().to_string();
1013        let later = queue
1014            .enqueue(Job::with_delay(
1015                "q".into(),
1016                serde_json::json!({"name": "later"}),
1017                chrono::Duration::hours(1),
1018            ))
1019            .await
1020            .unwrap()
1021            .to_string();
1022        let done = queue.enqueue(mk("done")).await.unwrap();
1023        let running = queue.enqueue(mk("running")).await.unwrap();
1024        let timed_out = queue.enqueue(mk("timed_out")).await.unwrap();
1025        let dead = queue.enqueue(mk("dead")).await.unwrap();
1026        let failed = queue.enqueue(mk("failed")).await.unwrap();
1027        // Claim the jobs that are due so each can end in the wanted status.
1028        let mut claimed = Vec::new();
1029        while let Some(job) = queue.dequeue("q").await.unwrap() {
1030            claimed.push(job.id);
1031        }
1032        for id in [done, running, timed_out, dead, failed] {
1033            assert!(claimed.contains(&id), "{id} was claimed");
1034        }
1035        // `ready` was claimed too; put it back to Pending.
1036        let ready_id: hammerwork::JobId = ready.parse().unwrap();
1037        queue
1038            .retry_job(ready_id, queue.clock().now())
1039            .await
1040            .unwrap();
1041        queue.complete_job(done).await.unwrap();
1042        queue.mark_job_timed_out(timed_out, "slow").await.unwrap();
1043        queue.mark_job_dead(dead, "boom").await.unwrap();
1044        queue.fail_job(failed, "bad").await.unwrap();
1045
1046        let ids =
1047            |status: &'static str| listed_ids(queue.clone(), serde_json::json!({"status": status}));
1048        assert_eq!(ids("running").await, vec![running.to_string()]);
1049        assert_eq!(ids("completed").await, vec![done.to_string()]);
1050        assert_eq!(ids("dead").await, vec![dead.to_string()]);
1051        assert_eq!(ids("timedout").await, vec![timed_out.to_string()]);
1052        assert_eq!(ids("timed_out").await, vec![timed_out.to_string()]);
1053        let mut every_failure = ids("failed").await;
1054        every_failure.sort();
1055        let mut expected = vec![dead.to_string(), timed_out.to_string(), failed.to_string()];
1056        expected.sort();
1057        assert_eq!(every_failure, expected);
1058        // Pending includes the job scheduled for later, as well as the ready one.
1059        let mut pending = ids("pending").await;
1060        pending.sort();
1061        let mut expected = vec![ready, later];
1062        expected.sort();
1063        assert_eq!(pending, expected);
1064        assert!(ids("no_such_status").await.is_empty());
1065    }
1066
1067    /// #114: search reaches jobs in every status, including failed and future ones.
1068    #[tokio::test]
1069    async fn test_search_reaches_every_status() {
1070        use hammerwork::{Job, queue::test::TestQueue};
1071        let queue = Arc::new(TestQueue::new());
1072        let now_job = queue
1073            .enqueue(Job::new("q".into(), serde_json::json!({"tag": "needle"})))
1074            .await
1075            .unwrap();
1076        let later = queue
1077            .enqueue(Job::with_delay(
1078                "q".into(),
1079                serde_json::json!({"tag": "needle"}),
1080                chrono::Duration::hours(1),
1081            ))
1082            .await
1083            .unwrap();
1084        let claimed = queue.dequeue("q").await.unwrap().unwrap();
1085        assert_eq!(claimed.id, now_job);
1086        queue.fail_job(now_job, "bad needle").await.unwrap();
1087
1088        let search = |body: serde_json::Value| {
1089            let queue = queue.clone();
1090            async move {
1091                let response = search_jobs_handler(
1092                    queue,
1093                    serde_json::from_value(body).unwrap(),
1094                    PaginationParams::default(),
1095                )
1096                .await
1097                .unwrap()
1098                .into_response();
1099                let (status, body) = body_json(response).await;
1100                assert_eq!(status, 200, "{body}");
1101                let mut ids: Vec<String> = body["data"]["items"]
1102                    .as_array()
1103                    .unwrap()
1104                    .iter()
1105                    .map(|i| i["id"].as_str().unwrap().to_string())
1106                    .collect();
1107                ids.sort();
1108                ids
1109            }
1110        };
1111        let mut both = vec![now_job.to_string(), later.to_string()];
1112        both.sort();
1113        assert_eq!(search(serde_json::json!({"query": "needle"})).await, both);
1114        assert_eq!(
1115            search(serde_json::json!({"query": "needle", "statuses": ["failed"]})).await,
1116            vec![now_job.to_string()]
1117        );
1118        assert_eq!(
1119            search(
1120                serde_json::json!({"query": "needle", "statuses": ["Pending"], "queues": ["q"]})
1121            )
1122            .await,
1123            vec![later.to_string()]
1124        );
1125    }
1126
1127    #[tokio::test]
1128    async fn test_invalid_job_id_is_400() {
1129        let response = get_job_handler("not-a-uuid".to_string(), unreachable_queue())
1130            .await
1131            .unwrap()
1132            .into_response();
1133        let (status, _) = body_json(response).await;
1134        assert_eq!(status, 400);
1135    }
1136
1137    #[test]
1138    fn test_payload_search_text_lowercases_json() {
1139        let payload = serde_json::json!({"To": "User@Example.com"});
1140        let text = payload_search_text(&payload);
1141        assert!(text.contains("user@example.com"));
1142        assert!(text.contains("\"to\""));
1143    }
1144
1145    #[test]
1146    fn test_create_job_request_deserialization() {
1147        let json = r#"{
1148            "queue_name": "email",
1149            "payload": {"to": "user@example.com", "subject": "Hello"},
1150            "priority": "high",
1151            "max_attempts": 5
1152        }"#;
1153
1154        let request: CreateJobRequest = serde_json::from_str(json).unwrap();
1155        assert_eq!(request.queue_name, "email");
1156        assert_eq!(request.priority, Some("high".to_string()));
1157        assert_eq!(request.max_attempts, Some(5));
1158    }
1159
1160    #[test]
1161    fn test_job_action_request_deserialization() {
1162        let json = r#"{"action": "retry", "reason": "Network error resolved"}"#;
1163        let request: JobActionRequest = serde_json::from_str(json).unwrap();
1164        assert_eq!(request.action, "retry");
1165        assert_eq!(request.reason, Some("Network error resolved".to_string()));
1166    }
1167
1168    #[test]
1169    fn test_bulk_job_action_request() {
1170        let json = r#"{
1171            "job_ids": ["job-1", "job-2", "job-3"],
1172            "action": "delete",
1173            "reason": "Cleanup old jobs"
1174        }"#;
1175
1176        let request: BulkJobActionRequest = serde_json::from_str(json).unwrap();
1177        assert_eq!(request.job_ids.len(), 3);
1178        assert_eq!(request.action, "delete");
1179    }
1180}