diff --git a/apps/companies/src/handlers/admin.rs b/apps/companies/src/handlers/admin.rs index 126725a..69323d4 100644 --- a/apps/companies/src/handlers/admin.rs +++ b/apps/companies/src/handlers/admin.rs @@ -137,8 +137,7 @@ async fn list_companies( State(state): State, Query(_q): Query, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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, Query(_q): Query, ) -> Result { - 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, Query(_q): Query, ) -> Result { - 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 diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index bf5f478..956ebe8 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -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; diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 32e29be..bc528fd 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -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; diff --git a/apps/users/src/handlers/activity_logs.rs b/apps/users/src/handlers/activity_logs.rs index 3e0e946..8863218 100644 --- a/apps/users/src/handlers/activity_logs.rs +++ b/apps/users/src/handlers/activity_logs.rs @@ -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>( diff --git a/apps/users/src/handlers/admin.rs b/apps/users/src/handlers/admin.rs index 4b28b10..68676bc 100644 --- a/apps/users/src/handlers/admin.rs +++ b/apps/users/src/handlers/admin.rs @@ -168,14 +168,12 @@ async fn update_user_status( Path(id): Path, Json(payload): Json, ) -> Result { - 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) } diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index d1d3f06..e716223 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -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(); diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 7cee26e..58f7dbc 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -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 { @@ -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; diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index 6ff2b67..6171a04 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -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, + } + 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::>() } 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, + } + 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 { 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 = sqlx::query_scalar!( + let permission_keys: Vec = 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(); diff --git a/apps/users/src/handlers/coupons.rs b/apps/users/src/handlers/coupons.rs index c593d28..f700005 100644 --- a/apps/users/src/handlers/coupons.rs +++ b/apps/users/src/handlers/coupons.rs @@ -108,19 +108,85 @@ struct PatchDiscountBody { is_active: Option, } +// ── FromRow structs ────────────────────────────────────────────────────────── + +#[derive(sqlx::FromRow)] +struct CouponRow { + id: Uuid, + code: String, + title: Option, + discount_type: String, + discount_value: i32, + min_order_amount: i32, + max_uses: Option, + uses_count: i32, + role_keys: Vec, + applies_to: String, + is_active: bool, + valid_until: Option>, +} + +#[derive(sqlx::FromRow)] +struct ExistingCouponRow { + code: String, + title: Option, + discount_type: String, + discount_value: i32, + min_order_amount: i32, + max_uses: Option, + role_keys: Vec, + 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, + role_keys: Vec, + valid_until: Option>, + is_active: bool, +} + +#[derive(sqlx::FromRow)] +struct DiscountRow { + id: Uuid, + title: String, + scope: String, + role_key: Option, + package_id: Option, + discount_type: String, + discount_value: i32, + is_active: bool, +} + +#[derive(sqlx::FromRow)] +struct ExistingDiscountRow { + title: String, + scope: String, + role_key: Option, + package_id: Option, + discount_type: String, + discount_value: i32, + is_active: bool, +} + // ── Coupon handlers ─────────────────────────────────────────────────────────── async fn list_coupons( _auth: AuthUser, State(state): State, ) -> 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 = 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, Json(body): Json, ) -> 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, Path(id): Path, ) -> 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, ) -> 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, Json(body): Json, ) -> 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(( diff --git a/apps/users/src/handlers/dashboard.rs b/apps/users/src/handlers/dashboard.rs index 6348346..2343ee7 100644 --- a/apps/users/src/handlers/dashboard.rs +++ b/apps/users/src/handlers/dashboard.rs @@ -15,29 +15,26 @@ pub fn router() -> Router { } async fn get_metrics(State(state): State) -> Json { - 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) -> Json( + "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) -> Json, + count: Option, + } + + 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) -> Json= 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) -> Json, + total: Option, + } + + 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) -> Json= 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) -> Json, + requester_name: Option, + } + + 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) -> Json, + updated_at: Option>, + config_json: Option, +} + async fn list_external_roles( auth: AuthUser, State(state): State, @@ -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 = 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>, } +#[derive(sqlx::FromRow)] +struct ExternalRoleDetailRow { + id: Uuid, + name: String, + code: String, + audience: String, + is_active: bool, + created_at: chrono::DateTime, + updated_at: Option>, + config_json: Option, +} + async fn get_external_role( auth: AuthUser, State(state): State, @@ -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, +} + +#[derive(sqlx::FromRow)] +struct InsertedRc { + updated_at: chrono::DateTime, +} + async fn create_external_role( auth: AuthUser, State(state): State, @@ -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}")))?; diff --git a/apps/users/src/handlers/kb.rs b/apps/users/src/handlers/kb.rs index 2468e33..ab9b3fd 100644 --- a/apps/users/src/handlers/kb.rs +++ b/apps/users/src/handlers/kb.rs @@ -85,16 +85,86 @@ struct AdminArticleDto { updated_at: String, } +// ── FromRow structs ─────────────────────────────────────────────────────────── + +#[derive(sqlx::FromRow)] +struct CategoryRow { + id: Uuid, + name: String, + slug: String, + description: Option, + display_order: i32, + is_active: bool, +} + +#[derive(sqlx::FromRow)] +struct CategoryWithCountRow { + id: Uuid, + name: String, + slug: String, + description: Option, + display_order: i32, + is_active: bool, + article_count: Option, +} + +#[derive(sqlx::FromRow)] +struct PublicArticleRow { + id: Uuid, + title: String, + slug: String, + summary: Option, + body: String, + target_roles: Option>, + tags: Vec, + updated_at: chrono::DateTime, + category_name: String, + category_slug: String, +} + +#[derive(sqlx::FromRow)] +struct AdminArticleRow { + id: Uuid, + title: String, + slug: String, + summary: Option, + body: String, + category_id: Uuid, + target_roles: Option>, + tags: Vec, + is_published: bool, + views: i32, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + category_name: String, +} + +#[derive(sqlx::FromRow)] +struct InsertedArticleRow { + id: Uuid, + title: String, + slug: String, + summary: Option, + body: String, + category_id: Uuid, + target_roles: Option>, + tags: Vec, + is_published: bool, + views: i32, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + // ── Public: categories ──────────────────────────────────────────────────────── async fn public_list_categories(State(state): State) -> 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, Path(slug): Path, ) -> 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, ) -> 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, ) -> 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, Json(body): Json, ) -> 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, Path(id): Path, ) -> 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 = 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, - 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 = body.target_roles.unwrap_or_default(); let tags: Vec = 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, - 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, Path(id): Path, ) -> 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, ) -> impl IntoResponse { let is_published: Option = 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, - 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, Path(id): Path, ) -> 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(_)) => ( diff --git a/apps/users/src/handlers/notifications.rs b/apps/users/src/handlers/notifications.rs index 709a8ce..1bd2e2d 100644 --- a/apps/users/src/handlers/notifications.rs +++ b/apps/users/src/handlers/notifications.rs @@ -54,6 +54,19 @@ pub struct Pagination { pub total_pages: i64, } +// ── FromRow structs ────────────────────────────────────────────────────────── + +#[derive(sqlx::FromRow)] +struct NotificationRow { + id: Uuid, + title: String, + body: Option, + notification_type: Option, + reference_id: Option, + is_read: bool, + created_at: chrono::DateTime, +} + // ── 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, ) -> 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, Path(id): Path, ) -> 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, ) -> 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; diff --git a/apps/users/src/handlers/pricing.rs b/apps/users/src/handlers/pricing.rs index 27d9720..5e7f996 100644 --- a/apps/users/src/handlers/pricing.rs +++ b/apps/users/src/handlers/pricing.rs @@ -83,6 +83,31 @@ struct DateRangeQuery { to: Option, } +// ── 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, + is_active: bool, +} + +#[derive(sqlx::FromRow)] +struct ExistingPackageRow { + name: String, + role_key: String, + package_type: String, + tracecoins_amount: i32, + price_inr: i32, + description: Option, + is_active: bool, +} + // ── Package handlers ────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -94,7 +119,7 @@ async fn public_list_packages( State(state): State, Query(params): Query, ) -> 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 ) + .bind(params.role) .fetch_all(&state.pool) .await; @@ -137,12 +162,12 @@ async fn list_packages( _auth: AuthUser, State(state): State, ) -> 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, Json(body): Json, ) -> 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, Path(id): Path, ) -> 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!({ diff --git a/apps/users/src/handlers/reviews.rs b/apps/users/src/handlers/reviews.rs index b64f1ad..17ec886 100644 --- a/apps/users/src/handlers/reviews.rs +++ b/apps/users/src/handlers/reviews.rs @@ -51,13 +51,30 @@ struct PatchReviewBody { is_published: Option, } +// ── FromRow structs ────────────────────────────────────────────────────────── + +#[derive(sqlx::FromRow)] +struct ReviewRow { + id: Uuid, + subject_type: String, + subject_id: Option, + reviewer_name: Option, + reviewer_id: Option, + rating: i16, + title: Option, + comment: Option, + status: String, + is_published: bool, + created_at: chrono::DateTime, +} + // ── Handlers ────────────────────────────────────────────────────────────────── async fn admin_list_reviews( _auth: AuthUser, State(state): State, ) -> 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, Path(id): Path, ) -> 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; diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index d5b8c86..2f7deb1 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -94,6 +94,64 @@ struct UpdateRolePayload { permission_keys: Option>, } +// ── FromRow structs ────────────────────────────────────────────────────────── + +#[derive(sqlx::FromRow)] +struct RoleListRow { + id: Uuid, + key: String, + name: String, + audience: String, + description: Option, + department_id: Option, + department_name: Option, + is_active: bool, + can_approve_requests: bool, + can_manage_system_settings: bool, + created_at: chrono::DateTime, + users_assigned: Option, + permissions_count: Option, +} + +#[derive(sqlx::FromRow)] +struct RoleDetailRow { + id: Uuid, + key: String, + name: String, + audience: String, + description: Option, + department_id: Option, + department_name: Option, + is_active: bool, + can_approve_requests: bool, + can_manage_system_settings: bool, + created_at: chrono::DateTime, +} + +#[derive(sqlx::FromRow)] +struct InsertedRoleRow { + id: Uuid, + key: String, + name: String, + audience: String, + description: Option, + department_id: Option, + is_active: bool, + can_approve_requests: bool, + can_manage_system_settings: bool, + created_at: chrono::DateTime, +} + +#[derive(sqlx::FromRow)] +struct CurrentRoleRow { + name: String, + description: Option, + department_id: Option, + 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, Path(id): Path, ) -> Result { - 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 = sqlx::query_scalar!( + let permission_keys: Vec = 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 = sqlx::query_scalar!( + let permission_keys: Vec = 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, ) -> Result { // 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, - department_id as Option, - 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, Path(id): Path, ) -> Result { - 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}")))?; diff --git a/apps/users/src/handlers/settings.rs b/apps/users/src/handlers/settings.rs index 4984030..c1a457f 100644 --- a/apps/users/src/handlers/settings.rs +++ b/apps/users/src/handlers/settings.rs @@ -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; diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index e84cf50..f48b9b5 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -65,6 +65,33 @@ struct MessageDto { created_at: String, } +// ── FromRow structs ─────────────────────────────────────────────────────────── + +#[derive(sqlx::FromRow)] +struct TicketRow { + id: Uuid, + subject: String, + description: Option, + category: String, + priority: String, + status: String, + requester_name: Option, + requester_email: Option, + assigned_to: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +#[derive(sqlx::FromRow)] +struct MessageRow { + id: Uuid, + ticket_id: Uuid, + sender_id: Uuid, + body: String, + is_internal: bool, + created_at: chrono::DateTime, +} + // ── 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, Path(id): Path, ) -> 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, ) -> 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, } +#[derive(sqlx::FromRow)] +struct AdminTicketRow { + id: Uuid, + subject: String, + description: Option, + category: String, + priority: String, + status: String, + requester_name: Option, + requester_email: Option, + assigned_to: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + user_name: Option, + user_email: String, +} + async fn admin_list_cases( _auth: AuthUser, State(state): State, @@ -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, Path(id): Path, ) -> 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, } +#[derive(sqlx::FromRow)] +struct UpdatedTicketRow { + id: Uuid, + subject: String, + category: String, + priority: String, + status: String, + requester_name: Option, + requester_email: Option, + assigned_to: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + async fn admin_update_case( _auth: AuthUser, State(state): State, Path(id): Path, Json(body): Json, ) -> 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, ) + .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; } diff --git a/apps/users/src/handlers/user_roles.rs b/apps/users/src/handlers/user_roles.rs index 58ac514..fb8078f 100644 --- a/apps/users/src/handlers/user_roles.rs +++ b/apps/users/src/handlers/user_roles.rs @@ -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 { 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>, +} + async fn list_my_roles( auth: AuthUser, State(state): State, ) -> Result { - 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()))?; diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index e6d9ddb..a920bf2 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -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();