2026-04-15 18:19:07 +02:00
use crate ::AppState ;
use axum ::{
2026-04-18 18:30:56 +02:00
extract ::State ,
2026-04-15 18:19:07 +02:00
http ::StatusCode ,
response ::IntoResponse ,
routing ::{ get , post } ,
Json , Router ,
} ;
2026-05-01 03:02:46 +02:00
use cache ::ai as ai_cache ;
2026-05-01 02:54:42 +02:00
use contracts ::auth_middleware ::AuthUser ;
2026-04-15 18:19:07 +02:00
use serde ::{ Deserialize , Serialize } ;
2026-05-01 02:54:42 +02:00
use std ::sync ::Arc ;
2026-04-15 18:19:07 +02:00
use uuid ::Uuid ;
2026-05-01 02:54:42 +02:00
#[ derive(sqlx::FromRow) ]
struct KbArticleRow {
id : Uuid ,
title : String ,
slug : String ,
summary : Option < String > ,
category_name : String ,
}
2026-04-15 18:19:07 +02:00
pub fn ai_router ( ) -> Router < AppState > {
Router ::new ( )
. route ( " /chat/message " , post ( ai_chat_message ) )
. route ( " /tickets/create " , post ( ai_create_ticket ) )
2026-04-15 19:54:58 +02:00
. route ( " /tickets/{id} " , get ( ai_get_ticket ) )
2026-04-15 18:19:07 +02:00
. route ( " /forms/extract " , post ( ai_extract_form ) )
2026-05-01 02:54:42 +02:00
. 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 ) )
2026-04-15 18:19:07 +02:00
}
#[ derive(Debug, Clone, Deserialize, Serialize) ]
pub struct OllamaChatRequest {
pub model : Option < String > ,
pub message : String ,
pub conversation_id : Option < String > ,
pub user_id : Option < String > ,
}
#[ derive(Debug, Clone, Deserialize, Serialize) ]
pub struct OllamaChatResponse {
pub message : String ,
pub conversation_id : String ,
pub intent : String ,
pub confidence : f32 ,
}
#[ derive(Debug, Clone, Deserialize, Serialize) ]
struct OllamaGenerateRequest {
model : String ,
prompt : String ,
stream : bool ,
}
#[ derive(Debug, Clone, Deserialize, Serialize) ]
struct OllamaGenerateResponse {
response : String ,
}
2026-04-18 18:30:56 +02:00
async fn call_ollama ( _state : & AppState , model : & str , prompt : & str ) -> Result < String , String > {
2026-04-15 19:54:58 +02:00
let base_url = std ::env ::var ( " OLLAMA_BASE_URL " ) . unwrap_or_else ( | _ | " http://ollama.nxtgauge-ai.svc.cluster.local:11434 " . to_string ( ) ) ;
2026-04-15 18:19:07 +02:00
let url = format! ( " {} /api/generate " , base_url ) ;
let req = OllamaGenerateRequest {
model : model . to_string ( ) ,
prompt : prompt . to_string ( ) ,
stream : false ,
} ;
let client = reqwest ::Client ::new ( ) ;
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 : OllamaGenerateResponse = response
. json ( )
. await
. map_err ( | e | format! ( " failed to parse ollama response: {} " , e ) ) ? ;
Ok ( result . response )
}
async fn classify_intent ( message : & str , ollama_base : & str , model : & str ) -> ( String , f32 ) {
let prompt = format! (
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
" Classify this user message into one intent category. Categories: \
ticket_creation , form_filling , help_search , job_description_generation , \
generate_cover_letter , improve_resume , request_view_contact , auto_apply_job , unknown , general . \
2026-04-15 18:19:07 +02:00
Return ONLY the intent name , nothing else . \ n \ nMessage : { } " ,
message
) ;
match call_ollama_inline ( ollama_base , model , & prompt ) . await {
Ok ( response ) = > {
let intent = response . trim ( ) . to_lowercase ( ) ;
let confidence = if intent . is_empty ( ) { 0.5 } else { 0.85 } ;
let intent = match intent . as_str ( ) {
" ticket_creation " = > " ticket_creation " ,
" form_filling " = > " form_filling " ,
" help_search " = > " help_search " ,
2026-05-01 02:54:42 +02:00
" job_description_generation " = > " job_description_generation " ,
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
" generate_cover_letter " = > " generate_cover_letter " ,
" improve_resume " = > " improve_resume " ,
" request_view_contact " = > " request_view_contact " ,
" auto_apply_job " = > " auto_apply_job " ,
" unknown " = > " unknown " ,
2026-04-15 18:19:07 +02:00
_ = > " general " ,
} ;
( intent . to_string ( ) , confidence )
}
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
Err ( _ ) = > ( " unknown " . to_string ( ) , 0.0 ) ,
2026-04-15 18:19:07 +02:00
}
}
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
fn is_internal_admin ( auth : & AuthUser ) -> bool {
let active = auth . claims . active_role . as_str ( ) ;
active = = " ADMIN "
| | active = = " SUPER_ADMIN "
| | auth . claims . roles . contains ( & " ADMIN " . to_string ( ) )
| | auth . claims . roles . contains ( & " SUPER_ADMIN " . to_string ( ) )
}
2026-04-15 18:19:07 +02:00
async fn call_ollama_inline ( base_url : & str , model : & str , prompt : & str ) -> Result < String , String > {
let url = format! ( " {} /api/generate " , base_url ) ;
let req = OllamaGenerateRequest {
model : model . to_string ( ) ,
prompt : prompt . to_string ( ) ,
stream : false ,
} ;
let client = reqwest ::Client ::new ( ) ;
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 : OllamaGenerateResponse = response
. json ( )
. await
. map_err ( | e | format! ( " failed to parse ollama response: {} " , e ) ) ? ;
Ok ( result . response )
}
async fn ai_chat_message (
State ( state ) : State < AppState > ,
Json ( body ) : Json < OllamaChatRequest > ,
) -> impl IntoResponse {
2026-04-15 19:54:58 +02:00
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 ( ) ) ;
2026-04-15 18:19:07 +02:00
let default_conversation = Uuid ::new_v4 ( ) . to_string ( ) ;
let conversation_id = body . conversation_id . unwrap_or_else ( | | default_conversation ) ;
let ( intent , confidence ) = classify_intent ( & body . message , & ollama_base , & model ) . await ;
2026-05-01 02:54:42 +02:00
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 < String > = 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 \n Is any of these what you were looking for? " ,
articles . len ( ) ,
links . join ( " \n \n " )
)
}
_ = > {
" I couldn't find any help articles matching your question. \
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
If you need further assistance , I can help you create a support ticket instead . \
Just describe your issue and I ' ll guide you through the ticket creation process . "
2026-05-01 02:54:42 +02:00
. 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 ( )
}
}
}
2026-04-15 18:19:07 +02:00
" ticket_creation " = > {
2026-05-01 02:54:42 +02:00
let system_prompt = " You are a support ticket assistant. Help users create clear, actionable support tickets. \
2026-04-15 18:19:07 +02:00
Ask for : subject , description of issue , category , priority if not provided . \
2026-05-01 02:54:42 +02:00
Summarize the ticket in a structured way . " ;
let full_prompt = format! ( " {} \n \n User: {} \n Assistant: " , 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 ( )
}
}
2026-04-15 18:19:07 +02:00
}
" form_filling " = > {
2026-05-01 02:54:42 +02:00
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 \n User: {} \n Assistant: " , 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 ( )
}
}
2026-04-15 18:19:07 +02:00
}
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
" unknown " = > {
" I'm not sure I understand your request. I can help you with: \n \n \
- Creating support tickets \ n \
- Searching help articles \ n \
- Generating job descriptions \ n \
- Writing cover letters \ n \
- Improving your resume \ n \
- Applying to jobs \ n \
- Requesting to view lead contacts \ n \ n \
Could you please rephrase your request ? " .to_string()
}
2026-04-15 18:19:07 +02:00
_ = > {
2026-05-01 02:54:42 +02:00
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 \n User: {} \n Assistant: " , 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 ( )
}
}
2026-04-15 18:19:07 +02:00
}
} ;
(
StatusCode ::OK ,
Json ( OllamaChatResponse {
message : response_text ,
conversation_id ,
intent ,
confidence ,
} ) ,
)
. into_response ( )
}
async fn ai_create_ticket (
State ( state ) : State < AppState > ,
Json ( body ) : Json < serde_json ::Value > ,
) -> impl IntoResponse {
let subject = body . get ( " subject " ) . and_then ( | v | v . as_str ( ) ) . unwrap_or ( " AI Assisted Request " ) ;
let description = body . get ( " description " ) . and_then ( | v | v . as_str ( ) ) ;
let category = body . get ( " category " ) . and_then ( | v | v . as_str ( ) ) . unwrap_or ( " ai_assisted " ) ;
let priority = body . get ( " priority " ) . and_then ( | v | v . as_str ( ) ) . unwrap_or ( " medium " ) ;
let user_id = body . get ( " user_id " ) . and_then ( | v | v . as_str ( ) )
. and_then ( | s | Uuid ::parse_str ( s ) . ok ( ) )
. unwrap_or_else ( Uuid ::nil ) ;
let result = sqlx ::query_as ::< _ , TicketRow > (
r #"
INSERT INTO support_tickets ( user_id , subject , description , category , priority , status )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , ' new ' )
RETURNING id , subject , description , category , priority , status ,
requester_name , requester_email , assigned_to , created_at , updated_at
" #,
)
. bind ( user_id )
. bind ( subject )
. bind ( description )
. bind ( category )
. bind ( priority )
. fetch_one ( & state . pool )
. await ;
match result {
Ok ( r ) = > (
StatusCode ::CREATED ,
Json ( serde_json ::json! ( {
" id " : r . id ,
" subject " : r . subject ,
" status " : r . status ,
" ticket_id " : r . id ,
} ) ) ,
)
. into_response ( ) ,
Err ( e ) = > {
tracing ::error! ( " AI ticket creation failed: {} " , e ) ;
( StatusCode ::INTERNAL_SERVER_ERROR , Json ( serde_json ::json! ( { " error " : " Failed to create ticket " } ) ) ) . into_response ( )
}
}
}
async fn ai_get_ticket (
State ( state ) : State < AppState > ,
axum ::extract ::Path ( id ) : axum ::extract ::Path < Uuid > ,
) -> impl IntoResponse {
let result = sqlx ::query_as ::< _ , TicketRow > (
r #"
SELECT id , subject , description , category , priority , status ,
requester_name , requester_email , assigned_to , created_at , updated_at
FROM support_tickets WHERE id = $ 1
" #,
)
. bind ( id )
. fetch_optional ( & state . pool )
. await ;
match result {
Ok ( Some ( r ) ) = > (
StatusCode ::OK ,
Json ( serde_json ::json! ( {
" id " : r . id ,
" subject " : r . subject ,
" description " : r . description ,
" category " : r . category ,
" priority " : r . priority ,
" status " : r . status ,
" requester_name " : r . requester_name ,
" requester_email " : r . requester_email ,
" assigned_to " : r . assigned_to ,
" created_at " : r . created_at ,
" updated_at " : r . updated_at ,
} ) ) ,
)
. into_response ( ) ,
Ok ( None ) = > ( StatusCode ::NOT_FOUND , Json ( serde_json ::json! ( { " error " : " Ticket not found " } ) ) ) . into_response ( ) ,
Err ( e ) = > {
tracing ::error! ( " Failed to fetch ticket {}: {} " , id , e ) ;
( StatusCode ::INTERNAL_SERVER_ERROR , Json ( serde_json ::json! ( { " error " : " Failed to fetch ticket " } ) ) ) . into_response ( )
}
}
}
#[ derive(Debug, Deserialize) ]
struct FormExtractBody {
message : String ,
form_type : Option < String > ,
}
#[ derive(Debug, Serialize) ]
struct FormExtractResponse {
fields : Vec < ExtractedField > ,
missing_fields : Vec < String > ,
confidence : f32 ,
}
#[ derive(Debug, Serialize) ]
struct ExtractedField {
key : String ,
value : String ,
confidence : f32 ,
}
async fn ai_extract_form (
2026-04-18 18:30:56 +02:00
State ( _state ) : State < AppState > ,
2026-04-15 18:19:07 +02:00
Json ( body ) : Json < FormExtractBody > ,
) -> impl IntoResponse {
2026-04-15 19:54:58 +02:00
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 ( ) ) ;
2026-04-15 18:19:07 +02:00
let form_type = body . form_type . unwrap_or_else ( | | " generic " . to_string ( ) ) ;
let prompt = format! (
" Extract key:value pairs from this message for a {} form. \
Return ONLY a JSON object with the fields you can identify . \
Use camelCase for field names . \ n \ nMessage : { } " ,
form_type , body . message
) ;
let response_text = match call_ollama_inline ( & ollama_base , & model , & prompt ) . await {
Ok ( r ) = > r ,
Err ( e ) = > {
tracing ::error! ( " Ollama form extraction error: {} " , e ) ;
return ( StatusCode ::INTERNAL_SERVER_ERROR , Json ( serde_json ::json! ( { " error " : " Form extraction failed " } ) ) ) . into_response ( ) ;
}
} ;
let extracted : serde_json ::Value = serde_json ::from_str ( & response_text )
. unwrap_or_else ( | _ | serde_json ::json! ( { } ) ) ;
let mut fields = Vec ::new ( ) ;
2026-04-18 18:30:56 +02:00
let missing_fields = Vec ::new ( ) ;
2026-04-15 18:19:07 +02:00
if let Some ( obj ) = extracted . as_object ( ) {
for ( key , value ) in obj {
fields . push ( ExtractedField {
key : key . clone ( ) ,
value : value . to_string ( ) ,
confidence : 0.8 ,
} ) ;
}
}
let confidence = if fields . is_empty ( ) { 0.3 } else { 0.75 } ;
( StatusCode ::OK , Json ( FormExtractResponse {
fields ,
missing_fields ,
confidence ,
} ) ) . into_response ( )
}
#[ derive(sqlx::FromRow) ]
struct TicketRow {
id : Uuid ,
subject : String ,
description : Option < String > ,
category : String ,
priority : String ,
status : String ,
requester_name : Option < String > ,
requester_email : Option < String > ,
assigned_to : Option < Uuid > ,
created_at : chrono ::DateTime < chrono ::Utc > ,
updated_at : chrono ::DateTime < chrono ::Utc > ,
2026-05-01 02:54:42 +02:00
}
// ── 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 < serde_json ::Value > , ) > (
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 ,
2026-05-01 03:02:46 +02:00
redis : & mut cache ::RedisPool ,
2026-05-01 02:54:42 +02:00
profile_id : Uuid ,
is_company : bool ,
daily_limit : i32 ,
) -> Result < ( i32 , i32 ) , String > {
2026-05-01 03:02:46 +02:00
let user_id_str = profile_id . to_string ( ) ;
// Fast path: check Redis first for rate limiting
let redis_allowed = ai_cache ::check_ai_rate_limit ( redis , & user_id_str , daily_limit as i64 )
. await
. map_err ( | e | e . to_string ( ) ) ? ;
if ! redis_allowed {
return Err ( " Daily AI generation limit reached " . to_string ( ) ) ;
}
// DB is source of truth - check and increment
2026-05-01 02:54:42 +02:00
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 < i32 > = 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 < AppState > ,
auth : AuthUser ,
Json ( body ) : Json < GenerateJobFieldBody > ,
) -> impl IntoResponse {
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
if is_internal_admin ( & auth ) {
return ( StatusCode ::FORBIDDEN , Json ( serde_json ::json! ( { " error " : " Admin users cannot use AI job description generation. Use the admin panel to manage jobs. " } ) ) ) . into_response ( ) ;
}
2026-05-01 02:54:42 +02:00
let company : Option < Uuid > = 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 < Uuid > = 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 ) ,
}
} ;
2026-05-01 03:02:46 +02:00
let mut redis = state . redis . clone ( ) ;
let ( used , limit ) = match check_and_increment_usage ( & state . pool , & mut redis , company_id , true , daily_limit ) . await {
2026-05-01 02:54:42 +02:00
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 < String > ,
}
async fn ai_generate_cover_letter (
State ( state ) : State < AppState > ,
auth : AuthUser ,
Json ( body ) : Json < CoverLetterBody > ,
) -> impl IntoResponse {
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
if is_internal_admin ( & auth ) {
return ( StatusCode ::FORBIDDEN , Json ( serde_json ::json! ( { " error " : " Admin users cannot use AI cover letter generation. " } ) ) ) . into_response ( ) ;
}
2026-05-01 02:54:42 +02:00
let seeker : Option < ( Uuid , String , Option < String > , i32 , Vec < String > ) > = 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 < Uuid > = 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 ) ,
}
} ;
2026-05-01 03:02:46 +02:00
let mut redis = state . redis . clone ( ) ;
let ( used , limit ) = match check_and_increment_usage ( & state . pool , & mut redis , seeker_id , false , daily_limit ) . await {
2026-05-01 02:54:42 +02:00
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 < String > ,
}
async fn ai_tailor_resume (
State ( state ) : State < AppState > ,
auth : AuthUser ,
Json ( body ) : Json < TailorResumeBody > ,
) -> impl IntoResponse {
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
if is_internal_admin ( & auth ) {
return ( StatusCode ::FORBIDDEN , Json ( serde_json ::json! ( { " error " : " Admin users cannot use AI resume tailoring. " } ) ) ) . into_response ( ) ;
}
2026-05-01 02:54:42 +02:00
let seeker : Option < ( Uuid , String , Option < String > , i32 , Vec < String > ) > = 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 < Uuid > = 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 ) ,
}
} ;
2026-05-01 03:02:46 +02:00
let mut redis = state . redis . clone ( ) ;
let ( used , limit ) = match check_and_increment_usage ( & state . pool , & mut redis , seeker_id , false , daily_limit ) . await {
2026-05-01 02:54:42 +02:00
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 < Uuid > ,
}
#[ derive(Debug, Serialize) ]
struct AutoApplyResponse {
applications_created : i32 ,
already_applied : Vec < Uuid > ,
failed : Vec < Uuid > ,
remaining_today : i32 ,
daily_limit : i32 ,
}
async fn ai_auto_apply (
State ( state ) : State < AppState > ,
auth : AuthUser ,
Json ( body ) : Json < AutoApplyBody > ,
) -> impl IntoResponse {
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
if is_internal_admin ( & auth ) {
return ( StatusCode ::FORBIDDEN , Json ( serde_json ::json! ( { " error " : " Admin users cannot use AI auto-apply. " } ) ) ) . into_response ( ) ;
}
2026-05-01 02:54:42 +02:00
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 < String > , i32 , Vec < String > ) > = 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 < Uuid > = 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 < i32 > = 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! [ ] ;
2026-05-01 03:02:46 +02:00
let mut redis = state . redis . clone ( ) ;
2026-05-01 02:54:42 +02:00
for job_id in & body . job_ids {
let existing : Option < Uuid > = 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 ;
2026-05-01 03:02:46 +02:00
let _ = check_and_increment_usage ( & state . pool , & mut redis , seeker_id , false , daily_limit ) . await ;
2026-05-01 02:54:42 +02:00
} 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 < AppState > ,
auth : AuthUser ,
Json ( body ) : Json < AutoRespondToLeadBody > ,
) -> impl IntoResponse {
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
if is_internal_admin ( & auth ) {
return ( StatusCode ::FORBIDDEN , Json ( serde_json ::json! ( { " error " : " Admin users cannot use AI contact reveal. Use the admin panel to manage leads. " } ) ) ) . into_response ( ) ;
}
2026-05-01 02:54:42 +02:00
let leads_service_url = std ::env ::var ( " LEADS_SERVICE_URL " )
2026-05-31 22:53:29 +05:30
. expect ( " LEADS_SERVICE_URL must be set " ) ;
2026-05-01 02:54:42 +02:00
let profile_id : Option < Uuid > = 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 ( ) ;
} ;
feat(ai): add missing intents, admin guards, and validation checks
- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
2026-05-05 17:44:40 +02:00
let approval_status : Option < String > = sqlx ::query_scalar (
" SELECT status FROM user_role_profiles WHERE id = $1 "
)
. bind ( profile_id )
. fetch_optional ( & state . pool )
. await
. ok ( )
. flatten ( ) ;
let Some ( status ) = approval_status else {
return ( StatusCode ::FORBIDDEN , Json ( serde_json ::json! ( { " error " : " Profile not found " } ) ) ) . into_response ( ) ;
} ;
if status ! = " APPROVED " {
return ( StatusCode ::FORBIDDEN , Json ( serde_json ::json! ( { " error " : " Your profile must be approved before you can request lead contact access. Please complete verification. " } ) ) ) . into_response ( ) ;
}
let wallet : Option < ( Uuid , i64 ) > = sqlx ::query_as (
" SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1 "
)
. bind ( auth . user_id )
. fetch_optional ( & state . pool )
. await
. ok ( )
. flatten ( ) ;
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 ( ) ,
} ;
let tracecoins_cost = 30 ;
if balance < tracecoins_cost as i64 {
return ( StatusCode ::PAYMENT_REQUIRED , Json ( serde_json ::json! ( { " error " : format ! ( " Insufficient balance. You need {} Tracecoins but have {}. Please top up your wallet. " , tracecoins_cost , balance ) } ) ) ) . into_response ( ) ;
}
2026-05-01 02:54:42 +02:00
let today = chrono ::Utc ::now ( ) . date_naive ( ) ;
let used : Option < i32 > = 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 < AppState > ,
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 < 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 )
. 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 < Uuid > = 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 " ) ;
}
2026-04-15 18:19:07 +02:00
}