fix(ai): align backend plans and clean warnings

This commit is contained in:
Ashwin Kumar Sivakumar 2026-06-15 09:23:44 +05:30
parent 09465824ad
commit 06e73eebb5
17 changed files with 514 additions and 261 deletions

View file

@ -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<dyn std::error::Err
COALESCE(jsp.experience_years, 0) as experience_years,
jsp.summary,
COALESCE((
SELECT p.monthly_action_limit / 30
FROM ai_entitlements e
JOIN ai_plans p ON p.id = e.plan_id
WHERE e.user_id = u.id AND e.role = 'JOB_SEEKER' AND e.status = 'active'
ORDER BY e.valid_from DESC
SELECT p.daily_action_limit
FROM user_ai_subscriptions s
JOIN ai_plans p ON p.id = s.plan_id
WHERE s.user_id = u.id
AND COALESCE(s.role_code, 'JOB_SEEKER') = 'JOB_SEEKER'
AND s.status = 'active'
AND NOW() >= s.current_period_start
AND NOW() < s.current_period_end
ORDER BY s.updated_at DESC
LIMIT 1
), 10) as daily_limit,
COALESCE((

View file

@ -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<bool, CreditError> {
let cost = get_feature_cost(pool, feature_code).await?;
let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id)

View file

@ -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<Value, LiteLlmError> {
let url = format!("{}/v1/chat/completions", self.base_url);

View file

@ -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<String>,
@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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(())

View file

@ -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)]

View file

@ -536,6 +536,7 @@ async fn update_email_config(
#[derive(Deserialize)]
struct EmailTestRequest {
to_email: String,
#[allow(dead_code)]
provider: Option<String>,
config: Option<EmailTestConfig>,
}
@ -559,7 +560,7 @@ async fn test_email_connection(
Json(req): Json<EmailTestRequest>,
) -> 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 {

View file

@ -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<String, String> {
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<Uuid> = 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<Uuid> = 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<Uuid> = 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<Uuid> = 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<i32> = if is_company {
let _used: Option<i32> = 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<Uuid> = 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<Persona>, pillar: Option<P
// ── Phase 2: HTTP client to Ollama with 30s timeout + fallback ───────────────
#[allow(dead_code)]
async fn ollama_generate_with_timeout(
base_url: &str,
model: &str,
@ -2384,7 +2390,7 @@ async fn ai_chat_ask(
let full_prompt = format!("{system_prompt}\n\n{user_block}\n\nAssistant:");
let (response_text, model_used, credits_charged, remaining_credits, remaining_daily, request_id, ollama_used) =
let (response_text, _model_used, _credits_charged, _remaining_credits, _remaining_daily, _request_id, ollama_used) =
match orchestrator::call_feature(
&state,
&auth,
@ -3169,8 +3175,10 @@ pub mod phase3 {
/// high enough to skip Ollama entirely. The threshold is intentionally
/// high (0.8) because we only want to short-circuit when the article
/// is a clear answer.
#[allow(dead_code)]
const KB_DIRECT_ANSWER_THRESHOLD: f32 = 0.8;
#[allow(dead_code)]
pub async fn kb_rag_top(
pool: &sqlx::PgPool,
decision: &RoutingDecision,
@ -3254,11 +3262,13 @@ pub mod phase3 {
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum RateLimitOutcome {
Allowed { remaining: i64 },
Limited { retry_after: i64 },
}
#[allow(dead_code)]
pub fn rate_limit_response(retry_after: i64) -> 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<String>,
pub pillar: Option<String>,
#[allow(dead_code)]
pub conversation_id: Option<String>,
}
@ -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<AppState>,
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<i32> = 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<i32> = 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<i32> = 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<i32> = 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<chrono::NaiveDate>) = sqlx::query_as::<_, (String, i32, i32, Option<chrono::NaiveDate>)>(
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<AppState>,
auth: contracts::auth_middleware::AuthUser,
Json(body): Json<AddonPurchaseBody>,
) -> 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<AppState>,
auth: contracts::auth_middleware::AuthUser,
Json(body): Json<PlanUpgradeBody>,
) -> 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<String>,
}
#[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<AppState>,
body: Json<FormExtractBody>,
) -> impl IntoResponse {
ai_extract_form(state, body).await
}
async fn ai_form_validate(
state: State<AppState>,
body: Json<FormExtractBody>,
) -> impl IntoResponse {
ai_extract_form(state, body).await
}
async fn ai_help_ask(
state: State<AppState>,
auth: AuthUser,
body: Json<AskAshRequest>,
) -> impl IntoResponse {
ai_chat_ask(state, auth, body).await
}
async fn ai_company_generate_description(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CompanyJobDescriptionBody>,
) -> 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<AppState>,
auth: AuthUser,
Json(body): Json<CompanyJobDescriptionBody>,
) -> 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<AppState>,
auth: AuthUser,
Json(body): Json<CompanyJobDescriptionBody>,
) -> 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<AppState>,
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<AppState>,
_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<AppState>,
auth: AuthUser,
axum::extract::Query(query): axum::extract::Query<UsageLogsQuery>,
) -> 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<AppState> {
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(
(),

View file

@ -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)]

View file

@ -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<String>,
@ -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,

View file

@ -22,6 +22,7 @@ pub fn onboarding_router() -> Router<AppState> {
.route("/submit", post(submit))
}
#[allow(dead_code)]
pub fn me_router() -> Router<AppState> {
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<String>,
@ -225,6 +227,7 @@ async fn submit(
}
/// GET /api/me/profile-status
#[allow(dead_code)]
async fn profile_status(
auth: AuthUser,
State(state): State<AppState>,

View file

@ -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<String>,
@ -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,

View file

@ -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();
}

View file

@ -49,6 +49,7 @@ pub struct GenerateResponse {
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct OllamaErrorResponse {
error: String,
}

View file

@ -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};