use crate::models::credential::TuleapCredentials; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::WatchedTracker; use crate::services::tuleap_client::TuleapClient; use crate::services::{crypto, filter_engine, notifier}; use rusqlite::Connection; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; use tokio::time::{interval, Duration}; pub fn start( db: Arc>, encryption_key: [u8; 32], http_client: reqwest::Client, app_handle: AppHandle, ) { tauri::async_runtime::spawn(async move { let mut tick = interval(Duration::from_secs(60)); loop { tick.tick().await; poll_all_trackers(&db, &encryption_key, &http_client, &app_handle).await; } }); } async fn poll_all_trackers( db: &Arc>, encryption_key: &[u8; 32], http_client: &reqwest::Client, app_handle: &AppHandle, ) { // 1. Read all enabled trackers and credentials from DB let (trackers, client) = { let conn = match db.lock() { Ok(c) => c, Err(e) => { eprintln!("poller: failed to lock db: {}", e); return; } }; let trackers = match WatchedTracker::list_all_enabled(&conn) { Ok(t) => t, Err(e) => { eprintln!("poller: failed to list trackers: {}", e); return; } }; // 2. Read credentials; bail silently if none let creds = match TuleapCredentials::get(&conn) { Ok(Some(c)) => c, Ok(None) => return, Err(e) => { eprintln!("poller: failed to read credentials: {}", e); return; } }; let password = match crypto::decrypt(encryption_key, &creds.password_encrypted) { Ok(p) => p, Err(e) => { eprintln!("poller: failed to decrypt password: {}", e); return; } }; let client = TuleapClient::new(http_client, &creds.tuleap_url, &creds.username, &password); (trackers, client) }; // lock released // 3. For each tracker that should_poll, poll it for tracker in &trackers { if should_poll(tracker) { poll_single_tracker(db, &client, tracker, app_handle).await; } } } fn should_poll(tracker: &WatchedTracker) -> bool { let last_polled_at = match &tracker.last_polled_at { None => return true, // Never polled Some(s) => s, }; let last = match parse_timestamp(last_polled_at) { Some(dt) => dt, None => { eprintln!( "poller: failed to parse last_polled_at '{}': unsupported format", last_polled_at ); return true; // Treat as never polled on parse error } }; let elapsed = chrono::Utc::now().signed_duration_since(last).num_minutes(); elapsed >= tracker.polling_interval as i64 } fn parse_timestamp(value: &str) -> Option> { if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) { return Some(dt.with_timezone(&chrono::Utc)); } if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f") { return Some(chrono::DateTime::::from_naive_utc_and_offset( naive, chrono::Utc, )); } None } async fn poll_single_tracker( db: &Arc>, client: &TuleapClient, tracker: &WatchedTracker, app_handle: &AppHandle, ) { let _ = app_handle.emit( "polling-started", serde_json::json!({ "project_id": &tracker.project_id, "tracker_id": &tracker.id, "tracker_label": &tracker.tracker_label, "source": "scheduled", }), ); // 1. Fetch artifacts let artifacts = match client.get_artifacts(tracker.tracker_id).await { Ok(a) => a, Err(e) => { eprintln!( "poller: failed to fetch artifacts for tracker {}: {}", tracker.id, e ); let _ = app_handle.emit( "polling-error", serde_json::json!({ "project_id": &tracker.project_id, "tracker_id": &tracker.id, "tracker_label": &tracker.tracker_label, "source": "scheduled", "error": e, }), ); return; } }; // 2. Apply filters let filtered = filter_engine::apply_filters(&artifacts, &tracker.filters); // 3. Insert new tickets and update last_polled_at let new_tickets = { let conn = match db.lock() { Ok(c) => c, Err(e) => { eprintln!("poller: failed to lock db for insert: {}", e); return; } }; let mut inserted = Vec::new(); for artifact in &filtered { let artifact_id = artifact.get("id").and_then(|v| v.as_i64()).unwrap_or(0) as i32; let artifact_title = artifact .get("title") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let artifact_data = serde_json::to_string(artifact).unwrap_or_else(|_| "{}".to_string()); match ProcessedTicket::insert_if_new( &conn, &tracker.id, artifact_id, &artifact_title, &artifact_data, ) { Ok(Some(ticket)) => inserted.push(ticket), Ok(None) => {} Err(e) => { eprintln!( "poller: failed to insert ticket (artifact {}): {}", artifact_id, e ); } } } // 4. Update last_polled_at if let Err(e) = WatchedTracker::update_last_polled(&conn, &tracker.id) { eprintln!( "poller: failed to update last_polled_at for tracker {}: {}", tracker.id, e ); } inserted }; // lock released // 5. Emit event if new tickets found if !new_tickets.is_empty() { if let Err(e) = app_handle.emit( "new-tickets-detected", serde_json::json!({ "project_id": &tracker.project_id, "tracker_id": &tracker.id, "tracker_label": &tracker.tracker_label, "count": new_tickets.len(), }), ) { eprintln!("poller: failed to emit event: {}", e); } for ticket in &new_tickets { notifier::notify_new_ticket( db, app_handle, &tracker.project_id, &ticket.id, ticket.artifact_id, &ticket.artifact_title, ); } } let _ = app_handle.emit( "polling-finished", serde_json::json!({ "project_id": &tracker.project_id, "tracker_id": &tracker.id, "tracker_label": &tracker.tracker_label, "source": "scheduled", "new_tickets_count": new_tickets.len(), }), ); } #[cfg(test)] mod tests { use super::parse_timestamp; #[test] fn parse_timestamp_supports_rfc3339() { let parsed = parse_timestamp("2026-04-16T10:15:30.123Z"); assert!(parsed.is_some()); } #[test] fn parse_timestamp_supports_legacy_sqlite_datetime() { let parsed = parse_timestamp("2026-04-16 10:15:30"); assert!(parsed.is_some()); } #[test] fn parse_timestamp_rejects_invalid_values() { let parsed = parse_timestamp("not-a-date"); assert!(parsed.is_none()); } }