nxtgauge-backend-rust/apps/users/src/handlers/support.rs
Tracewebstudio Dev 231ff9530f fix(auth): use 'name' column instead of 'full_name', combine first_name + last_name
- Replace full_name with name in User struct and all queries
- RegisterPayload now takes first_name + last_name instead of full_name
- Combine first_name and last_name into name before saving to DB
- Update all response structs to use 'name' field instead of 'full_name'
- Fix support and dashboard queries to use u.name instead of u.full_name

Root cause: DB has 'name' column, code was using 'full_name' which doesn't exist.
2026-04-13 16:55:09 +02:00

872 lines
29 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Routers ───────────────────────────────────────────────────────────────────
/// User-facing support routes
pub fn user_router() -> Router<AppState> {
Router::new()
.route("/", post(user_create_ticket).get(user_list_tickets))
.route("/{id}", get(user_get_ticket))
.route("/{id}/messages", post(user_add_message))
}
/// Admin support routes
pub fn admin_router() -> Router<AppState> {
Router::new()
.route("/", get(admin_list_cases).post(admin_create_case))
.route("/{id}", get(admin_get_case).patch(admin_update_case))
.route("/{id}/messages", post(admin_add_message))
}
// ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct TicketDto {
id: Uuid,
title: String,
description: Option<String>,
#[serde(rename = "type")]
ticket_type: String,
priority: String,
status: String,
#[serde(rename = "requesterName")]
requester_name: Option<String>,
#[serde(rename = "requesterEmail")]
requester_email: Option<String>,
#[serde(rename = "assignedTo")]
assigned_to: Option<Uuid>,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(rename = "updatedAt")]
updated_at: String,
}
#[derive(Serialize)]
struct MessageDto {
id: Uuid,
#[serde(rename = "ticketId")]
ticket_id: Uuid,
#[serde(rename = "senderId")]
sender_id: Uuid,
body: String,
#[serde(rename = "isInternal")]
is_internal: bool,
#[serde(rename = "createdAt")]
created_at: String,
}
// ── FromRow structs ───────────────────────────────────────────────────────────
#[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>,
}
#[derive(sqlx::FromRow)]
struct MessageRow {
id: Uuid,
ticket_id: Uuid,
sender_id: Uuid,
body: String,
is_internal: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
// ── User: create ticket ───────────────────────────────────────────────────────
#[derive(Deserialize)]
struct UserCreateTicketBody {
subject: String,
description: Option<String>,
category: Option<String>,
priority: Option<String>,
}
async fn user_create_ticket(
auth: AuthUser,
State(state): State<AppState>,
Json(body): Json<UserCreateTicketBody>,
) -> impl IntoResponse {
let category = body.category.unwrap_or_else(|| "customer_query".to_string());
let priority = body.priority.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(auth.user_id)
.bind(&body.subject)
.bind(&body.description)
.bind(&category)
.bind(&priority)
.fetch_one(&state.pool)
.await;
match result {
Ok(r) => {
// Send confirmation email to user
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, auth.user_id).await {
let response_time = match priority.as_str() {
"high" => "2-4 hours",
"medium" => "12-24 hours",
_ => "24-48 hours",
};
let _ = state.mail.send_support_ticket_created_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&r.id.to_string(),
&body.subject,
&category,
&priority,
response_time
).await;
}
(
StatusCode::CREATED,
Json(TicketDto {
id: r.id,
title: r.subject,
description: r.description,
ticket_type: 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.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
}),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to create support ticket: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to create ticket" })),
)
.into_response()
}
}
}
// ── User: list own tickets ────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ListQuery {
status: Option<String>,
page: Option<i64>,
limit: Option<i64>,
}
async fn user_list_tickets(
auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> impl IntoResponse {
let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let status_filter = params.status.as_deref().unwrap_or("").to_string();
let rows = 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 user_id = $1
AND ($2 = '' OR status = $2)
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(auth.user_id)
.bind(&status_filter)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let tickets: Vec<_> = rows
.into_iter()
.map(|r| TicketDto {
id: r.id,
title: r.subject,
description: r.description,
ticket_type: 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.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "tickets": tickets }))).into_response()
}
Err(e) => {
tracing::error!("Failed to list support tickets: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch tickets" })),
)
.into_response()
}
}
}
// ── User: get single ticket with messages ─────────────────────────────────────
async fn user_get_ticket(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let ticket = 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 AND user_id = $2
"#,
)
.bind(id)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await;
match ticket {
Ok(Some(t)) => {
let messages = sqlx::query_as::<_, MessageRow>(
r#"
SELECT id, ticket_id, sender_id, body, is_internal, created_at
FROM support_ticket_messages
WHERE ticket_id = $1 AND is_internal = false
ORDER BY created_at ASC
"#,
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let msgs: Vec<_> = messages
.into_iter()
.map(|m| MessageDto {
id: m.id,
ticket_id: m.ticket_id,
sender_id: m.sender_id,
body: m.body,
is_internal: m.is_internal,
created_at: m.created_at.to_rfc3339(),
})
.collect();
let dto = TicketDto {
id: t.id,
title: t.subject,
description: t.description,
ticket_type: t.category,
priority: t.priority,
status: t.status,
requester_name: t.requester_name,
requester_email: t.requester_email,
assigned_to: t.assigned_to,
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
};
(StatusCode::OK, Json(serde_json::json!({ "ticket": dto, "messages": msgs }))).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()
}
}
}
// ── User: add message ─────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct AddMessageBody {
body: String,
}
async fn user_add_message(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<AddMessageBody>,
) -> impl IntoResponse {
// Verify ticket belongs to user
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM support_tickets WHERE id = $1 AND user_id = $2)",
)
.bind(id)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.unwrap_or(false);
if !exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Ticket not found" })),
)
.into_response();
}
let result = sqlx::query_as::<_, MessageRow>(
r#"
INSERT INTO support_ticket_messages (ticket_id, sender_id, body, is_internal)
VALUES ($1, $2, $3, false)
RETURNING id, ticket_id, sender_id, body, is_internal, created_at
"#,
)
.bind(id)
.bind(auth.user_id)
.bind(&body.body)
.fetch_one(&state.pool)
.await;
// Update ticket updated_at
let _ = sqlx::query(
"UPDATE support_tickets SET updated_at = NOW(), status = CASE WHEN status = 'waiting_for_user' THEN 'in_progress' ELSE status END WHERE id = $1",
)
.bind(id)
.execute(&state.pool)
.await;
match result {
Ok(m) => (
StatusCode::CREATED,
Json(MessageDto {
id: m.id,
ticket_id: m.ticket_id,
sender_id: m.sender_id,
body: m.body,
is_internal: m.is_internal,
created_at: m.created_at.to_rfc3339(),
}),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to add message to ticket {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to add message" })),
)
.into_response()
}
}
}
// ── Admin: list all cases ─────────────────────────────────────────────────────
#[derive(Deserialize)]
struct AdminListQuery {
status: Option<String>,
priority: Option<String>,
ticket_type: Option<String>,
page: Option<i64>,
limit: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct AdminTicketRow {
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>,
user_name: Option<String>,
user_email: String,
}
async fn admin_list_cases(
_auth: AuthUser,
State(state): State<AppState>,
Query(params): Query<AdminListQuery>,
) -> impl IntoResponse {
let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(50).clamp(1, 200);
let offset = (page - 1) * limit;
let status_filter = params.status.as_deref().unwrap_or("").to_string();
let priority_filter = params.priority.as_deref().unwrap_or("").to_string();
let type_filter = params.ticket_type.as_deref().unwrap_or("").to_string();
let rows = sqlx::query_as::<_, AdminTicketRow>(
r#"
SELECT
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
u.name AS user_name, u.email AS user_email
FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id
WHERE ($1 = '' OR t.status = $1)
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#,
)
.bind(&status_filter)
.bind(&priority_filter)
.bind(&type_filter)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
let total: i64 = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM support_tickets")
.fetch_one(&state.pool)
.await
.unwrap_or(0);
match rows {
Ok(rows) => {
let cases: Vec<_> = rows
.into_iter()
.map(|r| {
// Use user info if available, fall back to requester fields
let requester_name = r.requester_name.or(r.user_name);
let requester_email = r.requester_email.or(Some(r.user_email));
serde_json::json!({
"id": r.id,
"title": r.subject,
"description": r.description,
"type": r.category,
"priority": r.priority,
"status": r.status,
"requesterName": requester_name,
"requesterEmail": requester_email,
"assignedTo": r.assigned_to,
"createdAt": r.created_at.to_rfc3339(),
"updatedAt": r.updated_at.to_rfc3339(),
})
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "cases": cases, "total": total }))).into_response()
}
Err(e) => {
tracing::error!("Failed to list support cases: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch cases" })),
)
.into_response()
}
}
}
// ── Admin: create case ────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct AdminCreateCaseBody {
title: String,
description: Option<String>,
#[serde(rename = "type")]
ticket_type: Option<String>,
priority: Option<String>,
#[serde(rename = "requesterName")]
requester_name: Option<String>,
#[serde(rename = "requesterEmail")]
requester_email: Option<String>,
}
async fn admin_create_case(
_auth: AuthUser,
State(state): State<AppState>,
Json(body): Json<AdminCreateCaseBody>,
) -> impl IntoResponse {
let category = body.ticket_type.unwrap_or_else(|| "customer_query".to_string());
let priority = body.priority.unwrap_or_else(|| "medium".to_string());
let result = sqlx::query_as::<_, TicketRow>(
r#"
INSERT INTO support_tickets
(subject, description, category, priority, status,
requester_name, requester_email)
VALUES ($1, $2, $3, $4, 'new', $5, $6)
RETURNING id, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
.bind(&body.title)
.bind(&body.description)
.bind(&category)
.bind(&priority)
.bind(&body.requester_name)
.bind(&body.requester_email)
.fetch_one(&state.pool)
.await;
match result {
Ok(r) => (
StatusCode::CREATED,
Json(serde_json::json!({
"id": r.id,
"title": r.subject,
"description": r.description,
"type": r.category,
"priority": r.priority,
"status": r.status,
"requesterName": r.requester_name,
"requesterEmail": r.requester_email,
"createdAt": r.created_at.to_rfc3339(),
"updatedAt": r.updated_at.to_rfc3339(),
})),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to create support case: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to create case" })),
)
.into_response()
}
}
}
// ── Admin: get case with messages ─────────────────────────────────────────────
async fn admin_get_case(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let ticket = sqlx::query_as::<_, AdminTicketRow>(
r#"
SELECT
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
u.name AS user_name, u.email AS user_email
FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = $1
"#,
)
.bind(id)
.fetch_optional(&state.pool)
.await;
match ticket {
Ok(Some(t)) => {
let messages = sqlx::query_as::<_, MessageRow>(
r#"
SELECT id, ticket_id, sender_id, body, is_internal, created_at
FROM support_ticket_messages
WHERE ticket_id = $1
ORDER BY created_at ASC
"#,
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let msgs: Vec<_> = messages
.into_iter()
.map(|m| serde_json::json!({
"id": m.id,
"ticketId": m.ticket_id,
"senderId": m.sender_id,
"body": m.body,
"isInternal": m.is_internal,
"createdAt": m.created_at.to_rfc3339(),
}))
.collect();
let requester_name = t.requester_name.or(t.user_name);
let requester_email = t.requester_email.or(Some(t.user_email));
(StatusCode::OK, Json(serde_json::json!({
"ticket": {
"id": t.id,
"title": t.subject,
"description": t.description,
"type": t.category,
"priority": t.priority,
"status": t.status,
"requesterName": requester_name,
"requesterEmail": requester_email,
"assignedTo": t.assigned_to,
"createdAt": t.created_at.to_rfc3339(),
"updatedAt": t.updated_at.to_rfc3339(),
},
"messages": msgs,
}))).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Case not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to fetch support case {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch case" })),
)
.into_response()
}
}
}
// ── Admin: update case status/priority ────────────────────────────────────────
#[derive(Deserialize)]
struct UpdateCaseBody {
status: Option<String>,
priority: Option<String>,
assigned_to: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
struct UpdatedTicketRow {
id: Uuid,
subject: 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>,
}
async fn admin_update_case(
_auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateCaseBody>,
) -> impl IntoResponse {
let result = sqlx::query_as::<_, UpdatedTicketRow>(
r#"
UPDATE support_tickets SET
status = COALESCE($2, status),
priority = COALESCE($3, priority),
assigned_to = COALESCE($4, assigned_to),
resolved_at = CASE WHEN $2 = 'resolved' AND resolved_at IS NULL THEN NOW() ELSE resolved_at END,
updated_at = NOW()
WHERE id = $1
RETURNING id, subject, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
.bind(id)
.bind(&body.status)
.bind(&body.priority)
.bind(body.assigned_to)
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some(r)) => {
// Send email notification if ticket was resolved
if body.status.as_deref() == Some("resolved") {
if let Some(user_email) = &r.requester_email {
let user_name = r.requester_name.clone().unwrap_or_default();
let _ = state.mail.send_support_ticket_resolved_email(
user_email,
&user_name,
&r.subject
).await;
}
}
(
StatusCode::OK,
Json(serde_json::json!({
"id": r.id,
"title": r.subject,
"type": r.category,
"priority": r.priority,
"status": r.status,
"requesterName": r.requester_name,
"requesterEmail": r.requester_email,
"assignedTo": r.assigned_to,
"createdAt": r.created_at.to_rfc3339(),
"updatedAt": r.updated_at.to_rfc3339(),
})),
)
.into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Case not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update support case {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to update case" })),
)
.into_response()
}
}
}
// ── Admin: add message (supports internal notes) ──────────────────────────────
#[derive(Deserialize)]
struct AdminAddMessageBody {
body: String,
is_internal: Option<bool>,
}
async fn admin_add_message(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<AdminAddMessageBody>,
) -> impl IntoResponse {
let is_internal = body.is_internal.unwrap_or(false);
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM support_tickets WHERE id = $1)",
)
.bind(id)
.fetch_one(&state.pool)
.await
.unwrap_or(false);
if !exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Case not found" })),
)
.into_response();
}
let result = sqlx::query_as::<_, MessageRow>(
r#"
INSERT INTO support_ticket_messages (ticket_id, sender_id, body, is_internal)
VALUES ($1, $2, $3, $4)
RETURNING id, ticket_id, sender_id, body, is_internal, created_at
"#,
)
.bind(id)
.bind(auth.user_id)
.bind(&body.body)
.bind(is_internal)
.fetch_one(&state.pool)
.await;
// Move to waiting_for_user if this is a non-internal reply
if !is_internal {
let _ = sqlx::query(
"UPDATE support_tickets SET updated_at = NOW(), status = CASE WHEN status = 'new' OR status = 'in_progress' THEN 'waiting_for_user' ELSE status END WHERE id = $1",
)
.bind(id)
.execute(&state.pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE support_tickets SET updated_at = NOW() WHERE id = $1",
)
.bind(id)
.execute(&state.pool)
.await;
}
match result {
Ok(m) => {
// Send email notification to user if this is a non-internal reply
if !is_internal {
if let Ok(ticket) = sqlx::query_as::<_, TicketRow>(
"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_one(&state.pool)
.await
{
if let Some(user_email) = ticket.requester_email {
// Try to get user name from user table
let user_name = if let Ok(user) = db::models::user::UserRepository::get_by_email(&state.pool, &user_email).await {
user.name.unwrap_or_default()
} else {
ticket.requester_name.unwrap_or_default()
};
let _ = state.mail.send_support_ticket_replied_email(
&user_email,
&user_name,
&ticket.subject,
&body.body
).await;
}
}
}
(
StatusCode::CREATED,
Json(serde_json::json!({
"id": m.id,
"ticketId": m.ticket_id,
"senderId": m.sender_id,
"body": m.body,
"isInternal": m.is_internal,
"createdAt": m.created_at.to_rfc3339(),
})),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to add message to case {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to add message" })),
)
.into_response()
}
}
}