nxtgauge-backend-rust/apps/cron/src/tasks/auto_apply.rs
Ashwin Kumar Sivakumar 4f2683d085 chore: remove legacy AI quota system
Delete legacy code that used old company_ai_usage/job_seeker_ai_usage tables:
- Remove has_active_ai_pack() - old AI_PACK pricing package check
- Remove check_and_increment_usage() - legacy daily quota tracking
- Remove BASE_AI_LIMIT, get_ai_limit_for_package constants/functions
- Remove legacy queries from ai_auto_apply() and ai_usage_status()
- Update auto_apply.rs to use user_ai_subscriptions.daily_actions_used
  instead of job_seeker_ai_usage table
- Inline apply_scheduled_downgrades() and expire_trials() in cron tasks
  to remove dependency on users crate internal modules

The new system uses user_ai_subscriptions with:
- daily_actions_used / daily_credits_used counters
- monthly_credits_total / monthly_credits_used
- purchased_credits_total / purchased_credits_used

All AI billing now flows through the wallet/ledger system with
LiteLLM integration (Tasks 1-10).
2026-07-06 02:14:23 +05:30

414 lines
13 KiB
Rust

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<ChatMessage>,
temperature: f32,
max_tokens: i32,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[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<String, Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<EligibleSeeker> = 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<String> = 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<MatchingJob> = 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(())
}