fix: convert all remaining SQLx macros in handlers to runtime API
This commit is contained in:
parent
83c62a1c5e
commit
3e557e54e8
20 changed files with 853 additions and 466 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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((
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")))?;
|
||||
|
|
|
|||
|
|
@ -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(_)) => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")))?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue