From aa71ccdf368ed1181b33cf87e468dff3cfffaef6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 02:54:42 +0200 Subject: [PATCH] Add AI endpoints and gateway route fix - Fix gateway: add /api/ai route to users_url - Add AI job field generation endpoints (generate-job-field, generate-cover-letter, tailor-resume, auto-apply) - Add AI usage tracking and rate limiting - Add professional auto-respond-to-lead endpoint (30 tracecoins) - Add DB migrations for AI usage tracking tables - Update leads service with AI auto-respond functionality --- Cargo.lock | 1 + apps/gateway/src/main.rs | 4 + apps/leads/Cargo.toml | 1 + apps/leads/src/lead_requests.rs | 206 ++++ apps/leads/src/main.rs | 13 +- apps/users/src/handlers/ai.rs | 1064 ++++++++++++++++- .../20260425000000_ai_usage.down.sql | 9 + .../migrations/20260425000000_ai_usage.up.sql | 38 + 8 files changed, 1313 insertions(+), 23 deletions(-) create mode 100644 crates/db/migrations/20260425000000_ai_usage.down.sql create mode 100644 crates/db/migrations/20260425000000_ai_usage.up.sql diff --git a/Cargo.lock b/Cargo.lock index eb267e0..6d5c0e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2144,6 +2144,7 @@ dependencies = [ "anyhow", "axum", "chrono", + "reqwest", "serde", "serde_json", "sqlx", diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index c8ac0d3..ae02dc9 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -198,6 +198,10 @@ impl Services { else if path.starts_with("/api/credits") { Some(self.payments_url.clone()) } + // ── AI Chat (routes to users service, which calls Ollama directly) ─── + else if path.starts_with("/api/ai") { + Some(self.users_url.clone()) + } // Admin runtime config management defaults to users service else if path.starts_with("/api/admin/runtime-configs") { Some(self.users_url.clone()) diff --git a/apps/leads/Cargo.toml b/apps/leads/Cargo.toml index 144c352..d17b2c0 100644 --- a/apps/leads/Cargo.toml +++ b/apps/leads/Cargo.toml @@ -15,6 +15,7 @@ anyhow = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tower-http = { version = "0.6", features = ["cors", "trace"] } +reqwest = { workspace = true } [[bin]] name = "leads" diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs index 0c91de7..168bcca 100644 --- a/apps/leads/src/lead_requests.rs +++ b/apps/leads/src/lead_requests.rs @@ -24,6 +24,13 @@ pub struct SendLeadRequestPayload { pub message: Option, } +#[derive(Debug, Deserialize)] +pub struct SendLeadRequestAiPayload { + pub lead_id: Uuid, + pub user_id: Uuid, + pub profession_key: String, +} + #[derive(Debug, FromRow)] pub struct LeadRequestRow { pub id: Uuid, @@ -64,6 +71,7 @@ pub fn router() -> Router> { Router::new() .route("/", get(list_lead_requests)) .route("/send", post(send_lead_request)) + .route("/send-ai", post(send_lead_request_ai)) .route("/{id}/accept", post(accept_lead_request)) .route("/{id}/reject", post(reject_lead_request)) .route("/my-requests", get(my_requests)) @@ -272,6 +280,204 @@ async fn send_lead_request( } } +async fn send_lead_request_ai( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + let user_id = payload.user_id; + + let lead = match sqlx::query_as::<_, (Uuid, String, String, String, String, Option, Option)>( + "SELECT id, title, description, location, profession_key, budget_min, budget_max FROM leads WHERE id = $1" + ) + .bind(payload.lead_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(l)) => l, + Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + if lead.4 != payload.profession_key { + return (StatusCode::BAD_REQUEST, "Lead profession does not match your profile").into_response(); + } + + let user_role_profile_id: Uuid = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM user_role_profiles WHERE user_id = $1 LIMIT 1" + ) + .bind(user_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(id)) => id, + Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let existing = match sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM lead_requests WHERE lead_id = $1 AND user_role_profile_id = $2 AND status IN ('PENDING', 'ACCEPTED')" + ) + .bind(payload.lead_id) + .bind(user_role_profile_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + if existing { + return (StatusCode::CONFLICT, "You have already sent a request for this lead").into_response(); + } + + let wallet = match sqlx::query_as::<_, (Uuid, i64)>( + "SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1" + ) + .bind(user_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(w)) => w, + Ok(None) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let tracecoins_cost = 30; + if wallet.1 < tracecoins_cost as i64 { + return (StatusCode::PAYMENT_REQUIRED, format!("Insufficient balance. You need {} Tracecoins.", tracecoins_cost)).into_response(); + } + + let budget = match (lead.5, lead.6) { + (Some(min), Some(max)) => format!("Budget: ₹{}-₹{}", min, max), + (Some(min), None) => format!("Budget: ₹{} onwards", min), + _ => "Budget: Not specified".to_string(), + }; + + let prompt = format!( + "You are a professional {} responding to a potential client's lead/request.\n\n\ + IMPORTANT: Do NOT include phone number, email, or any contact information in your response. \ + Clients pay to view contact details through the platform.\n\n\ + LEAD DETAILS:\n\ + Title: {}\n\ + Description: {}\n\ + Location: {}\n\ + {}\n\n\ + Write a professional, friendly message (max 150 words) expressing your interest and qualifications. \ + Mention relevant experience and ask any clarifying questions. Be concise and compelling.", + payload.profession_key.replace("_", " "), + lead.1, + lead.2, + lead.3, + budget + ); + + let ai_message = match generate_ai_message(&state.http_client, &state.ollama_base_url, &state.ollama_model, &prompt).await { + Ok(msg) => msg, + Err(e) => { + tracing::error!("AI message generation failed: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "AI generation failed").into_response(); + } + }; + + let expires_at = chrono::Utc::now() + chrono::Duration::hours(24); + + let result = sqlx::query_as::<_, LeadRequestRow>( + r#" + INSERT INTO lead_requests (lead_id, user_role_profile_id, customer_user_id, status, tracecoins_reserved, message, expires_at) + VALUES ($1, $2, $3, 'PENDING', $4, $5, $6) + RETURNING * + "# + ) + .bind(payload.lead_id) + .bind(user_role_profile_id) + .bind(user_id) + .bind(tracecoins_cost) + .bind(&ai_message) + .bind(expires_at) + .fetch_one(&state.pool) + .await; + + match result { + Ok(req) => { + let _ = sqlx::query( + r#" + UPDATE tracecoin_wallets SET + balance = balance - $1, + reserved = COALESCE(reserved, 0) + $1, + updated_at = NOW() + WHERE user_id = $2 + "# + ) + .bind(tracecoins_cost as i64) + .bind(user_id) + .execute(&state.pool) + .await; + + let _ = sqlx::query( + r#" + INSERT INTO notifications (user_id, title, body, notification_type, reference_id) + VALUES ($1, $2, $3, $4, $5) + "# + ) + .bind(user_id) + .bind("AI Auto-Respond Sent") + .bind("Your AI-assisted response has been sent to the customer.") + .bind("LEAD_REQUEST") + .bind(req.id) + .execute(&state.pool) + .await; + + let response = lead_request_to_response(req); + (StatusCode::CREATED, Json(response)).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +async fn generate_ai_message( + client: &reqwest::Client, + base_url: &str, + model: &str, + prompt: &str, +) -> Result { + #[derive(Serialize)] + struct GenerateRequest<'a> { + model: &'a str, + prompt: String, + stream: bool, + } + + #[derive(Deserialize)] + struct GenerateResponse { + response: String, + } + + let url = format!("{}/api/generate", base_url.trim_end_matches('/')); + let req = GenerateRequest { + model, + prompt: prompt.to_string(), + stream: false, + }; + + let response = client + .post(&url) + .json(&req) + .send() + .await + .map_err(|e| format!("ollama request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("ollama returned status: {}", response.status())); + } + + let result: GenerateResponse = response + .json() + .await + .map_err(|e| format!("failed to parse ollama response: {}", e))?; + + Ok(result.response.trim().to_string()) +} + async fn accept_lead_request( State(state): State>, Path(id): Path, diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 0d24782..940bc5e 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -4,6 +4,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::net::SocketAddr; @@ -16,6 +17,9 @@ pub mod lead_requests; #[derive(Clone)] pub struct AppState { pub pool: PgPool, + pub http_client: reqwest::Client, + pub ollama_base_url: String, + pub ollama_model: String, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] @@ -110,7 +114,14 @@ async fn main() { tracing::info!("Connected to database"); - let state = Arc::new(AppState { pool }); + let state = Arc::new(AppState { + pool, + http_client: Client::new(), + ollama_base_url: std::env::var("OLLAMA_BASE_URL") + .unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()), + ollama_model: std::env::var("OLLAMA_CHAT_MODEL") + .unwrap_or_else(|_| "gemma3:270m".to_string()), + }); let cors = CorsLayer::new() .allow_origin(Any) diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 1b06f71..5b3f4e8 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -6,15 +6,32 @@ use axum::{ routing::{get, post}, Json, Router, }; +use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use uuid::Uuid; +#[derive(sqlx::FromRow)] +struct KbArticleRow { + id: Uuid, + title: String, + slug: String, + summary: Option, + category_name: String, +} + pub fn ai_router() -> Router { Router::new() .route("/chat/message", post(ai_chat_message)) .route("/tickets/create", post(ai_create_ticket)) .route("/tickets/{id}", get(ai_get_ticket)) .route("/forms/extract", post(ai_extract_form)) + .route("/generate-job-field", post(ai_generate_job_field)) + .route("/generate-cover-letter", post(ai_generate_cover_letter)) + .route("/tailor-resume", post(ai_tailor_resume)) + .route("/auto-apply", post(ai_auto_apply)) + .route("/auto-respond-to-lead", post(ai_auto_respond_to_lead)) + .route("/usage", get(ai_usage_status)) } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -77,7 +94,7 @@ async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result (String, f32) { let prompt = format!( - "Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, general. \ + "Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, job_description_generation, general. \ Return ONLY the intent name, nothing else.\n\nMessage: {}", message ); @@ -90,6 +107,7 @@ async fn classify_intent(message: &str, ollama_base: &str, model: &str) -> (Stri "ticket_creation" => "ticket_creation", "form_filling" => "form_filling", "help_search" => "help_search", + "job_description_generation" => "job_description_generation", _ => "general", }; (intent.to_string(), confidence) @@ -138,33 +156,107 @@ async fn ai_chat_message( let (intent, confidence) = classify_intent(&body.message, &ollama_base, &model).await; - let system_prompt = match intent.as_str() { + let response_text = match intent.as_str() { + "help_search" => { + let q = body.message.to_lowercase(); + let rows = sqlx::query_as::<_, KbArticleRow>( + r#" + SELECT a.id, a.title, a.slug, a.summary, c.name AS category_name + FROM kb_articles a + JOIN kb_categories c ON c.id = a.category_id + WHERE a.status = 'PUBLISHED' + AND c.is_active = true + AND (LOWER(a.title) LIKE '%' || $1 || '%' + OR LOWER(COALESCE(a.summary, '')) LIKE '%' || $1 || '%') + ORDER BY a.updated_at DESC + LIMIT 5 + "#, + ) + .bind(&q) + .fetch_all(&state.pool) + .await; + + match rows { + Ok(articles) if !articles.is_empty() => { + let links: Vec = articles + .iter() + .map(|a| { + format!( + "- **{}** ({})\n {}\n /help-center/article/{}", + a.title, + a.category_name, + a.summary.as_deref().unwrap_or(""), + a.slug + ) + }) + .collect(); + format!( + "I found {} help article(s) for you:\n\n{}\n\nIs any of these what you were looking for?", + articles.len(), + links.join("\n\n") + ) + } + _ => { + "I couldn't find any help articles matching your question. \ + Try rephrasing or contact support if you need further assistance." + .to_string() + } + } + } + "job_description_generation" => { + let jd_prompt = format!( + "Generate a professional job description with the following sections: \ + **Job Title**, **Summary**, **Key Responsibilities**, **Required Skills & Qualifications**, \ + **Preferred Qualifications**, **What We Offer**. \ + Format each section clearly with bullet points where appropriate.\n\n\ + User's request: {}\n\n\ + Job Description:", + body.message + ); + match call_ollama(&state, &model, &jd_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama JD generation error: {}", e); + "I'm having trouble generating a job description right now. Please try again.".to_string() + } + } + } "ticket_creation" => { - "You are a support ticket assistant. Help users create clear, actionable support tickets. \ + let system_prompt = "You are a support ticket assistant. Help users create clear, actionable support tickets. \ Ask for: subject, description of issue, category, priority if not provided. \ - Summarize the ticket in a structured way." + Summarize the ticket in a structured way."; + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + } } "form_filling" => { - "You are a form filling assistant. Help users fill out forms by extracting relevant information \ - from their message. Extract key:value pairs when possible." - } - "help_search" => { - "You are a help center assistant. Help users find relevant help articles based on their query. \ - Ask clarifying questions to narrow down the search." + let system_prompt = "You are a form filling assistant. Help users fill out forms by extracting relevant information \ + from their message. Extract key:value pairs when possible."; + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + } } _ => { - "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \ - If the user needs support, guide them to create a ticket." - } - }; - - let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); - - let response_text = match call_ollama(&state, &model, &full_prompt).await { - Ok(r) => r, - Err(e) => { - tracing::error!("Ollama error: {}", e); - "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + let system_prompt = "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \ + If the user needs support, guide them to create a ticket."; + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + } } }; @@ -349,4 +441,932 @@ struct TicketRow { assigned_to: Option, created_at: chrono::DateTime, updated_at: chrono::DateTime, +} + +// ── AI Pack & Rate Limit Helpers ──────────────────────────────────────────────── + +const BASE_AI_LIMIT: i32 = 5; + +fn get_ai_limit_for_package(features: &serde_json::Value) -> i32 { + features + .get("ai_generations_per_day") + .and_then(|v| v.as_i64()) + .map(|v| v as i32) + .unwrap_or(BASE_AI_LIMIT) +} + +async fn has_active_ai_pack( + pool: &sqlx::PgPool, + user_role_profile_id: Uuid, + role_key: &str, +) -> (bool, i32) { + let now = chrono::Utc::now(); + let result = sqlx::query_as::<_, (Option,)>( + r#" + SELECT pp.features + FROM pricing_packages pp + JOIN payments p ON p.package_id = pp.id + WHERE pp.package_type = 'AI_PACK' + AND pp.is_active = true + AND p.user_role_profile_id = $1 + AND $2 = ANY(pp.applicable_roles) + AND p.tracecoins_credited > 0 + AND (pp.valid_from IS NULL OR pp.valid_from <= $3) + AND (pp.valid_until IS NULL OR pp.valid_until >= $3) + ORDER BY p.created_at DESC + LIMIT 1 + "#, + ) + .bind(user_role_profile_id) + .bind(role_key) + .bind(now) + .fetch_optional(pool) + .await; + + match result { + Ok(Some((Some(features),))) => { + let limit = get_ai_limit_for_package(&features); + (true, limit) + } + _ => (false, BASE_AI_LIMIT), + } +} + +async fn check_and_increment_usage( + pool: &sqlx::PgPool, + profile_id: Uuid, + is_company: bool, + daily_limit: i32, +) -> Result<(i32, i32), String> { + let today = chrono::Utc::now().date_naive(); + let table = if is_company { "company_ai_usage" } else { "job_seeker_ai_usage" }; + let id_col = if is_company { "company_id" } else { "job_seeker_id" }; + + let current: Option = sqlx::query_scalar(&format!( + "SELECT generations_used FROM {} WHERE {} = $1 AND usage_date = $2", + table, id_col + )) + .bind(profile_id) + .bind(today) + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + + let used = current.unwrap_or(0); + if used >= daily_limit { + return Err("Daily AI generation limit reached".to_string()); + } + + sqlx::query(&format!( + r#" + INSERT INTO {} ({} , usage_date, generations_used) + VALUES ($1, $2, 1) + ON CONFLICT ({}, usage_date) + DO UPDATE SET generations_used = {}.generations_used + 1, updated_at = NOW() + "#, + table, id_col, id_col, table + )) + .bind(profile_id) + .bind(today) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + + Ok((used + 1, daily_limit)) +} + +// ── Job Field Generation (Companies) ────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct GenerateJobFieldBody { + field: String, + context: String, +} + +#[derive(Debug, Serialize)] +struct GenerateFieldResponse { + generated_text: String, + remaining_today: i32, + daily_limit: i32, + has_ai_pack: bool, +} + +async fn ai_generate_job_field( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let company: Option = sqlx::query_scalar( + "SELECT id FROM company_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .flatten(); + + let Some(company_id) = company else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No company profile found" }))).into_response(); + }; + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'COMPANY'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "COMPANY").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let (used, limit) = match check_and_increment_usage(&state.pool, company_id, true, daily_limit).await { + Ok((u, l)) => (u, l), + Err(msg) => { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); + } + }; + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + + let field_prompt = match body.field.as_str() { + "title" => format!( + "Generate a concise, engaging job title (max 80 chars) for: {}. \ + Only return the title, nothing else.", + body.context + ), + "description" => format!( + "Generate a professional job description with sections: **Summary**, **Key Responsibilities**, **Required Skills**, **Preferred Qualifications**, **What We Offer**. \ + Use markdown formatting. Based on: {}\n\nJob Description:", + body.context + ), + "skills" => format!( + "List 6-10 relevant skills for this role, as a comma-separated string (no descriptions): {}", + body.context + ), + "category" => format!( + "Suggest a single job category/department name (max 50 chars) for: {}. Only return the category name.", + body.context + ), + _ => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid field. Use: title, description, skills, category" }))).into_response(); + } + }; + + let generated = match call_ollama_inline(&ollama_base, &model, &field_prompt).await { + Ok(r) => r.trim().to_string(), + Err(e) => { + tracing::error!("Ollama job field generation error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); + } + }; + + ( + StatusCode::OK, + Json(GenerateFieldResponse { + generated_text: generated, + remaining_today: limit - used, + daily_limit: limit, + has_ai_pack: has_pack, + }), + ).into_response() +} + +// ── Cover Letter Generation (Job Seekers) ────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct CoverLetterBody { + job_id: Uuid, + additional_notes: Option, +} + +async fn ai_generate_cover_letter( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( + "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); + }; + + let job: Option<(String, String, String)> = sqlx::query_as( + "SELECT title, description, location FROM jobs WHERE id = $1" + ) + .bind(body.job_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((job_title, job_desc, location)) = job else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); + }; + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let (used, limit) = match check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await { + Ok((u, l)) => (u, l), + Err(msg) => { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); + } + }; + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + + let notes = body.additional_notes.as_deref().unwrap_or(""); + let skills_str = skills.join(", "); + + let prompt = format!( + "Write a personalized, professional cover letter for a job application.\n\n\ + IMPORTANT: Do NOT include phone number, email, or any contact information. \ + Only use the information provided below. Companies pay to view candidate contact details through the platform.\n\n\ + CANDIDATE INFO:\n\ + Name: {}\n\ + Experience: {} years\n\ + Summary: {}\n\ + Skills: {}\n\ + Notes: {}\n\n\ + JOB INFO:\n\ + Title: {}\n\ + Description: {}\n\ + Location: {}\n\n\ + Write a compelling cover letter that highlights how the candidate's experience and skills match the role. \ + Use a professional tone, 3-4 short paragraphs.", + full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, notes, job_title, job_desc, location + ); + + let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await { + Ok(r) => r.trim().to_string(), + Err(e) => { + tracing::error!("Ollama cover letter generation error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); + } + }; + + ( + StatusCode::OK, + Json(GenerateFieldResponse { + generated_text: generated, + remaining_today: limit - used, + daily_limit: limit, + has_ai_pack: has_pack, + }), + ).into_response() +} + +// ── Tailor Resume (Job Seekers) ───────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct TailorResumeBody { + job_id: Uuid, + resume_text: Option, +} + +async fn ai_tailor_resume( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( + "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); + }; + + let job: Option<(String, String)> = sqlx::query_as( + "SELECT title, description FROM jobs WHERE id = $1" + ) + .bind(body.job_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((job_title, job_desc)) = job else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); + }; + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let (used, limit) = match check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await { + Ok((u, l)) => (u, l), + Err(msg) => { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); + } + }; + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + + let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided"); + let skills_str = skills.join(", "); + + let prompt = format!( + "Rewrite the following resume to better match the target job role. \ + IMPORTANT: Do NOT add phone number, email, or any contact information. \ + Only use the information provided. Companies pay to view candidate contact details through the platform.\n\n\ + CANDIDATE:\n\ + Name: {}\n\ + Experience: {} years\n\ + Summary: {}\n\ + Skills: {}\n\ + Current Resume:\n{}\n\n\ + TARGET JOB:\n\ + Title: {}\n\ + Description: {}\n\n\ + Rewrite the resume to emphasize relevant experience and skills for this role. \ + Keep the same format (bullet points, sections). Do not add contact info.", + full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, existing_resume, job_title, job_desc + ); + + let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await { + Ok(r) => r.trim().to_string(), + Err(e) => { + tracing::error!("Ollama resume tailoring error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); + } + }; + + ( + StatusCode::OK, + Json(GenerateFieldResponse { + generated_text: generated, + remaining_today: limit - used, + daily_limit: limit, + has_ai_pack: has_pack, + }), + ).into_response() +} + +// ── Auto Apply (Job Seekers) ─────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct AutoApplyBody { + job_ids: Vec, +} + +#[derive(Debug, Serialize)] +struct AutoApplyResponse { + applications_created: i32, + already_applied: Vec, + failed: Vec, + remaining_today: i32, + daily_limit: i32, +} + +async fn ai_auto_apply( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + if body.job_ids.is_empty() || body.job_ids.len() > 10 { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Select 1-10 jobs at a time" }))).into_response(); + } + + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( + "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); + }; + + if full_name.is_empty() || skills.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Complete your profile (name and skills required) before auto-applying" }))).into_response(); + } + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let remaining = daily_limit - { + let today = chrono::Utc::now().date_naive(); + let used: Option = sqlx::query_scalar( + "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2" + ) + .bind(seeker_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + used.unwrap_or(0) + }; + + if remaining < body.job_ids.len() as i32 { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response(); + } + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let skills_str = skills.join(", "); + + let mut created = 0; + let mut already = vec![]; + let mut failed = vec![]; + + for job_id in &body.job_ids { + let existing: Option = sqlx::query_scalar( + "SELECT id FROM job_applications WHERE job_id = $1 AND applicant_user_id = $2" + ) + .bind(job_id) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + if existing.is_some() { + already.push(*job_id); + continue; + } + + let job: Option<(String, String)> = sqlx::query_as( + "SELECT title, description FROM jobs WHERE id = $1" + ) + .bind(job_id) + .fetch_optional(&state.pool) + .await + .ok() + .and_then(|r| r); + + let Some((job_title, job_desc)) = job else { + failed.push(*job_id); + continue; + }; + + let cover_prompt = format!( + "Write a brief, professional cover letter (max 200 words).\n\n\ + IMPORTANT: Do NOT include phone number, email, or any contact information. \ + Only use the information provided below.\n\n\ + CANDIDATE: Name: {}, Experience: {} years, Skills: {}, Summary: {}\n\ + JOB: Title: {}, Description: {}\n\n\ + Cover Letter:", + full_name, experience, skills_str, summary.as_deref().unwrap_or(""), job_title, job_desc + ); + + let cover_letter = match call_ollama_inline(&ollama_base, &model, &cover_prompt).await { + Ok(r) => r.trim().to_string(), + Err(_) => "I am excited to apply for this position.".to_string(), + }; + + let result = sqlx::query( + r#" + INSERT INTO job_applications (job_id, applicant_user_id, cover_letter, applied_via_ai) + VALUES ($1, $2, $3, true) + ON CONFLICT (job_id, applicant_user_id) DO NOTHING + "# + ) + .bind(job_id) + .bind(auth.user_id) + .bind(&cover_letter) + .execute(&state.pool) + .await; + + match result { + Ok(r) => { + if r.rows_affected() > 0 { + created += 1; + let _ = check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await; + } else { + already.push(*job_id); + } + } + Err(_) => { + failed.push(*job_id); + } + } + } + + let new_remaining = remaining - created; + + ( + StatusCode::OK, + Json(AutoApplyResponse { + applications_created: created, + already_applied: already, + failed, + remaining_today: new_remaining.max(0), + daily_limit, + }), + ).into_response() +} + +// ── Auto Respond to Lead (Professionals) ─────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct AutoRespondToLeadBody { + lead_id: Uuid, + profession_key: String, +} + +async fn ai_auto_respond_to_lead( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let leads_service_url = std::env::var("LEADS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:9118".to_string()); + + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .flatten(); + + let Some(profile_id) = profile_id else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Profile not found" }))).into_response(); + }; + + let today = chrono::Utc::now().date_naive(); + let used: Option = sqlx::query_scalar( + "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2" + ) + .bind(profile_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let daily_limit = 10; + if used.unwrap_or(0) >= daily_limit { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": "Daily AI auto-respond limit reached (10/day)" }))).into_response(); + } + + let url = format!("{}/api/lead-requests/send-ai", leads_service_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let payload = serde_json::json!({ + "lead_id": body.lead_id.to_string(), + "user_id": auth.user_id.to_string(), + "profession_key": body.profession_key + }); + + let res = client + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| e.to_string()); + + let Ok(res) = res else { + return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Failed to reach leads service" }))).into_response(); + }; + + let status = res.status(); + if !status.is_success() { + let body = res.text().await.unwrap_or_default(); + return (StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), Json(serde_json::json!({ "error": body }))).into_response(); + } + + let _ = sqlx::query( + r#" + INSERT INTO job_seeker_ai_usage (job_seeker_id, usage_date, generations_used) + VALUES ($1, $2, 1) + ON CONFLICT (job_seeker_id, usage_date) + DO UPDATE SET generations_used = job_seeker_ai_usage.generations_used + 1, updated_at = NOW() + "# + ) + .bind(profile_id) + .bind(today) + .execute(&state.pool) + .await; + + let remaining = daily_limit - used.unwrap_or(0) - 1; + + (StatusCode::OK, Json(serde_json::json!({ + "success": true, + "remaining_today": remaining.max(0), + "daily_limit": daily_limit, + "message": "AI response sent successfully" + }))).into_response() +} + +// ── Usage Status ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct UsageStatusResponse { + remaining_today: i32, + daily_limit: i32, + has_ai_pack: bool, +} + +async fn ai_usage_status( + State(state): State, + auth: AuthUser, +) -> impl IntoResponse { + let (is_company, profile_id) = { + if let Some(cid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM company_profiles WHERE user_id = $1") + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + { + (true, cid) + } else if let Some(sid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM job_seeker_profiles WHERE user_id = $1") + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + { + (false, sid) + } else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "No profile found" }))).into_response(); + } + }; + + let today = chrono::Utc::now().date_naive(); + let used: Option = if is_company { + sqlx::query_scalar("SELECT generations_used FROM company_ai_usage WHERE company_id = $1 AND usage_date = $2") + .bind(profile_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + } else { + sqlx::query_scalar("SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2") + .bind(profile_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + }; + + let role_key = if is_company { "COMPANY" } else { "JOB_SEEKER" }; + let (has_pack, daily_limit) = { + let urp_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2" + ) + .bind(auth.user_id) + .bind(role_key) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match urp_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, role_key).await, + None => (false, BASE_AI_LIMIT), + } + }; + + let remaining = daily_limit - used.unwrap_or(0); + + (StatusCode::OK, Json(UsageStatusResponse { + remaining_today: remaining.max(0), + daily_limit, + has_ai_pack: has_pack, + })).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_field_request_deserialization() { + let json = serde_json::json!({ + "field": "title", + "context": "Senior Rust Developer" + }); + let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.field, "title"); + assert_eq!(body.context, "Senior Rust Developer"); + } + + #[test] + fn test_generate_field_request_all_fields() { + for field in ["title", "description", "skills", "category"] { + let json = serde_json::json!({ + "field": field, + "context": "Test context" + }); + let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.field, field); + } + } + + #[test] + fn test_generate_field_response_serialization() { + let response = GenerateFieldResponse { + generated_text: "Senior Rust Developer".to_string(), + remaining_today: 4, + daily_limit: 5, + has_ai_pack: false, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["generated_text"], "Senior Rust Developer"); + assert_eq!(json["remaining_today"], 4); + assert_eq!(json["daily_limit"], 5); + assert_eq!(json["has_ai_pack"], false); + } + + #[test] + fn test_generate_field_response_with_ai_pack() { + let response = GenerateFieldResponse { + generated_text: "Generated content".to_string(), + remaining_today: 15, + daily_limit: 20, + has_ai_pack: true, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["has_ai_pack"], true); + assert_eq!(json["daily_limit"], 20); + } + + #[test] + fn test_cover_letter_body_deserialization() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "additional_notes": "Available from next month" + }); + let body: CoverLetterBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.job_id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(body.additional_notes, Some("Available from next month".to_string())); + } + + #[test] + fn test_cover_letter_body_without_notes() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000" + }); + let body: CoverLetterBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.additional_notes, None); + } + + #[test] + fn test_tailor_resume_body_deserialization() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "resume_text": "My existing resume..." + }); + let body: TailorResumeBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.job_id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(body.resume_text, Some("My existing resume...".to_string())); + } + + #[test] + fn test_tailor_resume_body_without_resume() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000" + }); + let body: TailorResumeBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.resume_text, None); + } + + #[test] + fn test_auto_apply_body_deserialization() { + let json = serde_json::json!({ + "job_ids": [ + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001" + ] + }); + let body: AutoApplyBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.job_ids.len(), 2); + } + + #[test] + fn test_auto_apply_response_serialization() { + let response = AutoApplyResponse { + applications_created: 2, + already_applied: vec![], + failed: vec![], + remaining_today: 8, + daily_limit: 10, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["applications_created"], 2); + assert_eq!(json["remaining_today"], 8); + assert_eq!(json["daily_limit"], 10); + } + + #[test] + fn test_usage_status_response_serialization() { + let response = UsageStatusResponse { + remaining_today: 3, + daily_limit: 5, + has_ai_pack: false, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["remaining_today"], 3); + assert_eq!(json["daily_limit"], 5); + assert_eq!(json["has_ai_pack"], false); + } + + #[test] + fn test_base_ai_limit_constant() { + assert_eq!(BASE_AI_LIMIT, 5); + } + + #[test] + fn test_get_ai_limit_from_features_with_value() { + let features = serde_json::json!({"ai_generations_per_day": 20}); + let limit = get_ai_limit_for_package(&features); + assert_eq!(limit, 20); + } + + #[test] + fn test_get_ai_limit_from_features_defaults_to_base() { + let features = serde_json::json!({}); + assert_eq!(get_ai_limit_for_package(&features), BASE_AI_LIMIT); + + let features_null = serde_json::json!({"ai_generations_per_day": null}); + assert_eq!(get_ai_limit_for_package(&features_null), BASE_AI_LIMIT); + + let features_wrong_type = serde_json::json!({"ai_generations_per_day": "unlimited"}); + assert_eq!(get_ai_limit_for_package(&features_wrong_type), BASE_AI_LIMIT); + } + + #[test] + fn test_invalid_field_error() { + let json = serde_json::json!({ + "field": "invalid_field", + "context": "test" + }); + let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.field, "invalid_field"); + } } \ No newline at end of file diff --git a/crates/db/migrations/20260425000000_ai_usage.down.sql b/crates/db/migrations/20260425000000_ai_usage.down.sql new file mode 100644 index 0000000..952a6be --- /dev/null +++ b/crates/db/migrations/20260425000000_ai_usage.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +ALTER TABLE job_applications DROP COLUMN IF EXISTS applied_via_ai; +ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS has_ai_pack; + +DROP TABLE IF EXISTS company_ai_usage; +DROP TABLE IF EXISTS job_seeker_ai_usage; + +COMMIT; \ No newline at end of file diff --git a/crates/db/migrations/20260425000000_ai_usage.up.sql b/crates/db/migrations/20260425000000_ai_usage.up.sql new file mode 100644 index 0000000..2c59be5 --- /dev/null +++ b/crates/db/migrations/20260425000000_ai_usage.up.sql @@ -0,0 +1,38 @@ +-- AI usage tracking for companies and job seekers +-- Supports per-day rate limiting for AI generation features + +BEGIN; + +-- Track AI usage per company per day +CREATE TABLE IF NOT EXISTS company_ai_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE, + usage_date DATE NOT NULL DEFAULT CURRENT_DATE, + generations_used INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(company_id, usage_date) +); + +-- Track AI usage per job seeker per day +CREATE TABLE IF NOT EXISTS job_seeker_ai_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE, + usage_date DATE NOT NULL DEFAULT CURRENT_DATE, + generations_used INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(job_seeker_id, usage_date) +); + +-- Indexes for fast lookups +CREATE INDEX IF NOT EXISTS idx_company_ai_usage_company_date ON company_ai_usage(company_id, usage_date); +CREATE INDEX IF NOT EXISTS idx_job_seeker_ai_usage_seeker_date ON job_seeker_ai_usage(job_seeker_id, usage_date); + +-- Add applied_via_ai flag to job_applications for AI auto-apply tracking +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS applied_via_ai BOOLEAN DEFAULT false; + +-- Add ai_pack field to job_seeker_profiles for quick lookup (cached from pricing_packages) +ALTER TABLE job_seeker_profiles ADD COLUMN IF NOT EXISTS has_ai_pack BOOLEAN DEFAULT false; + +COMMIT; \ No newline at end of file