From 06e73eebb5efd9f70ab4ebdf430f2865673c0d5c Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Mon, 15 Jun 2026 09:23:44 +0530 Subject: [PATCH] fix(ai): align backend plans and clean warnings --- apps/cron/src/tasks/auto_apply.rs | 16 +- apps/users/src/ai/credits.rs | 1 + apps/users/src/ai/litellm.rs | 1 + apps/users/src/ai/middleware.rs | 8 +- apps/users/src/ai/model_router.rs | 2 + apps/users/src/ai/orchestrator.rs | 4 + apps/users/src/ai/plans.rs | 1 + apps/users/src/handlers/admin_ai.rs | 5 +- apps/users/src/handlers/admin_email.rs | 3 +- apps/users/src/handlers/ai.rs | 716 ++++++++++++++-------- apps/users/src/handlers/ai_auto.rs | 2 +- apps/users/src/handlers/ai_phase4.rs | 5 +- apps/users/src/handlers/onboarding.rs | 3 + apps/users/src/handlers/reviews.rs | 4 +- apps/users/src/main.rs | 2 +- crates/cache/src/ollama.rs | 1 + crates/contracts/src/profession_shared.rs | 1 - 17 files changed, 514 insertions(+), 261 deletions(-) diff --git a/apps/cron/src/tasks/auto_apply.rs b/apps/cron/src/tasks/auto_apply.rs index 9fc44d4..b6f9064 100644 --- a/apps/cron/src/tasks/auto_apply.rs +++ b/apps/cron/src/tasks/auto_apply.rs @@ -5,6 +5,7 @@ use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct AutoApplyConfig { litellm_base_url: String, litellm_api_key: String, @@ -139,6 +140,7 @@ struct JobSeekerWithAi { } #[derive(Debug, sqlx::FromRow)] +#[allow(dead_code)] struct NewJob { id: Uuid, title: String, @@ -169,11 +171,15 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box= s.current_period_start + AND NOW() < s.current_period_end + ORDER BY s.updated_at DESC LIMIT 1 ), 10) as daily_limit, COALESCE(( diff --git a/apps/users/src/ai/credits.rs b/apps/users/src/ai/credits.rs index 64fc2ee..773554c 100644 --- a/apps/users/src/ai/credits.rs +++ b/apps/users/src/ai/credits.rs @@ -145,6 +145,7 @@ pub async fn charge_feature( /// Validate that a user could afford a feature without charging. Useful for /// pre-flight checks in streaming endpoints. +#[allow(dead_code)] pub async fn can_afford(pool: &PgPool, user_id: Uuid, feature_code: &str) -> Result { let cost = get_feature_cost(pool, feature_code).await?; let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id) diff --git a/apps/users/src/ai/litellm.rs b/apps/users/src/ai/litellm.rs index ab3f463..7b7d350 100644 --- a/apps/users/src/ai/litellm.rs +++ b/apps/users/src/ai/litellm.rs @@ -182,6 +182,7 @@ impl LiteLlmClient { } /// Direct pass-through for callers that want the raw JSON response. + #[allow(dead_code)] pub async fn raw_chat_completion(&self, body: Value) -> Result { let url = format!("{}/v1/chat/completions", self.base_url); diff --git a/apps/users/src/ai/middleware.rs b/apps/users/src/ai/middleware.rs index 25cf931..4ba12b9 100644 --- a/apps/users/src/ai/middleware.rs +++ b/apps/users/src/ai/middleware.rs @@ -3,7 +3,6 @@ use axum::http::StatusCode; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use contracts::auth_middleware::AuthUser; -use sqlx::PgPool; use std::future::Future; use uuid::Uuid; @@ -13,6 +12,7 @@ use crate::AppState; /// Extractor that ensures the user has an active AI subscription and is not a /// customer. It can be combined with `AuthUser` in handlers that need it. #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct AiAccess { pub user_id: Uuid, pub role_code: Option, @@ -20,6 +20,7 @@ pub struct AiAccess { pub plan: db::models::ai::AiPlan, } +#[allow(dead_code)] impl AiAccess { /// Remaining credits from the user's subscription. pub fn remaining_credits(&self) -> i32 { @@ -69,10 +70,10 @@ where ) .map_err(|_| AiAccessError::InvalidToken)?; - let user_id = Uuid::parse_str(&token_data.claims.sub) + let _user_id = Uuid::parse_str(&token_data.claims.sub) .map_err(|_| AiAccessError::InvalidToken)?; - let role_code = token_data.claims.active_role.clone(); + let _role_code = token_data.claims.active_role.clone(); // State is not available via FromRequestParts, so we cannot load // the subscription here. Use the Layer middleware below for full checks. @@ -82,6 +83,7 @@ where } #[derive(Debug)] +#[allow(dead_code)] pub enum AiAccessError { MissingToken, InvalidToken, diff --git a/apps/users/src/ai/model_router.rs b/apps/users/src/ai/model_router.rs index 3ea4001..79b948b 100644 --- a/apps/users/src/ai/model_router.rs +++ b/apps/users/src/ai/model_router.rs @@ -59,6 +59,7 @@ pub fn resolve_model( /// Map a feature code to a suggested model tier for cases where the caller /// wants to force higher quality (e.g., long-form generation). +#[allow(dead_code)] pub fn preferred_tier_for_feature(feature_code: &str) -> ModelTier { match feature_code { "jd_generate" @@ -72,6 +73,7 @@ pub fn preferred_tier_for_feature(feature_code: &str) -> ModelTier { /// Convenience resolver that selects the best model alias for a feature, /// preferring Main for complex features unless explicitly overridden. +#[allow(dead_code)] pub fn resolve_best_model( feature: &AiFeatureCost, plan: &AiPlan, diff --git a/apps/users/src/ai/orchestrator.rs b/apps/users/src/ai/orchestrator.rs index e341c99..7e9ee6a 100644 --- a/apps/users/src/ai/orchestrator.rs +++ b/apps/users/src/ai/orchestrator.rs @@ -46,6 +46,7 @@ impl IntoResponse for AiCallError { } } +#[allow(dead_code)] pub struct AiCallResult { pub text: String, pub model_alias: String, @@ -230,6 +231,7 @@ pub async fn call_feature_with_plan( /// Validate that a user can use a feature. Returns the resolved model and /// cost without charging. Useful for streaming pre-flights. +#[allow(dead_code)] pub async fn check_feature_access( pool: &PgPool, user_id: Uuid, @@ -252,6 +254,7 @@ pub async fn check_feature_access( /// Helper used by handlers to respond with a JSON body that includes remaining /// credits for the frontend. +#[allow(dead_code)] pub fn success_json( result: &AiCallResult, extra: serde_json::Value, @@ -277,6 +280,7 @@ pub fn success_json( } /// Log a failed AI call without charging credits. +#[allow(dead_code)] pub async fn log_failed_call( pool: &PgPool, user_id: Uuid, diff --git a/apps/users/src/ai/plans.rs b/apps/users/src/ai/plans.rs index 034932d..ec73fc3 100644 --- a/apps/users/src/ai/plans.rs +++ b/apps/users/src/ai/plans.rs @@ -103,6 +103,7 @@ pub fn require_feature(plan: &AiPlan, feature_code: &str) -> Result<(), PlanErro } } +#[allow(dead_code)] pub fn require_model(plan: &AiPlan, model_alias: &str) -> Result<(), PlanError> { if is_model_allowed(plan, model_alias) { Ok(()) diff --git a/apps/users/src/handlers/admin_ai.rs b/apps/users/src/handlers/admin_ai.rs index 5e1d9a8..09d214c 100644 --- a/apps/users/src/handlers/admin_ai.rs +++ b/apps/users/src/handlers/admin_ai.rs @@ -9,11 +9,10 @@ use axum::{ }; use contracts::auth_middleware::{require_admin, AuthUser}; use db::models::ai::{ - AiCreditTransactionRepository, AiFeatureCost, AiFeatureCostRepository, AiPlanRepository, + AiCreditTransactionRepository, AiFeatureCostRepository, AiPlanRepository, AiUsageLogRepository, UserAiSubscriptionRepository, }; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; +use serde::Deserialize; use uuid::Uuid; #[derive(Debug, Deserialize)] diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index 5ff7f2b..24ed2ec 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -536,6 +536,7 @@ async fn update_email_config( #[derive(Deserialize)] struct EmailTestRequest { to_email: String, + #[allow(dead_code)] provider: Option, config: Option, } @@ -559,7 +560,7 @@ async fn test_email_connection( Json(req): Json, ) -> impl IntoResponse { // Send a test email using current or provided config - let result = if let Some(test_config) = req.config { + let result = if let Some(_test_config) = req.config { // For now, just use the existing mailer - test config would require recreating mailer state.mail.send_test_email(&req.to_email).await } else { diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index c580128..1b72187 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -9,12 +9,15 @@ use axum::{ }; use cache::ai as ai_cache; use contracts::auth_middleware::AuthUser; +use db::models::ai::{ + AiCreditPackageRepository, AiCreditTransactionRepository, AiPlanRepository, + AiUsageLogRepository, UserAiSubscriptionRepository, +}; use serde::{Deserialize, Serialize}; -use sqlx::PgPool; -use std::sync::Arc; use uuid::Uuid; #[derive(sqlx::FromRow)] +#[allow(dead_code)] struct KbArticleRow { id: Uuid, title: String, @@ -51,6 +54,7 @@ struct OllamaGenerateResponse { response: String, } +#[allow(dead_code)] async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result { let base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); call_ollama_inline(&base_url, model, prompt).await @@ -299,7 +303,7 @@ fn get_llm_base_url() -> String { let llm_provider = std::env::var("LLM_PROVIDER").unwrap_or_else(|_| "ollama".to_string()); if llm_provider == "litellm" { std::env::var("LITELLM_BASE_URL") - .unwrap_or_else(|_| "https://llm.nxtgauge.com/v1".to_string()) + .unwrap_or_else(|_| "http://litellm.nxtgauge-ai.svc.cluster.local:4000/v1".to_string()) } else { std::env::var("OLLAMA_BASE_URL") .unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()) @@ -447,7 +451,7 @@ async fn ai_chat_message( None => classify_intent(&body.message, &ollama_base, &model).await, }; - let (response_text, ollama_used) = match intent.as_str() { + let (response_text, _ollama_used) = match intent.as_str() { "help_search" => { let q = body.message.to_lowercase(); let rows = sqlx::query_as::<_, KbArticleRow>( @@ -1043,6 +1047,7 @@ fn from_orchestrator_result( } } +#[allow(dead_code)] fn fallback_response( generated_text: String, remaining_today: i32, @@ -1082,7 +1087,7 @@ async fn ai_generate_job_field( return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No company profile found" }))).into_response(); }; - let (has_pack, daily_limit) = { + 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'" ) @@ -1099,7 +1104,7 @@ async fn ai_generate_job_field( }; let mut redis = state.redis.clone(); - let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, company_id, true, daily_limit).await { + let (_used, _limit) = match check_and_increment_usage(&state.pool, &mut redis, 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(); @@ -1201,7 +1206,7 @@ async fn ai_generate_cover_letter( return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); }; - let (has_pack, daily_limit) = { + 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'" ) @@ -1218,15 +1223,15 @@ async fn ai_generate_cover_letter( }; let mut redis = state.redis.clone(); - let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { + let (_used, _limit) = match check_and_increment_usage(&state.pool, &mut redis, 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 = get_llm_base_url(); - let model = get_llm_model(); + let _ollama_base = get_llm_base_url(); + let _model = get_llm_model(); let notes = body.additional_notes.as_deref().unwrap_or(""); let skills_str = skills.join(", "); @@ -1319,7 +1324,7 @@ async fn ai_tailor_resume( return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); }; - let (has_pack, daily_limit) = { + 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'" ) @@ -1336,15 +1341,15 @@ async fn ai_tailor_resume( }; let mut redis = state.redis.clone(); - let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { + let (_used, _limit) = match check_and_increment_usage(&state.pool, &mut redis, 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 = get_llm_base_url(); - let model = get_llm_model(); + let _ollama_base = get_llm_base_url(); + let _model = get_llm_model(); let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided"); let skills_str = skills.join(", "); @@ -1438,7 +1443,7 @@ async fn ai_auto_apply( 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 (_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'" ) @@ -1472,8 +1477,8 @@ async fn ai_auto_apply( return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response(); } - let ollama_base = get_llm_base_url(); - let model = get_llm_model(); + let _ollama_base = get_llm_base_url(); + let _model = get_llm_model(); let skills_str = skills.join(", "); // Pre-check credits for all requested applications before generating anything. @@ -1670,7 +1675,7 @@ async fn ai_auto_respond_to_lead( .ok() .flatten(); - let (wallet_id, balance) = match wallet { + let (_wallet_id, balance) = match wallet { Some((id, bal)) => (id, bal), None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Wallet not found. Please contact support." }))).into_response(), }; @@ -1785,7 +1790,7 @@ async fn ai_usage_status( }; let today = chrono::Utc::now().date_naive(); - let used: Option = if is_company { + 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) @@ -1804,7 +1809,7 @@ async fn ai_usage_status( }; let role_key = if is_company { "COMPANY" } else { "JOB_SEEKER" }; - let (has_pack, daily_limit) = { + 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" ) @@ -2123,7 +2128,7 @@ fn is_support_intent(message: &str) -> bool { // ── Phase 2: auto-create a support ticket for support-intent queries ────────── #[derive(Debug, Serialize, Clone)] -struct CreatedTicket { +pub(crate) struct CreatedTicket { id: Uuid, subject: String, status: String, @@ -2197,6 +2202,7 @@ fn build_persona_pillar_system_prompt(persona: Option, pillar: Option

axum::response::Response { use axum::http::header; let body = serde_json::json!({ @@ -3278,6 +3288,7 @@ pub mod phase3 { /// Tries models in order, falling back to the next on any error. /// Logs every attempt at warn level so we can graph primary-uptime /// in Grafana later. + #[allow(dead_code)] pub async fn ollama_generate_with_fallback( base_url: &str, primary_model: &str, @@ -3358,6 +3369,7 @@ pub mod phase3 { pub message: String, pub persona: Option, pub pillar: Option, + #[allow(dead_code)] pub conversation_id: Option, } @@ -3608,86 +3620,24 @@ pub mod phase3 { } } - /// GET /api/ai/usage — counts, limits, remaining quota. - /// Per-user, per-minute rate-limit window + Redis daily counter + DB counter. - /// Extended with plan info, addon_balance, and renewal_date from ai_entitlements. + /// GET /api/ai/usage — credit-based usage, limits, and rate-limit windows. pub async fn ai_usage( State(state): State, auth: contracts::auth_middleware::AuthUser, ) -> impl axum::response::IntoResponse { - let today = chrono::Utc::now().date_naive(); - let start_of_month = { - let today_str = today.format("%Y-%m-%d").to_string(); - let year_month = &today_str[..7]; - let start_str = format!("{}-01", year_month); - chrono::NaiveDate::parse_from_str(&start_str, "%Y-%m-%d").unwrap_or(today) + let (sub, plan) = match crate::ai::plans::ensure_free_subscription( + &state.pool, + auth.user_id, + Some(&auth.claims.active_role), + ) + .await + { + Ok(data) => data, + Err(e) => { + return (e.status_code(), axum::Json(e.error_body())).into_response(); + } }; - let company_used: Option = sqlx::query_scalar( - "SELECT generations_used FROM company_ai_usage WHERE company_id = \ - (SELECT id FROM company_profiles WHERE user_id = $1) AND usage_date = $2", - ) - .bind(auth.user_id) - .bind(today) - .fetch_optional(&state.pool) - .await - .ok() - .flatten() - .flatten(); - - let seeker_used: Option = sqlx::query_scalar( - "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = \ - (SELECT id FROM job_seeker_profiles WHERE user_id = $1) AND usage_date = $2", - ) - .bind(auth.user_id) - .bind(today) - .fetch_optional(&state.pool) - .await - .ok() - .flatten() - .flatten(); - - let company_monthly_used: Option = sqlx::query_scalar( - "SELECT COALESCE(SUM(generations_used), 0) FROM company_ai_usage WHERE company_id = \ - (SELECT id FROM company_profiles WHERE user_id = $1) AND usage_date >= $2", - ) - .bind(auth.user_id) - .bind(start_of_month) - .fetch_optional(&state.pool) - .await - .ok() - .flatten() - .flatten(); - - let seeker_monthly_used: Option = sqlx::query_scalar( - "SELECT COALESCE(SUM(generations_used), 0) FROM job_seeker_ai_usage WHERE job_seeker_id = \ - (SELECT id FROM job_seeker_profiles WHERE user_id = $1) AND usage_date >= $2", - ) - .bind(auth.user_id) - .bind(start_of_month) - .fetch_optional(&state.pool) - .await - .ok() - .flatten() - .flatten(); - - let (plan_name, monthly_limit, addon_balance, renewal_date): (String, i32, i32, Option) = sqlx::query_as::<_, (String, i32, i32, Option)>( - r#" - SELECT p.name, e.monthly_limit, e.addon_balance, e.renewal_date - FROM ai_entitlements e - JOIN ai_plans p ON p.id = e.plan_id - WHERE e.user_id = $1 - ORDER BY e.created_at DESC - LIMIT 1 - "#, - ) - .bind(auth.user_id) - .fetch_optional(&state.pool) - .await - .ok() - .flatten() - .unwrap_or_else(|| ("Free".to_string(), 50, 0, None)); - let mut redis = state.redis.clone(); let now_minute = chrono::Utc::now().timestamp() / 60; let chat_key = format!("rl:ai_chat:{}:{}", auth.user_id, now_minute); @@ -3697,46 +3647,25 @@ pub mod phase3 { let chat_minute: i64 = redis.get(&chat_key).await.unwrap_or(0); let stream_minute: i64 = redis.get(&stream_key).await.unwrap_or(0); - let daily_used = company_used.or(seeker_used).unwrap_or(0); - let monthly_used = company_monthly_used.unwrap_or(0) + seeker_monthly_used.unwrap_or(0); - let daily_limit = super::BASE_AI_LIMIT; - - // New plan-aware usage status. - let plan_status = match crate::ai::plans::ensure_free_subscription( - &state.pool, - auth.user_id, - Some(&auth.claims.active_role), - ) - .await - { - Ok((sub, plan)) => serde_json::json!({ - "plan_code": plan.code, - "plan_name": plan.name, - "remaining_daily_actions": crate::ai::credits::remaining_daily_actions(&sub, &plan), - "daily_action_limit": plan.daily_action_limit, - "remaining_credits": crate::ai::credits::remaining_credits(&sub), - "monthly_credits_total": sub.monthly_credits_total, - "monthly_credits_used": sub.monthly_credits_used, - "purchased_credits_total": sub.purchased_credits_total, - "purchased_credits_used": sub.purchased_credits_used, - "period_start": sub.current_period_start, - "period_end": sub.current_period_end, - }), - Err(e) => e.error_body(), - }; + let purchased_remaining = + (sub.purchased_credits_total - sub.purchased_credits_used).max(0); + let remaining_credits = crate::ai::credits::remaining_credits(&sub); + let remaining_daily_actions = crate::ai::credits::remaining_daily_actions(&sub, &plan); ( axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "user_id": auth.user_id, - "plan": plan_name, - "monthly_limit": monthly_limit, - "monthly_used": monthly_used, - "monthly_remaining": (monthly_limit - monthly_used).max(0), - "daily_limit": daily_limit, - "daily_used": daily_used, - "addon_balance": addon_balance, - "renewal_date": renewal_date, + "plan": plan.name, + "plan_code": plan.code, + "monthly_limit": sub.monthly_credits_total, + "monthly_used": sub.monthly_credits_used, + "monthly_remaining": remaining_credits, + "daily_limit": plan.daily_action_limit, + "daily_used": sub.daily_actions_used, + "daily_remaining": remaining_daily_actions, + "addon_balance": purchased_remaining, + "renewal_date": sub.current_period_end.date_naive(), "rate_limits": { "chat_per_minute": { "used": chat_minute, @@ -3749,9 +3678,22 @@ pub mod phase3 { "remaining": (30 - stream_minute).max(0), }, }, - "plan": plan_status, + "plan_details": { + "plan_code": plan.code, + "plan_name": plan.name, + "remaining_daily_actions": remaining_daily_actions, + "daily_action_limit": plan.daily_action_limit, + "remaining_credits": remaining_credits, + "monthly_credits_total": sub.monthly_credits_total, + "monthly_credits_used": sub.monthly_credits_used, + "purchased_credits_total": sub.purchased_credits_total, + "purchased_credits_used": sub.purchased_credits_used, + "period_start": sub.current_period_start, + "period_end": sub.current_period_end, + }, })), ) + .into_response() } /// POST /api/ai/clear-history — GDPR right-to-erasure for AI history. @@ -3796,73 +3738,124 @@ pub mod phase3 { pub message: String, } - /// POST /api/ai/addons/purchase — purchase an addon pack to add generations + /// POST /api/ai/addons/purchase — purchase a credit pack. pub async fn ai_addon_purchase( State(state): State, auth: contracts::auth_middleware::AuthUser, Json(body): Json, ) -> impl axum::response::IntoResponse { - let addon_amount: i32 = match body.addon_code.as_str() { - "STARTER" => 100, - "PRO" => 500, - "ENTERPRISE" => 2000, + let code = body.addon_code.trim().to_uppercase(); + let addon_amount = match code.as_str() { + "STARTER" => 50, + "GROWTH" => 150, + "POWER" => 500, + "ENTERPRISE" => 1000, _ => { - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "success": false, - "message": format!("Unknown addon code: {}", body.addon_code), - })), - ) - .into_response() + match AiCreditPackageRepository::list_active(&state.pool).await { + Ok(packages) => packages + .into_iter() + .find(|pkg| { + pkg.name + .to_uppercase() + .replace([' ', '-'], "_") + .contains(&code) + }) + .map(|pkg| pkg.credits), + Err(e) => { + tracing::error!("Failed to list AI credit packages: {}", e); + None + } + } + .unwrap_or(0) } }; - let result = sqlx::query_as::<_, (i32,)>( - r#" - UPDATE ai_entitlements - SET addon_balance = addon_balance + $1, updated_at = NOW() - WHERE user_id = $2 AND status = 'active' - RETURNING addon_balance - "#, - ) - .bind(addon_amount) - .bind(auth.user_id) - .fetch_optional(&state.pool) - .await; - - match result { - Ok(Some((new_balance,))) => ( - axum::http::StatusCode::OK, - axum::Json(AddonPurchaseResponse { - success: true, - addon_balance: new_balance, - message: format!("Successfully purchased {} generations", addon_amount), - }), + if addon_amount <= 0 { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "success": false, + "message": format!("Unknown addon code: {}", body.addon_code), + })), ) - .into_response(), - Ok(None) => ( - axum::http::StatusCode::NOT_FOUND, + .into_response(); + } + + let _ = match crate::ai::plans::ensure_free_subscription( + &state.pool, + auth.user_id, + Some(&auth.claims.active_role), + ) + .await + { + Ok(data) => data, + Err(e) => { + return (e.status_code(), axum::Json(e.error_body())).into_response(); + } + }; + + if let Err(e) = UserAiSubscriptionRepository::add_purchased_credits( + &state.pool, + auth.user_id, + addon_amount, + ) + .await + { + tracing::error!("ai_addon_purchase update failed: {}", e); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(AddonPurchaseResponse { success: false, addon_balance: 0, - message: "No active AI entitlement found. Please upgrade your plan first.".to_string(), + message: "Failed to process purchase".to_string(), }), ) - .into_response(), - Err(e) => { - tracing::error!("ai_addon_purchase failed: {}", e); - ( + .into_response(); + } + + let Some(updated_sub) = UserAiSubscriptionRepository::get_by_user_id(&state.pool, auth.user_id) + .await + .ok() + .flatten() else { + return ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(AddonPurchaseResponse { success: false, addon_balance: 0, - message: "Failed to process purchase".to_string(), + message: "Subscription not found after purchase".to_string(), }), ) - .into_response() - } + .into_response(); + }; + + let purchased_remaining = + (updated_sub.purchased_credits_total - updated_sub.purchased_credits_used).max(0); + let remaining_credits = crate::ai::credits::remaining_credits(&updated_sub); + + if let Err(e) = AiCreditTransactionRepository::create( + &state.pool, + auth.user_id, + "credit", + "purchase", + addon_amount, + remaining_credits, + None, + Some(&format!("addon_code={}", body.addon_code)), + ) + .await + { + tracing::error!("Failed to record AI credit purchase transaction: {}", e); } + + ( + axum::http::StatusCode::OK, + axum::Json(AddonPurchaseResponse { + success: true, + addon_balance: purchased_remaining, + message: format!("Successfully added {} AI credits", addon_amount), + }), + ) + .into_response() } #[derive(Debug, Deserialize)] @@ -3878,90 +3871,83 @@ pub mod phase3 { pub message: String, } - /// POST /api/ai/plans/upgrade — upgrade the user's AI plan + /// POST /api/ai/plans/upgrade — upgrade the user's AI plan. pub async fn ai_plan_upgrade( State(state): State, auth: contracts::auth_middleware::AuthUser, Json(body): Json, ) -> impl axum::response::IntoResponse { - let (plan_id, plan_name, monthly_limit): (uuid::Uuid, String, i32) = sqlx::query_as( - r#" - SELECT id, name, monthly_action_limit - FROM ai_plans - WHERE code = $1 AND is_active = true - "#, + let plan = match AiPlanRepository::get_by_code(&state.pool, &body.plan_code).await { + Ok(Some(plan)) => plan, + Ok(None) => { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(PlanUpgradeResponse { + success: false, + plan: "Free".to_string(), + monthly_limit: 10, + message: format!("Unknown or inactive plan: {}", body.plan_code), + }), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to resolve AI plan '{}': {}", body.plan_code, e); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(PlanUpgradeResponse { + success: false, + plan: "Unknown".to_string(), + monthly_limit: 0, + message: "Failed to upgrade plan".to_string(), + }), + ) + .into_response(); + } + }; + + let _ = match crate::ai::plans::ensure_free_subscription( + &state.pool, + auth.user_id, + Some(&auth.claims.active_role), ) - .bind(&body.plan_code) - .fetch_optional(&state.pool) .await - .ok() - .flatten() - .map(|(id, name, limit): (uuid::Uuid, String, i32)| (id, name, limit)) - .unwrap_or_else(|| (uuid::Uuid::nil(), "Free".to_string(), 50)); + { + Ok(data) => data, + Err(e) => { + return (e.status_code(), axum::Json(e.error_body())).into_response(); + } + }; - if plan_id == uuid::Uuid::nil() { - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(PlanUpgradeResponse { - success: false, - plan: "Free".to_string(), - monthly_limit: 50, - message: format!("Unknown or inactive plan: {}", body.plan_code), - }), - ) - .into_response(); - } - - let result = sqlx::query_as::<_, (String, i32)>( - r#" - UPDATE ai_entitlements - SET plan_id = $1, - monthly_action_limit = $2, - valid_from = NOW(), - updated_at = NOW() - WHERE user_id = $3 AND status = 'active' - RETURNING p.name, e.monthly_action_limit - FROM ai_plans p - WHERE p.id = e.plan_id - "#, + let (period_start, period_end) = crate::ai::plans::current_monthly_period(chrono::Utc::now()); + match UserAiSubscriptionRepository::update_plan( + &state.pool, + auth.user_id, + plan.id, + plan.monthly_credits, + period_start, + period_end, ) - .bind(plan_id) - .bind(monthly_limit) - .bind(auth.user_id) - .fetch_optional(&state.pool) - .await; - - match result { - Ok(Some((name, limit))) => ( + .await + { + Ok(_) => ( axum::http::StatusCode::OK, axum::Json(PlanUpgradeResponse { success: true, - plan: name, - monthly_limit: limit, - message: format!("Successfully upgraded to {}", plan_name), + plan: plan.name.clone(), + monthly_limit: plan.monthly_credits, + message: format!("Successfully upgraded to {}", plan.name), }), ) .into_response(), - Ok(None) => { - ( - axum::http::StatusCode::NOT_FOUND, - axum::Json(PlanUpgradeResponse { - success: false, - plan: plan_name, - monthly_limit, - message: "No active AI entitlement found. Please purchase a plan first.".to_string(), - }), - ) - .into_response() - } Err(e) => { tracing::error!("ai_plan_upgrade failed: {}", e); ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(PlanUpgradeResponse { success: false, - plan: plan_name, - monthly_limit, + plan: plan.name, + monthly_limit: plan.monthly_credits, message: "Failed to upgrade plan".to_string(), }), ) @@ -4039,6 +4025,240 @@ pub mod phase3 { } } + +#[derive(Debug, Deserialize)] +struct CompanyJobDescriptionBody { + context: String, + #[serde(default)] + model: Option, +} + +#[derive(Debug, Serialize)] +struct AiCreditBalanceResponse { + remaining_credits: i32, + monthly_credits_total: i32, + monthly_credits_used: i32, + purchased_credits_total: i32, + purchased_credits_used: i32, + daily_limit: i32, + daily_used: i32, + plan_code: String, + plan_name: String, +} + +#[derive(Debug, Deserialize)] +struct UsageLogsQuery { + #[serde(default = "default_usage_limit")] + limit: i64, + #[serde(default)] + offset: i64, +} + +fn default_usage_limit() -> i64 { + 25 +} + +async fn ai_form_fill( + state: State, + body: Json, +) -> impl IntoResponse { + ai_extract_form(state, body).await +} + +async fn ai_form_validate( + state: State, + body: Json, +) -> impl IntoResponse { + ai_extract_form(state, body).await +} + +async fn ai_help_ask( + state: State, + auth: AuthUser, + body: Json, +) -> impl IntoResponse { + ai_chat_ask(state, auth, body).await +} + +async fn ai_company_generate_description( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + match orchestrator::call_feature( + &state, + &auth, + "jd_generate", + None, + &format!( + "Generate a professional job description with sections: Summary, Responsibilities, Requirements, Skills, Experience, Location. Do not invent salary or company details. Context: {}", + body.context + ), + body.model.as_deref(), + None, + ) + .await + { + Ok(r) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": r.text, + "generated_text": r.text, + "credits_charged": r.credits_charged, + "remaining_credits": r.remaining_credits, + "remaining_today": r.remaining_daily_actions, + "model": r.model_alias, + "feature_code": "jd_generate" + })), + ) + .into_response(), + Err(e) => e.into_response(), + } +} + +async fn ai_company_improve_description( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + match orchestrator::call_feature( + &state, + &auth, + "jd_improve", + None, + &format!( + "Improve the following job description. Keep it concise, professional, and structured. Do not invent salary or company details. Description: {}", + body.context + ), + body.model.as_deref(), + None, + ) + .await + { + Ok(r) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": r.text, + "generated_text": r.text, + "credits_charged": r.credits_charged, + "remaining_credits": r.remaining_credits, + "remaining_today": r.remaining_daily_actions, + "model": r.model_alias, + "feature_code": "jd_improve" + })), + ) + .into_response(), + Err(e) => e.into_response(), + } +} + +async fn ai_company_extract_skills( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + match orchestrator::call_feature( + &state, + &auth, + "skills_extract", + None, + &format!( + "Extract only the relevant job skills from this text. Return a comma-separated list and nothing else. Text: {}", + body.context + ), + body.model.as_deref(), + None, + ) + .await + { + Ok(r) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": r.text, + "skills": r.text, + "credits_charged": r.credits_charged, + "remaining_credits": r.remaining_credits, + "remaining_today": r.remaining_daily_actions, + "model": r.model_alias, + "feature_code": "skills_extract" + })), + ) + .into_response(), + Err(e) => e.into_response(), + } +} + +async fn ai_credits_balance( + State(state): State, + auth: AuthUser, +) -> impl IntoResponse { + match plans::ensure_free_subscription(&state.pool, auth.user_id, Some(&auth.claims.active_role)).await { + Ok((sub, plan)) => ( + StatusCode::OK, + Json(AiCreditBalanceResponse { + remaining_credits: credits::remaining_credits(&sub), + monthly_credits_total: sub.monthly_credits_total, + monthly_credits_used: sub.monthly_credits_used, + purchased_credits_total: sub.purchased_credits_total, + purchased_credits_used: sub.purchased_credits_used, + daily_limit: plan.daily_action_limit, + daily_used: sub.daily_actions_used, + plan_code: plan.code, + plan_name: plan.name, + }), + ) + .into_response(), + Err(e) => (e.status_code(), Json(e.error_body())).into_response(), + } +} + +async fn ai_plans_list( + State(state): State, + _auth: AuthUser, +) -> impl IntoResponse { + match AiPlanRepository::list_active(&state.pool).await { + Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "plans": rows }))).into_response(), + Err(e) => { + tracing::error!("Failed to list AI plans: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to list AI plans" })), + ) + .into_response() + } + } +} + +async fn ai_usage_logs( + State(state): State, + auth: AuthUser, + axum::extract::Query(query): axum::extract::Query, +) -> impl IntoResponse { + let limit = query.limit.clamp(1, 100); + let offset = query.offset.max(0); + + match AiUsageLogRepository::list_by_user(&state.pool, auth.user_id, limit, offset).await { + Ok(rows) => ( + StatusCode::OK, + Json(serde_json::json!({ + "logs": rows, + "limit": limit, + "offset": offset, + })), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to list AI usage logs: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to list AI usage logs" })), + ) + .into_response() + } + } +} + + // ── Wire the new Phase 3 endpoints into the router ─────────────────────────── // // We wrap the Phase 3 handlers in a private `phase3_router()` so the existing @@ -4057,29 +4277,37 @@ async fn phase3_chat_stream( pub fn ai_router() -> Router { Router::new() .route("/chat/message", post(ai_chat_message)) - // ── Ask Ash: Phase 2 endpoints (personas + pillars) ───────────────── .route("/chat/ask", post(ai_chat_ask)) + .route("/help/ask", post(ai_help_ask)) .route("/suggestions", get(ai_suggestions)) .route("/context", post(ai_save_context)) .route("/history", get(ai_history)) .route("/tickets/create", post(ai_create_ticket)) .route("/tickets/{id}", get(ai_get_ticket)) .route("/forms/extract", post(ai_extract_form)) + .route("/forms/fill", post(ai_form_fill)) + .route("/forms/validate", post(ai_form_validate)) .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)) - // ── Phase 3: streaming, feedback, usage, GDPR clear ─────────────── + .route("/usage/summary", get(phase3::ai_usage)) + .route("/usage/logs", get(ai_usage_logs)) + .route("/credits/balance", get(ai_credits_balance)) + .route("/plans", get(ai_plans_list)) + .route("/company/jobs/generate-description", post(ai_company_generate_description)) + .route("/company/jobs/improve-description", post(ai_company_improve_description)) + .route("/company/jobs/extract-skills", post(ai_company_extract_skills)) .route("/chat/stream", post(phase3_chat_stream)) .route("/feedback", post(phase3::ai_feedback)) .route("/usage/v2", get(phase3::ai_usage)) .route("/clear-history", axum::routing::post(phase3::ai_clear_history)) - // ── Addon purchase and plan upgrade ────────────────────────────────── .route("/addons/purchase", post(phase3::ai_addon_purchase)) + .route("/credits/buy", post(phase3::ai_addon_purchase)) .route("/plans/upgrade", post(phase3::ai_plan_upgrade)) - // ── Phase 4: multi-lang, voice, A/B, analytics, model swap, KB+ ─── + .merge(crate::handlers::ai_auto::ai_auto_router()) .merge(crate::handlers::ai_phase4::phase4_router()) .layer(axum::middleware::from_fn_with_state( (), diff --git a/apps/users/src/handlers/ai_auto.rs b/apps/users/src/handlers/ai_auto.rs index 09a4d22..699063b 100644 --- a/apps/users/src/handlers/ai_auto.rs +++ b/apps/users/src/handlers/ai_auto.rs @@ -12,7 +12,7 @@ use db::models::ai::{ AiAutoApplyLogRepository, AiAutoApplySettingsRepository, AiAutoRequestLogRepository, AiAutoRequestSettingsRepository, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::Value; #[derive(Debug, Deserialize)] diff --git a/apps/users/src/handlers/ai_phase4.rs b/apps/users/src/handlers/ai_phase4.rs index d2e0446..5b0b791 100644 --- a/apps/users/src/handlers/ai_phase4.rs +++ b/apps/users/src/handlers/ai_phase4.rs @@ -21,7 +21,6 @@ use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use sqlx::Row; -use std::collections::HashMap; use uuid::Uuid; use crate::AppState; @@ -330,6 +329,7 @@ pub struct AbTest { } #[derive(Debug, Serialize, sqlx::FromRow)] +#[allow(dead_code)] pub struct AbTestSummary { pub name: String, pub description: Option, @@ -744,6 +744,7 @@ async fn analytics_personal( /// Best-effort, idempotent aggregation of ai_conversations → ai_daily_stats. /// Called every 5 minutes by the background task in main.rs. +#[allow(dead_code)] pub async fn aggregate_daily_stats(pool: &sqlx::PgPool) -> Result<(), String> { sqlx::query( r#" @@ -791,11 +792,13 @@ pub fn estimate_tokens(text: &str) -> i32 { /// Hard cap for total history size in tokens. When exceeded, older /// messages get summarised via Ollama. +#[allow(dead_code)] pub const HISTORY_TOKEN_CAP: i32 = 2_000; /// If the total history exceeds the cap, summarise the oldest 3 messages /// into a single one-liner via Ollama and return (summary, trimmed_count). /// Returns `None` when no summarisation is needed or when Ollama is down. +#[allow(dead_code)] pub async fn maybe_summarise_history( pool: &sqlx::PgPool, user_id: Uuid, diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs index d6036b7..4cf41eb 100644 --- a/apps/users/src/handlers/onboarding.rs +++ b/apps/users/src/handlers/onboarding.rs @@ -22,6 +22,7 @@ pub fn onboarding_router() -> Router { .route("/submit", post(submit)) } +#[allow(dead_code)] pub fn me_router() -> Router { Router::new() .route("/profile-status", get(profile_status)) @@ -51,6 +52,7 @@ pub struct SubmitInput { } #[derive(Serialize)] +#[allow(dead_code)] pub struct ProfileStatusResponse { pub onboarding_complete: bool, pub active_role: Option, @@ -225,6 +227,7 @@ async fn submit( } /// GET /api/me/profile-status +#[allow(dead_code)] async fn profile_status( auth: AuthUser, State(state): State, diff --git a/apps/users/src/handlers/reviews.rs b/apps/users/src/handlers/reviews.rs index 5ed75fb..bd67f63 100644 --- a/apps/users/src/handlers/reviews.rs +++ b/apps/users/src/handlers/reviews.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::{get, post}, + routing::get, Json, Router, }; use contracts::auth_middleware::AuthUser; @@ -48,6 +48,7 @@ struct PublicReviewDto { #[derive(Deserialize)] struct CreateReviewBody { + #[allow(dead_code)] lead_request_id: Uuid, rating: i16, comment: Option, @@ -69,6 +70,7 @@ struct PublicListQuery { #[derive(sqlx::FromRow)] struct ReviewRow { id: Uuid, + #[allow(dead_code)] lead_request_id: Uuid, customer_id: Uuid, professional_id: Uuid, diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 1adbc15..44b4c43 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -134,5 +134,5 @@ async fn main() { tracing::info!("Users service listening on {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - let app = axum::serve(listener, app).await.unwrap(); + let _app = axum::serve(listener, app).await.unwrap(); } diff --git a/crates/cache/src/ollama.rs b/crates/cache/src/ollama.rs index 8d842a9..1422130 100644 --- a/crates/cache/src/ollama.rs +++ b/crates/cache/src/ollama.rs @@ -49,6 +49,7 @@ pub struct GenerateResponse { } #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct OllamaErrorResponse { error: String, } diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 66de4c9..9e63e24 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -6,7 +6,6 @@ use axum::{ Json, Router, }; use bytes::BufMut; -use chrono::Utc; use serde::Deserialize; use uuid::Uuid; use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};