use chrono::{Duration, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug)] struct AutoApplyConfig { litellm_base_url: String, litellm_api_key: String, litellm_model: String, max_applications_per_run: usize, } impl AutoApplyConfig { fn from_env() -> Self { Self { litellm_base_url: std::env::var("LITELLM_BASE_URL") .unwrap_or_else(|_| "https://llm.nxtgauge.com/v1".to_string()), litellm_api_key: std::env::var("LITELLM_API_KEY").unwrap_or_default(), litellm_model: std::env::var("LITELLM_MODEL") .unwrap_or_else(|_| "askash-main".to_string()), max_applications_per_run: std::env::var("AUTO_APPLY_MAX_PER_RUN") .unwrap_or_else(|_| "5".to_string()) .parse() .unwrap_or(5), } } } #[derive(Debug, Serialize)] struct ChatMessage { role: String, content: String, } #[derive(Debug, Serialize)] struct ChatCompletionRequest { model: String, messages: Vec, temperature: f32, max_tokens: i32, } #[derive(Debug, Deserialize)] struct ChatCompletionResponse { choices: Vec, } #[derive(Debug, Deserialize)] struct Choice { message: Message, } #[derive(Debug, Deserialize)] struct Message { content: String, } async fn generate_cover_letter( client: &Client, config: &AutoApplyConfig, seeker_name: &str, experience: i32, skills: &[String], summary: Option<&str>, job_title: &str, job_desc: &str, ) -> Result> { let url = format!("{}/chat/completions", config.litellm_base_url.trim_end_matches('/')); let desc_excerpt = &job_desc[..job_desc.len().min(500)]; let prompt = format!( "Write a brief, professional cover letter (max 200 words).\n\n\ IMPORTANT: Do NOT include phone number, email, or any contact information.\n\ Only use the information provided below.\n\n\ CANDIDATE: Name: {seeker_name}, Experience: {experience} years, \ Skills: {skills}, Summary: {summary}\n\ JOB: Title: {job_title}, Description: {desc_excerpt}\n\n\ Cover Letter:", skills = skills.join(", "), summary = summary.unwrap_or(""), ); let payload = ChatCompletionRequest { model: config.litellm_model.clone(), messages: vec![ ChatMessage { role: "system".to_string(), content: "You are a professional cover letter writer.".to_string(), }, ChatMessage { role: "user".to_string(), content: prompt, }, ], temperature: 0.2, max_tokens: 500, }; let res = client .post(&url) .header("Authorization", format!("Bearer {}", config.litellm_api_key)) .header("Content-Type", "application/json") .json(&payload) .send() .await?; if !res.status().is_success() { return Ok("I am excited to apply for this position.".to_string()); } let body: ChatCompletionResponse = res.json().await?; Ok(body .choices .into_iter() .next() .map(|c| c.message.content.trim().to_string()) .unwrap_or_else(|| "I am excited to apply for this position.".to_string())) } #[derive(Debug, sqlx::FromRow)] struct EligibleSeeker { user_id: Uuid, profile_id: Uuid, full_name: String, experience_years: i32, custom_data: Value, daily_limit: i32, used_today: i32, available_credits: i32, } #[derive(Debug, sqlx::FromRow)] struct MatchingJob { id: Uuid, title: String, description: String, } const AUTO_APPLY_CREDIT_COST: i32 = 5; pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box> { let config = AutoApplyConfig::from_env(); if config.litellm_api_key.is_empty() { tracing::warn!("Auto-apply skipped: LITELLM_API_KEY not configured"); return Ok(()); } tracing::info!("Starting auto-apply job..."); let client = Client::new(); let cutoff_time = Utc::now() - Duration::hours(24); // Fetch job seekers who have auto-apply enabled and sufficient AI credits let seekers: Vec = sqlx::query_as( r#" SELECT u.id AS user_id, js.id AS profile_id, COALESCE(CONCAT(u.first_name, ' ', u.last_name), 'Candidate') AS full_name, COALESCE(js.experience_years, 0) AS experience_years, COALESCE(js.custom_data, '{}'::jsonb) AS custom_data, COALESCE(uas.daily_action_limit, 3) AS daily_limit, COALESCE(uas.daily_actions_used, 0) AS used_today, COALESCE(( monthly_credits_total - monthly_credits_used + purchased_credits_total - purchased_credits_used ), 0) AS available_credits FROM users u INNER JOIN job_seeker_profiles js ON js.user_id = u.id INNER JOIN ai_auto_apply_settings aas ON aas.user_id = u.id INNER JOIN user_ai_subscriptions uas ON uas.user_id = u.id WHERE u.status = 'ACTIVE' AND aas.is_enabled = true AND uas.status = 'active' AND NOW() >= uas.current_period_start AND NOW() < uas.current_period_end "#, ) .fetch_all(pool) .await?; if seekers.is_empty() { tracing::info!("No eligible job seekers found for auto-apply"); return Ok(()); } tracing::info!("{} job seekers eligible for auto-apply", seekers.len()); let mut total_applications = 0; for seeker in &seekers { let remaining_today = seeker.daily_limit - seeker.used_today; if remaining_today <= 0 { tracing::debug!("User {} hit daily auto-apply limit", seeker.user_id); continue; } if seeker.available_credits < AUTO_APPLY_CREDIT_COST { tracing::debug!("User {} has insufficient AI credits", seeker.user_id); continue; } // Extract skills from custom_data -> job_seeker_portfolio -> skills let portfolio = seeker .custom_data .get("job_seeker_portfolio") .cloned() .unwrap_or(Value::Null); let skills: Vec = portfolio .get("skills") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|s| s.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(); if skills.is_empty() { tracing::debug!("User {} has no skills listed, skipping", seeker.user_id); continue; } let summary = portfolio .get("summary") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let max_to_apply = remaining_today .min(config.max_applications_per_run as i32) .min(seeker.available_credits / AUTO_APPLY_CREDIT_COST); // Find LIVE jobs posted in last 24h matching seeker skills, not already applied to let matching_jobs: Vec = sqlx::query_as( r#" SELECT j.id, j.title, j.description FROM jobs j INNER JOIN company_profiles c ON c.id = j.company_id WHERE j.status = 'LIVE' AND j.created_at > $1 AND c.status = 'ACTIVE' AND NOT EXISTS ( SELECT 1 FROM job_applications ja WHERE ja.job_id = j.id AND ja.applicant_user_id = $2 ) AND j.skills && $3::text[] ORDER BY j.created_at DESC LIMIT $4 "#, ) .bind(cutoff_time) .bind(seeker.user_id) .bind(&skills) .bind(max_to_apply) .fetch_all(pool) .await?; if matching_jobs.is_empty() { continue; } tracing::info!( "User {} matched {} new jobs", seeker.user_id, matching_jobs.len() ); let mut credits_remaining = seeker.available_credits; for job in &matching_jobs { if credits_remaining < AUTO_APPLY_CREDIT_COST { break; } let cover_letter = match generate_cover_letter( &client, &config, &seeker.full_name, seeker.experience_years, &skills, summary.as_deref(), &job.title, &job.description, ) .await { Ok(cl) => cl, Err(e) => { tracing::warn!( "Cover letter generation failed for user {} / job {}: {}", seeker.user_id, job.id, e ); "I am excited to apply for this position.".to_string() } }; // Insert application; ON CONFLICT DO NOTHING guards against race conditions let applied = match sqlx::query( r#" INSERT INTO job_applications (job_id, applicant_user_id, cover_note, applied_via_ai) VALUES ($1, $2, $3, true) ON CONFLICT DO NOTHING "#, ) .bind(job.id) .bind(seeker.user_id) .bind(&cover_letter) .execute(pool) .await { Ok(r) => r.rows_affected() > 0, Err(e) => { tracing::error!( "Failed to insert application for user {} / job {}: {}", seeker.user_id, job.id, e ); false } }; if !applied { continue; } total_applications += 1; credits_remaining -= AUTO_APPLY_CREDIT_COST; // Log to ai_auto_apply_logs sqlx::query( r#" INSERT INTO ai_auto_apply_logs (user_id, job_id, match_score, status, credits_charged, generated_cover_letter, applied_at) VALUES ($1, $2, $3, 'applied', $4, $5, NOW()) "#, ) .bind(seeker.user_id) .bind(job.id) .bind(75i32) // placeholder score; can be replaced with real ranking later .bind(AUTO_APPLY_CREDIT_COST) .bind(&cover_letter) .execute(pool) .await .ok(); // Deduct credits (monthly pool first, then purchased) sqlx::query( r#" UPDATE user_ai_subscriptions SET monthly_credits_used = LEAST( monthly_credits_used + $1, monthly_credits_total ), purchased_credits_used = purchased_credits_used + GREATEST( 0, $1 - (monthly_credits_total - monthly_credits_used) ), daily_actions_used = daily_actions_used + 1, updated_at = NOW() WHERE user_id = $2 AND status = 'active' AND NOW() >= current_period_start AND NOW() < current_period_end "#, ) .bind(AUTO_APPLY_CREDIT_COST) .bind(seeker.user_id) .execute(pool) .await .ok(); // Increment daily_actions_used on subscription sqlx::query( r#" UPDATE user_ai_subscriptions SET daily_actions_used = daily_actions_used + 1, updated_at = NOW() WHERE user_id = $1 AND status = 'active' AND NOW() >= current_period_start AND NOW() < current_period_end "#, ) .bind(seeker.user_id) .execute(pool) .await .ok(); tracing::info!( "Auto-applied user {} to job '{}' ({})", seeker.user_id, job.title, job.id ); } } tracing::info!( "Auto-apply run complete. {} applications submitted.", total_applications ); Ok(()) }