feat: add AI endpoints for chat, tickets, form extraction via Ollama
- Add /api/ai/chat/message: LLM-powered chat with intent classification
- Add /api/ai/tickets/create and /api/ai/tickets/🆔 AI ticket management
- Add /api/ai/forms/extract: LLM-powered form field extraction
- Add /api/support/tickets/ai/create: unauthenticated ticket creation for AI service
- Add reqwest to workspace dependencies
This commit is contained in:
parent
4fa5005559
commit
430711a0ae
7 changed files with 505 additions and 0 deletions
92
Cargo.lock
generated
92
Cargo.lock
generated
|
|
@ -788,6 +788,12 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
|
|
@ -1522,9 +1528,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1826,6 +1834,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2248,6 +2257,12 @@ dependencies = [
|
|||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "makeup_artists"
|
||||
version = "0.1.0"
|
||||
|
|
@ -2723,6 +2738,61 @@ dependencies = [
|
|||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.5.10",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
|
|
@ -2914,6 +2984,8 @@ dependencies = [
|
|||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2921,6 +2993,7 @@ dependencies = [
|
|||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
|
|
@ -2930,6 +3003,7 @@ dependencies = [
|
|||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2977,6 +3051,12 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
|
|
@ -3045,6 +3125,7 @@ version = "1.14.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -4099,6 +4180,7 @@ dependencies = [
|
|||
"db",
|
||||
"email",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
|
@ -4323,6 +4405,16 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
|
|
|||
|
|
@ -54,3 +54,4 @@ redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
|||
async-trait = "0.1"
|
||||
bytes = "1"
|
||||
tower-http = "0.6"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ contracts = { path = "../../crates/contracts" }
|
|||
cache = { path = "../../crates/cache" }
|
||||
rand = "0.8"
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
|
|
|
|||
352
apps/users/src/handlers/ai.rs
Normal file
352
apps/users/src/handlers/ai.rs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn ai_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/chat/message", post(ai_chat_message))
|
||||
.route("/tickets/create", post(ai_create_ticket))
|
||||
.route("/tickets/:id", get(ai_get_ticket))
|
||||
.route("/forms/extract", post(ai_extract_form))
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
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://localhost:11434".to_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 classify_intent(message: &str, ollama_base: &str, model: &str) -> (String, f32) {
|
||||
let prompt = format!(
|
||||
"Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, general. \
|
||||
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",
|
||||
_ => "general",
|
||||
};
|
||||
(intent.to_string(), confidence)
|
||||
}
|
||||
Err(_) => ("general".to_string(), 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string());
|
||||
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;
|
||||
|
||||
let system_prompt = match intent.as_str() {
|
||||
"ticket_creation" => {
|
||||
"You are a support ticket assistant. Help users create clear, actionable support tickets. \
|
||||
Ask for: subject, description of issue, category, priority if not provided. \
|
||||
Summarize the ticket in a structured way."
|
||||
}
|
||||
"form_filling" => {
|
||||
"You are a form filling assistant. Help users fill out forms by extracting relevant information \
|
||||
from their message. Extract key:value pairs when possible."
|
||||
}
|
||||
"help_search" => {
|
||||
"You are a help center assistant. Help users find relevant help articles based on their query. \
|
||||
Ask clarifying questions to narrow down the search."
|
||||
}
|
||||
_ => {
|
||||
"You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \
|
||||
If the user needs support, guide them to create a ticket."
|
||||
}
|
||||
};
|
||||
|
||||
let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message);
|
||||
|
||||
let response_text = match call_ollama(&state, &model, &full_prompt).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("Ollama error: {}", e);
|
||||
"I'm having trouble processing your request right now. Please try again or contact support.".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<FormExtractBody>,
|
||||
) -> impl IntoResponse {
|
||||
let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string());
|
||||
|
||||
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();
|
||||
let mut missing_fields = Vec::new();
|
||||
|
||||
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>,
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ pub mod admin_email;
|
|||
pub mod activity_logs;
|
||||
pub mod approvals;
|
||||
pub mod auth;
|
||||
pub mod ai;
|
||||
pub mod config;
|
||||
pub mod coupons;
|
||||
pub mod dashboard;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub fn user_router() -> Router<AppState> {
|
|||
.route("/", post(user_create_ticket).get(user_list_tickets))
|
||||
.route("/{id}", get(user_get_ticket))
|
||||
.route("/{id}/messages", post(user_add_message))
|
||||
.route("/ai/create", post(ai_create_ticket))
|
||||
}
|
||||
|
||||
/// Admin support routes
|
||||
|
|
@ -92,6 +93,61 @@ struct MessageRow {
|
|||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// ── AI Service: create ticket (no user auth required) ────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AiCreateTicketBody {
|
||||
subject: String,
|
||||
description: Option<String>,
|
||||
category: Option<String>,
|
||||
priority: Option<String>,
|
||||
#[serde(rename = "userId")]
|
||||
user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
async fn ai_create_ticket(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Json(body): axum::extract::Json<AiCreateTicketBody>,
|
||||
) -> impl IntoResponse {
|
||||
let user_id = body.user_id.unwrap_or_else(|| Uuid::nil());
|
||||
let category = body.category.clone().unwrap_or_else(|| "ai_assisted".to_string());
|
||||
let priority = body.priority.clone().unwrap_or_else(|| "medium".to_string());
|
||||
|
||||
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(&body.subject)
|
||||
.bind(&body.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,
|
||||
"description": r.description,
|
||||
"category": r.category,
|
||||
"priority": r.priority,
|
||||
"status": r.status,
|
||||
})),
|
||||
).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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── User: create ticket ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ async fn main() {
|
|||
.nest("/api/admin/reports", handlers::pricing::reports_router())
|
||||
// ── Email Management (admin) ──────────────────────────────────────
|
||||
.nest("/api/admin/email", handlers::admin_email::router())
|
||||
// ── AI Assistant ──────────────────────────────────────────────────
|
||||
.nest("/api/ai", handlers::ai::ai_router())
|
||||
.route("/health", get(|| async { "Users OK" }))
|
||||
.with_state(state);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue