fix: convert all remaining SQLx macros in handlers to runtime API

This commit is contained in:
Ashwin Kumar 2026-04-09 08:11:26 +02:00
parent 83c62a1c5e
commit 3e557e54e8
20 changed files with 853 additions and 466 deletions

View file

@ -137,8 +137,7 @@ async fn list_companies(
State(state): State<AppState>,
Query(_q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let companies = sqlx::query_as!(
CompanyProfile,
let companies = sqlx::query_as::<_, CompanyProfile>(
r#"
SELECT id, user_id, company_name, registration_number, industry, website_url,
employee_count, business_type, gst_number, contact_name, contact_email,
@ -148,7 +147,7 @@ async fn list_companies(
FROM company_profiles
ORDER BY created_at DESC
LIMIT 100
"#
"#,
)
.fetch_all(&state.pool)
.await
@ -163,8 +162,7 @@ async fn get_company(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let company = sqlx::query_as!(
CompanyProfile,
let company = sqlx::query_as::<_, CompanyProfile>(
r#"
SELECT id, user_id, company_name, registration_number, industry, website_url,
employee_count, business_type, gst_number, contact_name, contact_email,
@ -173,8 +171,8 @@ async fn get_company(
purchased_contact_views, created_at, updated_at
FROM company_profiles WHERE id = $1
"#,
id
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -190,7 +188,8 @@ async fn approve_company(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
sqlx::query!("UPDATE company_profiles SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1", id)
sqlx::query("UPDATE company_profiles SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -202,7 +201,8 @@ async fn reject_company(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
sqlx::query!("UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE id = $1", id)
sqlx::query("UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -214,7 +214,8 @@ async fn suspend_company(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
sqlx::query!("UPDATE company_profiles SET status = 'SUSPENDED', updated_at = NOW() WHERE id = $1", id)
sqlx::query("UPDATE company_profiles SET status = 'SUSPENDED', updated_at = NOW() WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -226,8 +227,7 @@ async fn list_jobs(
State(state): State<AppState>,
Query(_q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let jobs = sqlx::query_as!(
Job,
let jobs = sqlx::query_as::<_, Job>(
r#"
SELECT id, company_id, title, category, description, location, job_type,
salary_min, salary_max, experience_years, skills, status, rejection_reason,
@ -235,7 +235,7 @@ async fn list_jobs(
FROM jobs
ORDER BY created_at DESC
LIMIT 100
"#
"#,
)
.fetch_all(&state.pool)
.await
@ -250,15 +250,14 @@ async fn list_applications(
State(state): State<AppState>,
Query(_q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let applications = sqlx::query_as!(
Application,
let applications = sqlx::query_as::<_, Application>(
r#"
SELECT id, job_id, job_seeker_id, cover_letter, resume_url, status,
applied_at, updated_at, contact_viewed
FROM applications
ORDER BY applied_at DESC
LIMIT 100
"#
"#,
)
.fetch_all(&state.pool)
.await

View file

@ -162,10 +162,10 @@ async fn create_job(
}
// Deduct ONE purchased slot
let deduct_result = sqlx::query!(
let deduct_result = sqlx::query(
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1",
company.id
)
.bind(company.id)
.execute(&state.pool)
.await;

View file

@ -171,8 +171,7 @@ async fn browse_jobs(
let limit = q.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let jobs = sqlx::query_as!(
db::models::job::Job,
let jobs = sqlx::query_as::<_, db::models::job::Job>(
r#"
SELECT * FROM jobs
WHERE status = 'LIVE'
@ -182,12 +181,12 @@ async fn browse_jobs(
ORDER BY created_at DESC
LIMIT $4 OFFSET $5
"#,
q.location,
q.job_type,
q.search,
limit,
offset
)
.bind(q.location)
.bind(q.job_type)
.bind(q.search)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;

View file

@ -64,7 +64,7 @@ async fn list_activity_logs(
let from_dt = params.from;
let to_dt = params.to;
let total: i64 = sqlx::query_scalar!(
let total: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*) FROM activity_logs
WHERE ($1::uuid IS NULL OR actor_id = $1)
@ -73,15 +73,14 @@ async fn list_activity_logs(
AND ($4::timestamptz IS NULL OR created_at >= $4)
AND ($5::timestamptz IS NULL OR created_at <= $5)
"#,
actor_id,
if entity_type.is_empty() { None } else { Some(entity_type) },
if action.is_empty() { None } else { Some(action) },
from_dt,
to_dt,
)
.bind(actor_id)
.bind(if entity_type.is_empty() { None } else { Some(entity_type) })
.bind(if action.is_empty() { None } else { Some(action) })
.bind(from_dt)
.bind(to_dt)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let rows = sqlx::query_as::<_, ActivityLogDto>(

View file

@ -168,14 +168,12 @@ async fn update_user_status(
Path(id): Path<Uuid>,
Json(payload): Json<StatusPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
sqlx::query!(
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
payload.status,
id
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
sqlx::query("UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2")
.bind(&payload.status)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(StatusCode::OK)
}

View file

@ -211,19 +211,19 @@ async fn activate_profile_after_final_approval(
);
sqlx::query(&query).bind(user_id).execute(&state.pool).await?;
sqlx::query!(
sqlx::query(
"UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'",
user_id
)
.bind(user_id)
.execute(&state.pool)
.await?;
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query!(
sqlx::query(
"INSERT INTO user_roles (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()",
user_id,
role.id
)
.bind(user_id)
.bind(role.id)
.execute(&state.pool)
.await
.ok();

View file

@ -12,6 +12,7 @@ use axum::{
use db::models::user::{CreateUserPayload, UserRepository};
use serde::{Deserialize, Serialize};
use contracts::auth_middleware::AuthUser;
use uuid::Uuid;
use crate::AppState;
pub fn router() -> Router<AppState> {
@ -220,26 +221,24 @@ async fn register(
payload.profession.as_deref(),
);
for role_key in role_candidates {
let role = sqlx::query!(
"SELECT id FROM roles WHERE key = $1",
role_key
)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let role = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
.bind(&role_key)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
if let Some(role_row) = role {
let _ = sqlx::query!(
if let Some(role_id) = role {
let _ = sqlx::query(
r#"
INSERT INTO user_roles (user_id, role_id, status, approved_at)
VALUES ($1, $2, 'APPROVED', NOW())
ON CONFLICT (user_id, role_id)
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
"#,
user.id,
role_row.id
)
.bind(user.id)
.bind(role_id)
.execute(&state.pool)
.await;
break;

View file

@ -61,7 +61,8 @@ async fn list_runtime_configs(
let role_id = if let Some(id) = q.role_id {
Some(id)
} else if let Some(key) = q.role_key.clone() {
sqlx::query_scalar!("SELECT id FROM roles WHERE key = $1", key.to_uppercase())
sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
.bind(key.to_uppercase())
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
@ -69,16 +70,26 @@ async fn list_runtime_configs(
None
};
#[derive(sqlx::FromRow)]
struct RcRow {
id: Uuid,
role_id: Uuid,
config_json: serde_json::Value,
version: i32,
is_active: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
let items = if let Some(rid) = role_id {
sqlx::query!(
sqlx::query_as::<_, RcRow>(
r#"
SELECT id, role_id, config_json, version, is_active, updated_at
FROM runtime_configs
WHERE role_id = $1
ORDER BY version DESC
"#,
rid
)
.bind(rid)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
@ -93,14 +104,14 @@ async fn list_runtime_configs(
}))
.collect::<Vec<_>>()
} else {
sqlx::query!(
sqlx::query_as::<_, RcRow>(
r#"
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
FROM runtime_configs rc
JOIN roles r ON rc.role_id = r.id
WHERE r.audience = 'INTERNAL'
ORDER BY rc.updated_at DESC
"#
"#,
)
.fetch_all(&state.pool)
.await
@ -128,10 +139,19 @@ async fn get_runtime_config_by_id(
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let r = sqlx::query!(
#[derive(sqlx::FromRow)]
struct RcDetailRow {
id: Uuid,
role_id: Uuid,
config_json: serde_json::Value,
version: i32,
is_active: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
let r = sqlx::query_as::<_, RcDetailRow>(
"SELECT id, role_id, config_json, version, is_active, updated_at FROM runtime_configs WHERE id = $1",
id
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
@ -173,27 +193,24 @@ async fn activate_runtime_config(
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
// Fetch role_id for the target config
let role = sqlx::query!("SELECT role_id FROM runtime_configs WHERE id = $1", id)
let role_id: Uuid = sqlx::query_scalar::<_, Uuid>("SELECT role_id FROM runtime_configs WHERE id = $1")
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
// Disable existing active
sqlx::query!(
"UPDATE runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true",
role.role_id
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
sqlx::query("UPDATE runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true")
.bind(role_id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Activate target
sqlx::query!(
"UPDATE runtime_configs SET is_active = true WHERE id = $1",
id
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
sqlx::query("UPDATE runtime_configs SET is_active = true WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
get_runtime_config_by_id(auth, State(state), Path(id)).await
}
@ -205,7 +222,8 @@ async fn delete_runtime_config(
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let result = sqlx::query!("DELETE FROM runtime_configs WHERE id = $1", id)
let result = sqlx::query("DELETE FROM runtime_configs WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -220,7 +238,14 @@ async fn get_my_runtime_config(
) -> Result<impl IntoResponse, (StatusCode, String)> {
let role_key = auth.claims.active_role.clone().to_uppercase();
let role = sqlx::query!("SELECT id, key, audience FROM roles WHERE key = $1", role_key)
#[derive(sqlx::FromRow)]
struct RoleRow {
id: Uuid,
key: String,
audience: String,
}
let role = sqlx::query_as::<_, RoleRow>("SELECT id, key, audience FROM roles WHERE key = $1")
.bind(&role_key)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)))?
@ -270,10 +295,10 @@ async fn get_my_runtime_config(
}
if role.audience == "INTERNAL" {
let permission_keys: Vec<String> = sqlx::query_scalar!(
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
role.id
)
.bind(role.id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();

View file

@ -108,19 +108,85 @@ struct PatchDiscountBody {
is_active: Option<bool>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct CouponRow {
id: Uuid,
code: String,
title: Option<String>,
discount_type: String,
discount_value: i32,
min_order_amount: i32,
max_uses: Option<i32>,
uses_count: i32,
role_keys: Vec<String>,
applies_to: String,
is_active: bool,
valid_until: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(sqlx::FromRow)]
struct ExistingCouponRow {
code: String,
title: Option<String>,
discount_type: String,
discount_value: i32,
min_order_amount: i32,
max_uses: Option<i32>,
role_keys: Vec<String>,
is_active: bool,
}
#[derive(sqlx::FromRow)]
struct ValidateCouponRow {
id: Uuid,
code: String,
discount_type: String,
discount_value: i32,
min_order_amount: i32,
max_uses: Option<i32>,
role_keys: Vec<String>,
valid_until: Option<chrono::DateTime<chrono::Utc>>,
is_active: bool,
}
#[derive(sqlx::FromRow)]
struct DiscountRow {
id: Uuid,
title: String,
scope: String,
role_key: Option<String>,
package_id: Option<Uuid>,
discount_type: String,
discount_value: i32,
is_active: bool,
}
#[derive(sqlx::FromRow)]
struct ExistingDiscountRow {
title: String,
scope: String,
role_key: Option<String>,
package_id: Option<Uuid>,
discount_type: String,
discount_value: i32,
is_active: bool,
}
// ── Coupon handlers ───────────────────────────────────────────────────────────
async fn list_coupons(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, CouponRow>(
r#"
SELECT id, code, title, discount_type, discount_value, min_order_amount,
max_uses, uses_count, role_keys, applies_to, is_active, valid_until
FROM coupons
ORDER BY created_at DESC
"#
"#,
)
.fetch_all(&state.pool)
.await;
@ -165,7 +231,7 @@ async fn create_coupon(
let role_keys: Vec<String> = body.role_keys.unwrap_or_default();
let code = body.code.to_uppercase();
let row = sqlx::query!(
let row = sqlx::query_as::<_, CouponRow>(
r#"
INSERT INTO coupons (code, title, discount_type, discount_value, min_order_amount,
max_uses, role_keys, applies_to)
@ -173,15 +239,15 @@ async fn create_coupon(
RETURNING id, code, title, discount_type, discount_value, min_order_amount,
max_uses, uses_count, role_keys, applies_to, is_active, valid_until
"#,
code,
body.title,
discount_type,
value,
min_order,
body.max_uses,
&role_keys,
applies_to,
)
.bind(&code)
.bind(&body.title)
.bind(&discount_type)
.bind(value)
.bind(min_order)
.bind(body.max_uses)
.bind(&role_keys)
.bind(&applies_to)
.fetch_one(&state.pool)
.await;
@ -220,11 +286,10 @@ async fn update_coupon(
Path(id): Path<Uuid>,
Json(body): Json<PatchCouponBody>,
) -> impl IntoResponse {
// Build dynamic update using individual queries for simplicity
let existing = sqlx::query!(
let existing = sqlx::query_as::<_, ExistingCouponRow>(
"SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1",
id
)
.bind(id)
.fetch_optional(&state.pool)
.await;
@ -246,23 +311,23 @@ async fn update_coupon(
let role_keys = body.role_keys.unwrap_or(existing.role_keys);
let is_active = body.is_active.unwrap_or(existing.is_active);
let result = sqlx::query!(
let result = sqlx::query(
r#"
UPDATE coupons
SET code = $1, title = $2, discount_type = $3, discount_value = $4,
min_order_amount = $5, max_uses = $6, role_keys = $7, is_active = $8
WHERE id = $9
"#,
code,
title,
discount_type,
value,
min_order,
max_uses,
&role_keys,
is_active,
id,
)
.bind(code)
.bind(title)
.bind(discount_type)
.bind(value)
.bind(min_order)
.bind(max_uses)
.bind(&role_keys)
.bind(is_active)
.bind(id)
.execute(&state.pool)
.await;
@ -280,7 +345,8 @@ async fn delete_coupon(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query!("DELETE FROM coupons WHERE id = $1", id)
let result = sqlx::query("DELETE FROM coupons WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await;
@ -302,12 +368,12 @@ async fn list_discounts(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, DiscountRow>(
r#"
SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active
FROM discounts
ORDER BY created_at DESC
"#
"#,
)
.fetch_all(&state.pool)
.await;
@ -345,19 +411,19 @@ async fn create_discount(
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
let value = body.value.unwrap_or(0);
let row = sqlx::query!(
let row = sqlx::query_as::<_, DiscountRow>(
r#"
INSERT INTO discounts (title, scope, role_key, package_id, discount_type, discount_value)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, title, scope, role_key, package_id, discount_type, discount_value, is_active
"#,
body.title,
scope,
body.role_key,
body.package_id,
discount_type,
value,
)
.bind(&body.title)
.bind(&scope)
.bind(&body.role_key)
.bind(body.package_id)
.bind(&discount_type)
.bind(value)
.fetch_one(&state.pool)
.await;
@ -388,10 +454,10 @@ async fn update_discount(
Path(id): Path<Uuid>,
Json(body): Json<PatchDiscountBody>,
) -> impl IntoResponse {
let existing = sqlx::query!(
let existing = sqlx::query_as::<_, ExistingDiscountRow>(
"SELECT title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts WHERE id = $1",
id
)
.bind(id)
.fetch_optional(&state.pool)
.await;
@ -412,22 +478,22 @@ async fn update_discount(
let value = body.value.unwrap_or(existing.discount_value);
let is_active = body.is_active.unwrap_or(existing.is_active);
let result = sqlx::query!(
let result = sqlx::query(
r#"
UPDATE discounts
SET title = $1, scope = $2, role_key = $3, package_id = $4,
discount_type = $5, discount_value = $6, is_active = $7
WHERE id = $8
"#,
title,
scope,
role_key,
package_id,
discount_type,
value,
is_active,
id,
)
.bind(title)
.bind(scope)
.bind(role_key)
.bind(package_id)
.bind(discount_type)
.bind(value)
.bind(is_active)
.bind(id)
.execute(&state.pool)
.await;
@ -461,15 +527,15 @@ async fn validate_coupon(
let code = payload.coupon_code.trim().to_uppercase();
// Fetch coupon
let coupon = sqlx::query!(
let coupon = sqlx::query_as::<_, ValidateCouponRow>(
r#"
SELECT id, code, title, discount_type, discount_value, min_order_amount,
SELECT id, code, discount_type, discount_value, min_order_amount,
max_uses, role_keys, valid_until, is_active
FROM coupons
WHERE code = $1
"#,
code
)
.bind(&code)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -550,13 +616,12 @@ async fn validate_coupon(
// Check usage limit if set
if let Some(max_uses) = coupon.max_uses {
let count: i64 = sqlx::query_scalar!(
let count: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = $1",
coupon.id
)
.bind(coupon.id)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
if count >= max_uses as i64 {
return Ok((

View file

@ -15,29 +15,26 @@ pub fn router() -> Router<crate::AppState> {
}
async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetricsResponse> {
let total_users: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM users")
let total_users: i64 = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let active_companies: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'"
let active_companies: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'",
)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let open_leads: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'"
let open_leads: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'",
)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let pending_approvals: i64 = sqlx::query_scalar!(
let pending_approvals: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*) FROM (
SELECT id FROM company_profiles WHERE status = 'PENDING_APPROVAL'
@ -48,19 +45,17 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
UNION ALL
SELECT id FROM professionals WHERE status = 'PENDING_APPROVAL'
) sub
"#
"#,
)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let total_revenue: i64 = sqlx::query_scalar!(
"SELECT COALESCE(SUM(amount_inr), 0) FROM payments WHERE status = 'SUCCESS'"
let total_revenue: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(SUM(amount_inr), 0) FROM payments WHERE status = 'SUCCESS'",
)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let kpis = vec![
@ -72,7 +67,13 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
];
// User registrations per day (last 7 days)
let trend_rows = sqlx::query!(
#[derive(sqlx::FromRow)]
struct TrendRow {
day_name: Option<String>,
count: Option<i64>,
}
let trend_rows = sqlx::query_as::<_, TrendRow>(
r#"
SELECT TO_CHAR(DATE_TRUNC('day', created_at), 'Dy') AS day_name,
COUNT(*) AS count
@ -80,7 +81,7 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', created_at), day_name
ORDER BY DATE_TRUNC('day', created_at)
"#
"#,
)
.fetch_all(&state.pool)
.await
@ -92,7 +93,13 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
.collect();
// Revenue per week (last 4 weeks)
let rev_rows = sqlx::query!(
#[derive(sqlx::FromRow)]
struct RevRow {
week_name: Option<String>,
total: Option<i64>,
}
let rev_rows = sqlx::query_as::<_, RevRow>(
r#"
SELECT TO_CHAR(DATE_TRUNC('week', created_at), '"Week" W') AS week_name,
COALESCE(SUM(amount_inr), 0) AS total
@ -100,7 +107,7 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
WHERE status = 'SUCCESS' AND created_at >= NOW() - INTERVAL '28 days'
GROUP BY DATE_TRUNC('week', created_at), week_name
ORDER BY DATE_TRUNC('week', created_at)
"#
"#,
)
.fetch_all(&state.pool)
.await
@ -112,7 +119,16 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
.collect();
// Recent open leads
let recent_leads = sqlx::query!(
#[derive(sqlx::FromRow)]
struct LeadRow {
id: uuid::Uuid,
title: String,
status: String,
created_at: chrono::DateTime<chrono::Utc>,
requester_name: Option<String>,
}
let recent_leads = sqlx::query_as::<_, LeadRow>(
r#"
SELECT r.id, r.title, r.status, r.created_at,
u.full_name AS requester_name
@ -122,7 +138,7 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED')
ORDER BY r.created_at DESC
LIMIT 5
"#
"#,
)
.fetch_all(&state.pool)
.await

View file

@ -56,6 +56,17 @@ struct ListResponse {
per_page: i64,
}
#[derive(sqlx::FromRow)]
struct ExternalRoleListRow {
id: Uuid,
name: String,
code: String,
is_active: bool,
created_date: chrono::DateTime<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
config_json: Option<serde_json::Value>,
}
async fn list_external_roles(
auth: AuthUser,
State(state): State<AppState>,
@ -73,7 +84,7 @@ async fn list_external_roles(
let category = q.category.unwrap_or_default().to_lowercase();
// Join roles with active runtime_config for that role (optional) and count assigned user_roles
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, ExternalRoleListRow>(
r#"
SELECT
r.id,
@ -81,8 +92,8 @@ async fn list_external_roles(
r.key as code,
r.is_active,
r.created_at as created_date,
rc.updated_at as "updated_at?",
rc.config_json as "config_json?"
rc.updated_at as "updated_at",
rc.config_json as "config_json"
FROM roles r
LEFT JOIN runtime_configs rc
ON rc.role_id = r.id AND rc.is_active = true
@ -92,17 +103,17 @@ async fn list_external_roles(
ORDER BY r.created_at DESC
LIMIT $3 OFFSET $4
"#,
search,
status,
per_page,
offset
)
.bind(&search)
.bind(&status)
.bind(per_page)
.bind(offset)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Compute total with same filters
let total: i64 = sqlx::query_scalar!(
let total: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM roles r
@ -110,13 +121,12 @@ async fn list_external_roles(
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END))
"#,
search,
status
)
.bind(&search)
.bind(&status)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.unwrap_or(0);
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let mut roles: Vec<ExternalRoleRow> = Vec::with_capacity(rows.len());
for row in rows {
@ -147,13 +157,12 @@ async fn list_external_roles(
continue;
}
// Count assigned users from user_roles (approved)
let assigned_users: i64 = sqlx::query_scalar!(
let assigned_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_roles WHERE role_id = $1 AND status = 'APPROVED'",
row.id
)
.bind(row.id)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
roles.push(ExternalRoleRow {
@ -192,6 +201,18 @@ struct ExternalRoleDetail {
updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(sqlx::FromRow)]
struct ExternalRoleDetailRow {
id: Uuid,
name: String,
code: String,
audience: String,
is_active: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
config_json: Option<serde_json::Value>,
}
async fn get_external_role(
auth: AuthUser,
State(state): State<AppState>,
@ -200,15 +221,15 @@ async fn get_external_role(
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let row = sqlx::query!(
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
r#"
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at, rc.updated_at as "updated_at?", rc.config_json as "config_json?"
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at, rc.updated_at as updated_at, rc.config_json as config_json
FROM roles r
LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
"#,
id
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
@ -234,6 +255,21 @@ struct CreateExternalRolePayload {
runtime: JsonValue, // carries vertical/category/modules/permissions/assigned_user_types/requires/feature_limits/onboarding_schema_id
}
#[derive(sqlx::FromRow)]
struct InsertedRole {
id: Uuid,
key: String,
name: String,
audience: String,
is_active: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct InsertedRc {
updated_at: chrono::DateTime<chrono::Utc>,
}
async fn create_external_role(
auth: AuthUser,
State(state): State<AppState>,
@ -244,30 +280,30 @@ async fn create_external_role(
}
let is_active = payload.is_active.unwrap_or(true);
// Insert role
let role = sqlx::query!(
let role = sqlx::query_as::<_, InsertedRole>(
r#"
INSERT INTO roles (key, name, audience, is_active)
VALUES ($1, $2, 'EXTERNAL', $3)
RETURNING id, key, name, audience, is_active, created_at
"#,
payload.code.to_uppercase(),
payload.name,
is_active
)
.bind(payload.code.to_uppercase())
.bind(&payload.name)
.bind(is_active)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Create runtime config version 1
let rc = sqlx::query!(
let rc = sqlx::query_as::<_, InsertedRc>(
r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
VALUES ($1, $2, 1, true)
RETURNING updated_at
"#,
role.id,
payload.runtime
)
.bind(role.id)
.bind(&payload.runtime)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -305,35 +341,35 @@ async fn update_external_role(
}
// Update role basic fields
if payload.name.is_some() || payload.is_active.is_some() {
sqlx::query!(
sqlx::query(
r#"
UPDATE roles
SET name = COALESCE($1, name),
is_active = COALESCE($2, is_active)
WHERE id = $3 AND audience = 'EXTERNAL'
"#,
payload.name,
payload.is_active,
id
)
.bind(payload.name)
.bind(payload.is_active)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
// Create a new runtime config version if provided
if let Some(runtime) = payload.runtime {
sqlx::query!(
sqlx::query(
r#"
UPDATE runtime_configs
SET is_active = false
WHERE role_id = $1 AND is_active = true
"#,
id
)
.bind(id)
.execute(&state.pool)
.await
.ok();
sqlx::query!(
sqlx::query(
r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
VALUES (
@ -343,9 +379,9 @@ async fn update_external_role(
true
)
"#,
id,
runtime
)
.bind(id)
.bind(runtime)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -361,7 +397,8 @@ async fn delete_external_role(
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let result = sqlx::query!("DELETE FROM roles WHERE id = $1 AND audience = 'EXTERNAL'", id)
let result = sqlx::query("DELETE FROM roles WHERE id = $1 AND audience = 'EXTERNAL'")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

View file

@ -85,16 +85,86 @@ struct AdminArticleDto {
updated_at: String,
}
// ── FromRow structs ───────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct CategoryRow {
id: Uuid,
name: String,
slug: String,
description: Option<String>,
display_order: i32,
is_active: bool,
}
#[derive(sqlx::FromRow)]
struct CategoryWithCountRow {
id: Uuid,
name: String,
slug: String,
description: Option<String>,
display_order: i32,
is_active: bool,
article_count: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct PublicArticleRow {
id: Uuid,
title: String,
slug: String,
summary: Option<String>,
body: String,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
updated_at: chrono::DateTime<chrono::Utc>,
category_name: String,
category_slug: String,
}
#[derive(sqlx::FromRow)]
struct AdminArticleRow {
id: Uuid,
title: String,
slug: String,
summary: Option<String>,
body: String,
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
is_published: bool,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
category_name: String,
}
#[derive(sqlx::FromRow)]
struct InsertedArticleRow {
id: Uuid,
title: String,
slug: String,
summary: Option<String>,
body: String,
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
is_published: bool,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
// ── Public: categories ────────────────────────────────────────────────────────
async fn public_list_categories(State(state): State<AppState>) -> impl IntoResponse {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, CategoryRow>(
r#"
SELECT id, name, slug, description, display_order
SELECT id, name, slug, description, display_order, is_active
FROM kb_categories
WHERE is_active = true
ORDER BY display_order ASC, name ASC
"#
"#,
)
.fetch_all(&state.pool)
.await;
@ -146,10 +216,10 @@ async fn public_list_articles(
let offset = (page - 1) * limit;
let role_filter = params.role.as_deref().unwrap_or("").to_uppercase();
let cat_slug = params.category.as_deref().unwrap_or("");
let cat_slug = params.category.as_deref().unwrap_or("").to_string();
let q = params.q.as_deref().unwrap_or("").to_lowercase();
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, PublicArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
@ -168,12 +238,12 @@ async fn public_list_articles(
ORDER BY a.updated_at DESC
LIMIT $4 OFFSET $5
"#,
cat_slug,
role_filter,
q,
limit,
offset
)
.bind(&cat_slug)
.bind(&role_filter)
.bind(&q)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
@ -216,7 +286,7 @@ async fn public_get_article(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> impl IntoResponse {
let row = sqlx::query!(
let row = sqlx::query_as::<_, PublicArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
@ -226,8 +296,8 @@ async fn public_get_article(
JOIN kb_categories c ON c.id = a.category_id
WHERE a.slug = $1 AND a.is_published = true AND c.is_active = true
"#,
slug
)
.bind(&slug)
.fetch_optional(&state.pool)
.await;
@ -235,10 +305,10 @@ async fn public_get_article(
let pool = state.pool.clone();
let slug_clone = slug.clone();
tokio::spawn(async move {
let _ = sqlx::query!(
let _ = sqlx::query(
"UPDATE kb_articles SET views = views + 1 WHERE slug = $1",
slug_clone
)
.bind(slug_clone)
.execute(&pool)
.await;
});
@ -282,7 +352,7 @@ async fn admin_list_categories(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, CategoryWithCountRow>(
r#"
SELECT
c.id, c.name, c.slug, c.description, c.display_order, c.is_active,
@ -291,7 +361,7 @@ async fn admin_list_categories(
LEFT JOIN kb_articles a ON a.category_id = c.id
GROUP BY c.id
ORDER BY c.display_order ASC, c.name ASC
"#
"#,
)
.fetch_all(&state.pool)
.await;
@ -337,17 +407,17 @@ async fn admin_create_category(
Json(body): Json<CreateCategoryBody>,
) -> impl IntoResponse {
let order = body.display_order.unwrap_or(0);
let result = sqlx::query!(
let result = sqlx::query_as::<_, CategoryRow>(
r#"
INSERT INTO kb_categories (name, slug, description, display_order)
VALUES ($1, $2, $3, $4)
RETURNING id, name, slug, description, display_order, is_active
"#,
body.name,
body.slug,
body.description,
order
)
.bind(&body.name)
.bind(&body.slug)
.bind(&body.description)
.bind(order)
.fetch_one(&state.pool)
.await;
@ -396,7 +466,7 @@ async fn admin_update_category(
Path(id): Path<Uuid>,
Json(body): Json<UpdateCategoryBody>,
) -> impl IntoResponse {
let result = sqlx::query!(
let result = sqlx::query_as::<_, CategoryRow>(
r#"
UPDATE kb_categories SET
name = COALESCE($2, name),
@ -407,13 +477,13 @@ async fn admin_update_category(
WHERE id = $1
RETURNING id, name, slug, description, display_order, is_active
"#,
id,
body.name,
body.slug,
body.description,
body.display_order,
body.is_active,
)
.bind(id)
.bind(&body.name)
.bind(&body.slug)
.bind(&body.description)
.bind(body.display_order)
.bind(body.is_active)
.fetch_optional(&state.pool)
.await;
@ -452,10 +522,13 @@ async fn admin_delete_category(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query!(
#[derive(sqlx::FromRow)]
struct IdRow { id: Uuid }
let result = sqlx::query_as::<_, IdRow>(
"DELETE FROM kb_categories WHERE id = $1 RETURNING id",
id
)
.bind(id)
.fetch_optional(&state.pool)
.await;
@ -498,7 +571,7 @@ async fn admin_list_articles(
let q = params.q.as_deref().unwrap_or("").to_lowercase();
let published_filter: Option<bool> = params.status.as_deref().map(|s| s == "PUBLISHED");
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, AdminArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
@ -512,10 +585,10 @@ async fn admin_list_articles(
ORDER BY a.updated_at DESC
LIMIT 200
"#,
q,
params.category_id as Option<Uuid>,
published_filter,
)
.bind(&q)
.bind(params.category_id)
.bind(published_filter)
.fetch_all(&state.pool)
.await;
@ -577,7 +650,7 @@ async fn admin_create_article(
let roles: Vec<String> = body.target_roles.unwrap_or_default();
let tags: Vec<String> = body.tags.unwrap_or_default();
let result = sqlx::query!(
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
INSERT INTO kb_articles
(title, slug, summary, body, category_id, is_published, target_roles, tags, created_by)
@ -585,16 +658,16 @@ async fn admin_create_article(
RETURNING id, title, slug, summary, body, category_id, is_published,
target_roles, tags, views, created_at, updated_at
"#,
body.title,
slug,
body.summary,
body.content,
body.category_id as Option<Uuid>,
is_published,
&roles,
&tags,
auth.user_id,
)
.bind(&body.title)
.bind(&slug)
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(is_published)
.bind(&roles)
.bind(&tags)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await;
@ -644,7 +717,7 @@ async fn admin_get_article(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let row = sqlx::query!(
let row = sqlx::query_as::<_, AdminArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.category_id,
@ -655,8 +728,8 @@ async fn admin_get_article(
JOIN kb_categories c ON c.id = a.category_id
WHERE a.id = $1
"#,
id
)
.bind(id)
.fetch_optional(&state.pool)
.await;
@ -715,7 +788,7 @@ async fn admin_update_article(
Json(body): Json<UpdateArticleBody>,
) -> impl IntoResponse {
let is_published: Option<bool> = body.status.as_deref().map(|s| s == "PUBLISHED");
let result = sqlx::query!(
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
UPDATE kb_articles SET
title = COALESCE($2, title),
@ -731,16 +804,16 @@ async fn admin_update_article(
RETURNING id, title, slug, summary, body, category_id,
target_roles, tags, is_published, views, created_at, updated_at
"#,
id,
body.title,
body.slug,
body.summary,
body.content,
body.category_id as Option<Uuid>,
is_published,
body.target_roles.as_deref(),
body.tags.as_deref(),
)
.bind(id)
.bind(&body.title)
.bind(&body.slug)
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(is_published)
.bind(body.target_roles.as_deref())
.bind(body.tags.as_deref())
.fetch_optional(&state.pool)
.await;
@ -785,10 +858,15 @@ async fn admin_delete_article(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result =
sqlx::query!("DELETE FROM kb_articles WHERE id = $1 RETURNING id", id)
.fetch_optional(&state.pool)
.await;
#[derive(sqlx::FromRow)]
struct IdRow { id: Uuid }
let result = sqlx::query_as::<_, IdRow>(
"DELETE FROM kb_articles WHERE id = $1 RETURNING id",
)
.bind(id)
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some(_)) => (

View file

@ -54,6 +54,19 @@ pub struct Pagination {
pub total_pages: i64,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct NotificationRow {
id: Uuid,
title: String,
body: Option<String>,
notification_type: Option<String>,
reference_id: Option<Uuid>,
is_read: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn list_notifications(
@ -65,7 +78,7 @@ async fn list_notifications(
let limit = params.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, NotificationRow>(
r#"
SELECT id, title, body, type AS notification_type,
reference_id, is_read, created_at
@ -74,20 +87,19 @@ async fn list_notifications(
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
auth.user_id,
limit,
offset
)
.bind(auth.user_id)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
let total: i64 = sqlx::query_scalar!(
let total: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM notifications WHERE user_id = $1",
auth.user_id
)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
match rows {
@ -131,13 +143,12 @@ async fn unread_count(
auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let count = sqlx::query_scalar!(
let count: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND is_read = false",
auth.user_id
)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
(StatusCode::OK, Json(serde_json::json!({ "unread_count": count })))
@ -148,11 +159,11 @@ async fn mark_read(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query!(
let result = sqlx::query(
"UPDATE notifications SET is_read = true WHERE id = $1 AND user_id = $2",
id,
auth.user_id
)
.bind(id)
.bind(auth.user_id)
.execute(&state.pool)
.await;
@ -182,10 +193,10 @@ async fn mark_all_read(
auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let result = sqlx::query!(
let result = sqlx::query(
"UPDATE notifications SET is_read = true WHERE user_id = $1 AND is_read = false",
auth.user_id
)
.bind(auth.user_id)
.execute(&state.pool)
.await;

View file

@ -83,6 +83,31 @@ struct DateRangeQuery {
to: Option<String>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct PackageRow {
id: Uuid,
name: String,
role_key: String,
package_type: String,
tracecoins_amount: i32,
price_inr: i32,
description: Option<String>,
is_active: bool,
}
#[derive(sqlx::FromRow)]
struct ExistingPackageRow {
name: String,
role_key: String,
package_type: String,
tracecoins_amount: i32,
price_inr: i32,
description: Option<String>,
is_active: bool,
}
// ── Package handlers ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
@ -94,7 +119,7 @@ async fn public_list_packages(
State(state): State<AppState>,
Query(params): Query<PackageQuery>,
) -> impl IntoResponse {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, PackageRow>(
r#"
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
FROM pricing_packages
@ -102,8 +127,8 @@ async fn public_list_packages(
AND ($1::text IS NULL OR role_key = $1 OR role_key = 'ALL')
ORDER BY role_key, price_inr
"#,
params.role as Option<String>
)
.bind(params.role)
.fetch_all(&state.pool)
.await;
@ -137,12 +162,12 @@ async fn list_packages(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, PackageRow>(
r#"
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
FROM pricing_packages
ORDER BY role_key, price_inr
"#
"#,
)
.fetch_all(&state.pool)
.await;
@ -184,19 +209,19 @@ async fn create_package(
// Accept role (admin UI) or role_key
let role_key = body.role_key.or(body.role).unwrap_or_else(|| "ALL".to_string());
let row = sqlx::query!(
let row = sqlx::query_as::<_, PackageRow>(
r#"
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
"#,
body.name,
role_key,
package_type,
tracecoins_amount,
body.price_inr,
body.description,
)
.bind(&body.name)
.bind(&role_key)
.bind(&package_type)
.bind(tracecoins_amount)
.bind(body.price_inr)
.bind(&body.description)
.fetch_one(&state.pool)
.await;
@ -229,10 +254,10 @@ async fn update_package(
Path(id): Path<Uuid>,
Json(body): Json<PatchPackageBody>,
) -> impl IntoResponse {
let existing = sqlx::query!(
let existing = sqlx::query_as::<_, ExistingPackageRow>(
"SELECT name, role_key, package_type, tracecoins_amount, price_inr, description, is_active FROM pricing_packages WHERE id = $1",
id
)
.bind(id)
.fetch_optional(&state.pool)
.await;
@ -253,22 +278,22 @@ async fn update_package(
let description = body.description.or(existing.description);
let is_active = body.is_active.unwrap_or(existing.is_active);
let result = sqlx::query!(
let result = sqlx::query(
r#"
UPDATE pricing_packages
SET name = $1, role_key = $2, package_type = $3, tracecoins_amount = $4,
price_inr = $5, description = $6, is_active = $7
WHERE id = $8
"#,
name,
role_key,
package_type,
tracecoins_amount,
price_inr,
description,
is_active,
id,
)
.bind(name)
.bind(role_key)
.bind(package_type)
.bind(tracecoins_amount)
.bind(price_inr)
.bind(description)
.bind(is_active)
.bind(id)
.execute(&state.pool)
.await;
@ -286,7 +311,8 @@ async fn delete_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query!("UPDATE pricing_packages SET is_active = false WHERE id = $1", id)
let result = sqlx::query("UPDATE pricing_packages SET is_active = false WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await;
@ -325,35 +351,32 @@ async fn report_users(
}
};
let total_users: i64 = sqlx::query_scalar!(
let total_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2",
from_ts,
to_ts
)
.bind(from_ts)
.bind(to_ts)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let new_users: i64 = sqlx::query_scalar!(
let new_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2 AND created_at >= NOW() - INTERVAL '30 days'",
from_ts,
to_ts
)
.bind(from_ts)
.bind(to_ts)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
// Active = email-verified users registered in range
let active_users: i64 = sqlx::query_scalar!(
let active_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM users WHERE created_at >= $1 AND created_at <= $2 AND email_verified = true",
from_ts,
to_ts
)
.bind(from_ts)
.bind(to_ts)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
(StatusCode::OK, Json(serde_json::json!({
@ -386,34 +409,31 @@ async fn report_revenue(
}
};
let total_revenue: i64 = sqlx::query_scalar!(
let total_revenue: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(SUM(amount_inr), 0) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2",
from_ts,
to_ts
)
.bind(from_ts)
.bind(to_ts)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let total_orders: i64 = sqlx::query_scalar!(
let total_orders: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2",
from_ts,
to_ts
)
.bind(from_ts)
.bind(to_ts)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let total_tracecoins_sold: i64 = sqlx::query_scalar!(
let total_tracecoins_sold: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(SUM(tracecoins_credited), 0) FROM payments WHERE status = 'SUCCESS' AND created_at >= $1 AND created_at <= $2",
from_ts,
to_ts
)
.bind(from_ts)
.bind(to_ts)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
(StatusCode::OK, Json(serde_json::json!({

View file

@ -51,13 +51,30 @@ struct PatchReviewBody {
is_published: Option<bool>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ReviewRow {
id: Uuid,
subject_type: String,
subject_id: Option<String>,
reviewer_name: Option<String>,
reviewer_id: Option<Uuid>,
rating: i16,
title: Option<String>,
comment: Option<String>,
status: String,
is_published: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn admin_list_reviews(
_auth: AuthUser,
State(state): State<AppState>,
) -> impl IntoResponse {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, ReviewRow>(
r#"
SELECT
r.id,
@ -73,7 +90,7 @@ async fn admin_list_reviews(
r.created_at
FROM reviews r
ORDER BY r.created_at DESC
"#
"#,
)
.fetch_all(&state.pool)
.await;
@ -117,21 +134,21 @@ async fn admin_create_review(
let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string());
let status = "PUBLISHED".to_string();
let row = sqlx::query!(
let row = sqlx::query_as::<_, ReviewRow>(
r#"
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status, is_published)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
RETURNING id, subject_type, subject_id, reviewer_name, customer_id AS reviewer_id,
rating, title, comment, status, is_published, created_at
"#,
subject_type,
body.subject_id,
body.reviewer_name,
body.rating,
body.title,
body.comment,
status,
)
.bind(&subject_type)
.bind(&body.subject_id)
.bind(&body.reviewer_name)
.bind(body.rating)
.bind(&body.title)
.bind(&body.comment)
.bind(&status)
.fetch_one(&state.pool)
.await;
@ -178,12 +195,12 @@ async fn admin_update_review(
}
};
let result = sqlx::query!(
let result = sqlx::query(
"UPDATE reviews SET status = $1, is_published = $2, updated_at = NOW() WHERE id = $3",
status,
published,
id,
)
.bind(&status)
.bind(published)
.bind(id)
.execute(&state.pool)
.await;
@ -204,7 +221,8 @@ async fn admin_delete_review(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let result = sqlx::query!("DELETE FROM reviews WHERE id = $1", id)
let result = sqlx::query("DELETE FROM reviews WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await;

View file

@ -94,6 +94,64 @@ struct UpdateRolePayload {
permission_keys: Option<Vec<String>>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct RoleListRow {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
department_name: Option<String>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
created_at: chrono::DateTime<chrono::Utc>,
users_assigned: Option<i64>,
permissions_count: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct RoleDetailRow {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
department_name: Option<String>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct InsertedRoleRow {
id: Uuid,
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct CurrentRoleRow {
name: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
async fn list_roles(
@ -104,9 +162,9 @@ async fn list_roles(
let per_page = params.per_page.unwrap_or(20).min(100);
let offset = (page - 1) * per_page;
let search = params.q.as_deref().unwrap_or("").to_lowercase();
let audience = params.audience.as_deref().unwrap_or("");
let audience = params.audience.as_deref().unwrap_or("").to_string();
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, RoleListRow>(
r#"
SELECT
r.id,
@ -115,7 +173,7 @@ async fn list_roles(
r.audience,
r.description,
r.department_id,
d.name AS "department_name?",
d.name AS department_name,
r.is_active,
r.can_approve_requests,
r.can_manage_system_settings,
@ -132,28 +190,27 @@ async fn list_roles(
ORDER BY r.created_at DESC
LIMIT $3 OFFSET $4
"#,
audience,
search,
per_page,
offset
)
.bind(&audience)
.bind(&search)
.bind(per_page)
.bind(offset)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let total: i64 = sqlx::query_scalar!(
let total: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*) FROM roles r
WHERE ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%')
"#,
audience,
search
)
.bind(&audience)
.bind(&search)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.unwrap_or(0);
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let roles = rows
.into_iter()
@ -181,28 +238,28 @@ async fn get_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let row = sqlx::query!(
let row = sqlx::query_as::<_, RoleDetailRow>(
r#"
SELECT
r.id, r.key, r.name, r.audience, r.description,
r.department_id, d.name AS "department_name?",
r.department_id, d.name AS department_name,
r.is_active, r.can_approve_requests, r.can_manage_system_settings,
r.created_at
FROM roles r
LEFT JOIN departments d ON d.id = r.department_id
WHERE r.id = $1
"#,
id
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
let permission_keys: Vec<String> = sqlx::query_scalar!(
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
id
)
.bind(id)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -231,21 +288,21 @@ async fn create_role(
let can_approve = payload.can_approve_requests.unwrap_or(false);
let can_manage = payload.can_manage_system_settings.unwrap_or(false);
let role = sqlx::query!(
let role = sqlx::query_as::<_, InsertedRoleRow>(
r#"
INSERT INTO roles (key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at
"#,
payload.key,
payload.name,
payload.audience,
payload.description,
payload.department_id,
is_active,
can_approve,
can_manage,
)
.bind(&payload.key)
.bind(&payload.name)
.bind(&payload.audience)
.bind(&payload.description)
.bind(payload.department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -253,21 +310,21 @@ async fn create_role(
// Insert permission keys
if let Some(keys) = &payload.permission_keys {
for key in keys {
sqlx::query!(
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
role.id,
key
)
.bind(role.id)
.bind(key)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
}
let permission_keys: Vec<String> = sqlx::query_scalar!(
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
role.id
)
.bind(role.id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
@ -297,10 +354,10 @@ async fn update_role(
Json(payload): Json<UpdateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// Fetch current values first
let current = sqlx::query!(
let current = sqlx::query_as::<_, CurrentRoleRow>(
"SELECT name, description, department_id, is_active, can_approve_requests, can_manage_system_settings FROM roles WHERE id = $1",
id
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
@ -313,7 +370,7 @@ async fn update_role(
let can_approve = payload.can_approve_requests.unwrap_or(current.can_approve_requests);
let can_manage = payload.can_manage_system_settings.unwrap_or(current.can_manage_system_settings);
sqlx::query!(
sqlx::query(
r#"
UPDATE roles SET
name = $1,
@ -324,31 +381,32 @@ async fn update_role(
can_manage_system_settings = $6
WHERE id = $7
"#,
name as String,
description as Option<String>,
department_id as Option<Uuid>,
is_active as bool,
can_approve as bool,
can_manage as bool,
id as Uuid
)
.bind(name)
.bind(description)
.bind(department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Replace permissions if provided
if let Some(keys) = &payload.permission_keys {
sqlx::query!("DELETE FROM role_permissions WHERE role_id = $1", id)
sqlx::query("DELETE FROM role_permissions WHERE role_id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
for key in keys {
sqlx::query!(
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
id,
key
)
.bind(id)
.bind(key)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -363,7 +421,8 @@ async fn delete_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let result = sqlx::query!("DELETE FROM roles WHERE id = $1", id)
let result = sqlx::query("DELETE FROM roles WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

View file

@ -228,21 +228,21 @@ async fn create_delete_account_request(
user.full_name.as_deref().unwrap_or_default(),
)
.await;
let _ = sqlx::query!(
let _ = sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type)
VALUES ($1, $2, $3, $4)"#,
auth.user_id,
"Account Deleted",
format!(
"Your account was deleted{}.",
payload
.reason
.as_deref()
.map(|r| format!(" Reason: {r}"))
.unwrap_or_default()
),
"ACCOUNT"
)
.bind(auth.user_id)
.bind("Account Deleted")
.bind(format!(
"Your account was deleted{}.",
payload
.reason
.as_deref()
.map(|r| format!(" Reason: {r}"))
.unwrap_or_default()
))
.bind("ACCOUNT")
.execute(&state.pool)
.await;

View file

@ -65,6 +65,33 @@ struct MessageDto {
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)]
@ -83,19 +110,19 @@ async fn user_create_ticket(
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!(
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
"#,
auth.user_id,
body.subject,
body.description,
category,
priority,
)
.bind(auth.user_id)
.bind(&body.subject)
.bind(&body.description)
.bind(&category)
.bind(&priority)
.fetch_one(&state.pool)
.await;
@ -145,9 +172,9 @@ async fn user_list_tickets(
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("");
let status_filter = params.status.as_deref().unwrap_or("").to_string();
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, TicketRow>(
r#"
SELECT id, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
@ -157,11 +184,11 @@ async fn user_list_tickets(
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4
"#,
auth.user_id,
status_filter,
limit,
offset,
)
.bind(auth.user_id)
.bind(&status_filter)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
@ -203,30 +230,30 @@ async fn user_get_ticket(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let ticket = sqlx::query!(
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
"#,
id,
auth.user_id,
)
.bind(id)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await;
match ticket {
Ok(Some(t)) => {
let messages = sqlx::query!(
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
"#,
id,
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
@ -289,14 +316,13 @@ async fn user_add_message(
Json(body): Json<AddMessageBody>,
) -> impl IntoResponse {
// Verify ticket belongs to user
let exists = sqlx::query_scalar!(
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM support_tickets WHERE id = $1 AND user_id = $2)",
id,
auth.user_id,
)
.bind(id)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(false))
.unwrap_or(false);
if !exists {
@ -307,24 +333,24 @@ async fn user_add_message(
.into_response();
}
let result = sqlx::query!(
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
"#,
id,
auth.user_id,
body.body,
)
.bind(id)
.bind(auth.user_id)
.bind(&body.body)
.fetch_one(&state.pool)
.await;
// Update ticket updated_at
let _ = sqlx::query!(
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",
id
)
.bind(id)
.execute(&state.pool)
.await;
@ -363,6 +389,23 @@ struct AdminListQuery {
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>,
@ -371,11 +414,11 @@ async fn admin_list_cases(
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("");
let priority_filter = params.priority.as_deref().unwrap_or("");
let type_filter = params.ticket_type.as_deref().unwrap_or("");
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!(
let rows = sqlx::query_as::<_, AdminTicketRow>(
r#"
SELECT
t.id, t.subject, t.description, t.category, t.priority, t.status,
@ -390,19 +433,18 @@ async fn admin_list_cases(
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#,
status_filter,
priority_filter,
type_filter,
limit,
offset,
)
.bind(&status_filter)
.bind(&priority_filter)
.bind(&type_filter)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
let total: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM support_tickets")
let total: i64 = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM support_tickets")
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
match rows {
@ -464,7 +506,7 @@ async fn admin_create_case(
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!(
let result = sqlx::query_as::<_, TicketRow>(
r#"
INSERT INTO support_tickets
(subject, description, category, priority, status,
@ -473,13 +515,13 @@ async fn admin_create_case(
RETURNING id, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
body.title,
body.description,
category,
priority,
body.requester_name,
body.requester_email,
)
.bind(&body.title)
.bind(&body.description)
.bind(&category)
.bind(&priority)
.bind(&body.requester_name)
.bind(&body.requester_email)
.fetch_one(&state.pool)
.await;
@ -518,7 +560,7 @@ async fn admin_get_case(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let ticket = sqlx::query!(
let ticket = sqlx::query_as::<_, AdminTicketRow>(
r#"
SELECT
t.id, t.subject, t.description, t.category, t.priority, t.status,
@ -529,22 +571,22 @@ async fn admin_get_case(
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = $1
"#,
id,
)
.bind(id)
.fetch_optional(&state.pool)
.await;
match ticket {
Ok(Some(t)) => {
let messages = sqlx::query!(
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
"#,
id,
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
@ -606,13 +648,27 @@ struct UpdateCaseBody {
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!(
let result = sqlx::query_as::<_, UpdatedTicketRow>(
r#"
UPDATE support_tickets SET
status = COALESCE($2, status),
@ -624,11 +680,11 @@ async fn admin_update_case(
RETURNING id, subject, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
id,
body.status,
body.priority,
body.assigned_to as Option<Uuid>,
)
.bind(id)
.bind(&body.status)
.bind(&body.priority)
.bind(body.assigned_to)
.fetch_optional(&state.pool)
.await;
@ -681,13 +737,12 @@ async fn admin_add_message(
) -> impl IntoResponse {
let is_internal = body.is_internal.unwrap_or(false);
let exists = sqlx::query_scalar!(
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM support_tickets WHERE id = $1)",
id,
)
.bind(id)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(false))
.unwrap_or(false);
if !exists {
@ -698,33 +753,33 @@ async fn admin_add_message(
.into_response();
}
let result = sqlx::query!(
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
"#,
id,
auth.user_id,
body.body,
is_internal,
)
.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!(
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",
id
)
.bind(id)
.execute(&state.pool)
.await;
} else {
let _ = sqlx::query!(
let _ = sqlx::query(
"UPDATE support_tickets SET updated_at = NOW() WHERE id = $1",
id
)
.bind(id)
.execute(&state.pool)
.await;
}

View file

@ -9,6 +9,7 @@ use axum::{
use contracts::auth_middleware::AuthUser;
use db::models::role::RoleRepository;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
@ -45,11 +46,19 @@ fn is_professional_role(role_key: &str) -> bool {
)
}
#[derive(sqlx::FromRow)]
struct UserRoleRow {
key: String,
name: String,
status: String,
approved_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn list_my_roles(
auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query!(
let rows = sqlx::query_as::<_, UserRoleRow>(
r#"
SELECT r.key, r.name, ur.status, ur.approved_at
FROM user_roles ur
@ -57,8 +66,8 @@ async fn list_my_roles(
WHERE ur.user_id = $1
ORDER BY ur.created_at ASC
"#,
auth.user_id
)
.bind(auth.user_id)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -90,16 +99,16 @@ async fn register_role(
.await
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?;
sqlx::query!(
sqlx::query(
r#"
INSERT INTO user_roles (user_id, role_id, status, approved_at)
VALUES ($1, $2, 'APPROVED', NOW())
ON CONFLICT (user_id, role_id)
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
"#,
auth.user_id,
role.id
)
.bind(auth.user_id)
.bind(role.id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

View file

@ -255,15 +255,15 @@ async fn request_documents(
{
Ok(v) => {
// Notify the user
sqlx::query!(
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
v.user_id,
"Action Required — Documents Needed",
format!("Please resubmit your documents: {}", payload.message),
"DOCUMENT_REQUEST",
v.id
)
.bind(v.user_id)
.bind("Action Required — Documents Needed")
.bind(format!("Please resubmit your documents: {}", payload.message))
.bind("DOCUMENT_REQUEST")
.bind(v.id)
.execute(&state.pool)
.await
.ok();
@ -297,15 +297,15 @@ async fn request_revision(
.await
{
Ok(v) => {
sqlx::query!(
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
v.user_id,
"Action Required — Revision Requested",
format!("Please revise your submission: {}", payload.message),
"REVISION_REQUEST",
v.id
)
.bind(v.user_id)
.bind("Action Required — Revision Requested")
.bind(format!("Please revise your submission: {}", payload.message))
.bind("REVISION_REQUEST")
.bind(v.id)
.execute(&state.pool)
.await
.ok();