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>,
|
State(state): State<AppState>,
|
||||||
Query(_q): Query<ListQuery>,
|
Query(_q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let companies = sqlx::query_as!(
|
let companies = sqlx::query_as::<_, CompanyProfile>(
|
||||||
CompanyProfile,
|
|
||||||
r#"
|
r#"
|
||||||
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
||||||
employee_count, business_type, gst_number, contact_name, contact_email,
|
employee_count, business_type, gst_number, contact_name, contact_email,
|
||||||
|
|
@ -148,7 +147,7 @@ async fn list_companies(
|
||||||
FROM company_profiles
|
FROM company_profiles
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -163,8 +162,7 @@ async fn get_company(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let company = sqlx::query_as!(
|
let company = sqlx::query_as::<_, CompanyProfile>(
|
||||||
CompanyProfile,
|
|
||||||
r#"
|
r#"
|
||||||
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
SELECT id, user_id, company_name, registration_number, industry, website_url,
|
||||||
employee_count, business_type, gst_number, contact_name, contact_email,
|
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
|
purchased_contact_views, created_at, updated_at
|
||||||
FROM company_profiles WHERE id = $1
|
FROM company_profiles WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -190,7 +188,8 @@ async fn approve_company(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> 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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -202,7 +201,8 @@ async fn reject_company(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> 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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -214,7 +214,8 @@ async fn suspend_company(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> 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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -226,8 +227,7 @@ async fn list_jobs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(_q): Query<ListQuery>,
|
Query(_q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let jobs = sqlx::query_as!(
|
let jobs = sqlx::query_as::<_, Job>(
|
||||||
Job,
|
|
||||||
r#"
|
r#"
|
||||||
SELECT id, company_id, title, category, description, location, job_type,
|
SELECT id, company_id, title, category, description, location, job_type,
|
||||||
salary_min, salary_max, experience_years, skills, status, rejection_reason,
|
salary_min, salary_max, experience_years, skills, status, rejection_reason,
|
||||||
|
|
@ -235,7 +235,7 @@ async fn list_jobs(
|
||||||
FROM jobs
|
FROM jobs
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -250,15 +250,14 @@ async fn list_applications(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(_q): Query<ListQuery>,
|
Query(_q): Query<ListQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let applications = sqlx::query_as!(
|
let applications = sqlx::query_as::<_, Application>(
|
||||||
Application,
|
|
||||||
r#"
|
r#"
|
||||||
SELECT id, job_id, job_seeker_id, cover_letter, resume_url, status,
|
SELECT id, job_id, job_seeker_id, cover_letter, resume_url, status,
|
||||||
applied_at, updated_at, contact_viewed
|
applied_at, updated_at, contact_viewed
|
||||||
FROM applications
|
FROM applications
|
||||||
ORDER BY applied_at DESC
|
ORDER BY applied_at DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -162,10 +162,10 @@ async fn create_job(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduct ONE purchased slot
|
// 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",
|
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1",
|
||||||
company.id
|
|
||||||
)
|
)
|
||||||
|
.bind(company.id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,7 @@ async fn browse_jobs(
|
||||||
let limit = q.limit.unwrap_or(20);
|
let limit = q.limit.unwrap_or(20);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
let jobs = sqlx::query_as!(
|
let jobs = sqlx::query_as::<_, db::models::job::Job>(
|
||||||
db::models::job::Job,
|
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM jobs
|
SELECT * FROM jobs
|
||||||
WHERE status = 'LIVE'
|
WHERE status = 'LIVE'
|
||||||
|
|
@ -182,12 +181,12 @@ async fn browse_jobs(
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $4 OFFSET $5
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ async fn list_activity_logs(
|
||||||
let from_dt = params.from;
|
let from_dt = params.from;
|
||||||
let to_dt = params.to;
|
let to_dt = params.to;
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar!(
|
let total: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*) FROM activity_logs
|
SELECT COUNT(*) FROM activity_logs
|
||||||
WHERE ($1::uuid IS NULL OR actor_id = $1)
|
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 ($4::timestamptz IS NULL OR created_at >= $4)
|
||||||
AND ($5::timestamptz IS NULL OR created_at <= $5)
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, ActivityLogDto>(
|
let rows = sqlx::query_as::<_, ActivityLogDto>(
|
||||||
|
|
|
||||||
|
|
@ -168,11 +168,9 @@ async fn update_user_status(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<StatusPayload>,
|
Json(payload): Json<StatusPayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
sqlx::query!(
|
sqlx::query("UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2")
|
||||||
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
|
.bind(&payload.status)
|
||||||
payload.status,
|
.bind(id)
|
||||||
id
|
|
||||||
)
|
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
|
||||||
|
|
@ -211,19 +211,19 @@ async fn activate_profile_after_final_approval(
|
||||||
);
|
);
|
||||||
sqlx::query(&query).bind(user_id).execute(&state.pool).await?;
|
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'",
|
"UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'",
|
||||||
user_id
|
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).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()",
|
"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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use axum::{
|
||||||
use db::models::user::{CreateUserPayload, UserRepository};
|
use db::models::user::{CreateUserPayload, UserRepository};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::AuthUser;
|
||||||
|
use uuid::Uuid;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
|
|
@ -220,26 +221,24 @@ async fn register(
|
||||||
payload.profession.as_deref(),
|
payload.profession.as_deref(),
|
||||||
);
|
);
|
||||||
for role_key in role_candidates {
|
for role_key in role_candidates {
|
||||||
let role = sqlx::query!(
|
let role = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
|
||||||
"SELECT id FROM roles WHERE key = $1",
|
.bind(&role_key)
|
||||||
role_key
|
|
||||||
)
|
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
if let Some(role_row) = role {
|
if let Some(role_id) = role {
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO user_roles (user_id, role_id, status, approved_at)
|
INSERT INTO user_roles (user_id, role_id, status, approved_at)
|
||||||
VALUES ($1, $2, 'APPROVED', NOW())
|
VALUES ($1, $2, 'APPROVED', NOW())
|
||||||
ON CONFLICT (user_id, role_id)
|
ON CONFLICT (user_id, role_id)
|
||||||
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
|
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
|
||||||
"#,
|
"#,
|
||||||
user.id,
|
|
||||||
role_row.id
|
|
||||||
)
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(role_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,8 @@ async fn list_runtime_configs(
|
||||||
let role_id = if let Some(id) = q.role_id {
|
let role_id = if let Some(id) = q.role_id {
|
||||||
Some(id)
|
Some(id)
|
||||||
} else if let Some(key) = q.role_key.clone() {
|
} 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)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
|
|
@ -69,16 +70,26 @@ async fn list_runtime_configs(
|
||||||
None
|
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 {
|
let items = if let Some(rid) = role_id {
|
||||||
sqlx::query!(
|
sqlx::query_as::<_, RcRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, role_id, config_json, version, is_active, updated_at
|
SELECT id, role_id, config_json, version, is_active, updated_at
|
||||||
FROM runtime_configs
|
FROM runtime_configs
|
||||||
WHERE role_id = $1
|
WHERE role_id = $1
|
||||||
ORDER BY version DESC
|
ORDER BY version DESC
|
||||||
"#,
|
"#,
|
||||||
rid
|
|
||||||
)
|
)
|
||||||
|
.bind(rid)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
|
|
@ -93,14 +104,14 @@ async fn list_runtime_configs(
|
||||||
}))
|
}))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
} else {
|
} else {
|
||||||
sqlx::query!(
|
sqlx::query_as::<_, RcRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
|
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
|
||||||
FROM runtime_configs rc
|
FROM runtime_configs rc
|
||||||
JOIN roles r ON rc.role_id = r.id
|
JOIN roles r ON rc.role_id = r.id
|
||||||
WHERE r.audience = 'INTERNAL'
|
WHERE r.audience = 'INTERNAL'
|
||||||
ORDER BY rc.updated_at DESC
|
ORDER BY rc.updated_at DESC
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -128,10 +139,19 @@ async fn get_runtime_config_by_id(
|
||||||
if let Err(_e) = require_admin(&auth) {
|
if let Err(_e) = require_admin(&auth) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
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",
|
"SELECT id, role_id, config_json, version, is_active, updated_at FROM runtime_configs WHERE id = $1",
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
|
|
@ -173,24 +193,21 @@ async fn activate_runtime_config(
|
||||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||||
}
|
}
|
||||||
// Fetch role_id for the target config
|
// 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)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
|
||||||
// Disable existing active
|
// Disable existing active
|
||||||
sqlx::query!(
|
sqlx::query("UPDATE runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true")
|
||||||
"UPDATE runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true",
|
.bind(role_id)
|
||||||
role.role_id
|
|
||||||
)
|
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
// Activate target
|
// Activate target
|
||||||
sqlx::query!(
|
sqlx::query("UPDATE runtime_configs SET is_active = true WHERE id = $1")
|
||||||
"UPDATE runtime_configs SET is_active = true WHERE id = $1",
|
.bind(id)
|
||||||
id
|
|
||||||
)
|
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -205,7 +222,8 @@ async fn delete_runtime_config(
|
||||||
if let Err(_e) = require_admin(&auth) {
|
if let Err(_e) = require_admin(&auth) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.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)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let role_key = auth.claims.active_role.clone().to_uppercase();
|
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)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)))?
|
.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" {
|
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",
|
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
|
||||||
role.id
|
|
||||||
)
|
)
|
||||||
|
.bind(role.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
|
||||||
|
|
@ -108,19 +108,85 @@ struct PatchDiscountBody {
|
||||||
is_active: Option<bool>,
|
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 ───────────────────────────────────────────────────────────
|
// ── Coupon handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn list_coupons(
|
async fn list_coupons(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, CouponRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, code, title, discount_type, discount_value, min_order_amount,
|
SELECT id, code, title, discount_type, discount_value, min_order_amount,
|
||||||
max_uses, uses_count, role_keys, applies_to, is_active, valid_until
|
max_uses, uses_count, role_keys, applies_to, is_active, valid_until
|
||||||
FROM coupons
|
FROM coupons
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -165,7 +231,7 @@ async fn create_coupon(
|
||||||
let role_keys: Vec<String> = body.role_keys.unwrap_or_default();
|
let role_keys: Vec<String> = body.role_keys.unwrap_or_default();
|
||||||
let code = body.code.to_uppercase();
|
let code = body.code.to_uppercase();
|
||||||
|
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query_as::<_, CouponRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO coupons (code, title, discount_type, discount_value, min_order_amount,
|
INSERT INTO coupons (code, title, discount_type, discount_value, min_order_amount,
|
||||||
max_uses, role_keys, applies_to)
|
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,
|
RETURNING id, code, title, discount_type, discount_value, min_order_amount,
|
||||||
max_uses, uses_count, role_keys, applies_to, is_active, valid_until
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -220,11 +286,10 @@ async fn update_coupon(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchCouponBody>,
|
Json(body): Json<PatchCouponBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Build dynamic update using individual queries for simplicity
|
let existing = sqlx::query_as::<_, ExistingCouponRow>(
|
||||||
let existing = sqlx::query!(
|
|
||||||
"SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1",
|
"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)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -246,23 +311,23 @@ async fn update_coupon(
|
||||||
let role_keys = body.role_keys.unwrap_or(existing.role_keys);
|
let role_keys = body.role_keys.unwrap_or(existing.role_keys);
|
||||||
let is_active = body.is_active.unwrap_or(existing.is_active);
|
let is_active = body.is_active.unwrap_or(existing.is_active);
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE coupons
|
UPDATE coupons
|
||||||
SET code = $1, title = $2, discount_type = $3, discount_value = $4,
|
SET code = $1, title = $2, discount_type = $3, discount_value = $4,
|
||||||
min_order_amount = $5, max_uses = $6, role_keys = $7, is_active = $8
|
min_order_amount = $5, max_uses = $6, role_keys = $7, is_active = $8
|
||||||
WHERE id = $9
|
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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -280,7 +345,8 @@ async fn delete_coupon(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> 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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -302,12 +368,12 @@ async fn list_discounts(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, DiscountRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active
|
SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active
|
||||||
FROM discounts
|
FROM discounts
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -345,19 +411,19 @@ async fn create_discount(
|
||||||
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
|
let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string());
|
||||||
let value = body.value.unwrap_or(0);
|
let value = body.value.unwrap_or(0);
|
||||||
|
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query_as::<_, DiscountRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO discounts (title, scope, role_key, package_id, discount_type, discount_value)
|
INSERT INTO discounts (title, scope, role_key, package_id, discount_type, discount_value)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, title, scope, role_key, package_id, discount_type, discount_value, is_active
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -388,10 +454,10 @@ async fn update_discount(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchDiscountBody>,
|
Json(body): Json<PatchDiscountBody>,
|
||||||
) -> impl IntoResponse {
|
) -> 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",
|
"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)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -412,22 +478,22 @@ async fn update_discount(
|
||||||
let value = body.value.unwrap_or(existing.discount_value);
|
let value = body.value.unwrap_or(existing.discount_value);
|
||||||
let is_active = body.is_active.unwrap_or(existing.is_active);
|
let is_active = body.is_active.unwrap_or(existing.is_active);
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE discounts
|
UPDATE discounts
|
||||||
SET title = $1, scope = $2, role_key = $3, package_id = $4,
|
SET title = $1, scope = $2, role_key = $3, package_id = $4,
|
||||||
discount_type = $5, discount_value = $6, is_active = $7
|
discount_type = $5, discount_value = $6, is_active = $7
|
||||||
WHERE id = $8
|
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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -461,15 +527,15 @@ async fn validate_coupon(
|
||||||
let code = payload.coupon_code.trim().to_uppercase();
|
let code = payload.coupon_code.trim().to_uppercase();
|
||||||
|
|
||||||
// Fetch coupon
|
// Fetch coupon
|
||||||
let coupon = sqlx::query!(
|
let coupon = sqlx::query_as::<_, ValidateCouponRow>(
|
||||||
r#"
|
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
|
max_uses, role_keys, valid_until, is_active
|
||||||
FROM coupons
|
FROM coupons
|
||||||
WHERE code = $1
|
WHERE code = $1
|
||||||
"#,
|
"#,
|
||||||
code
|
|
||||||
)
|
)
|
||||||
|
.bind(&code)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -550,13 +616,12 @@ async fn validate_coupon(
|
||||||
|
|
||||||
// Check usage limit if set
|
// Check usage limit if set
|
||||||
if let Some(max_uses) = coupon.max_uses {
|
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",
|
"SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = $1",
|
||||||
coupon.id
|
|
||||||
)
|
)
|
||||||
|
.bind(coupon.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if count >= max_uses as i64 {
|
if count >= max_uses as i64 {
|
||||||
return Ok((
|
return Ok((
|
||||||
|
|
|
||||||
|
|
@ -15,29 +15,26 @@ pub fn router() -> Router<crate::AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetricsResponse> {
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let active_companies: i64 = sqlx::query_scalar!(
|
let active_companies: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'"
|
"SELECT COUNT(*) FROM company_profiles WHERE status = 'APPROVED'",
|
||||||
)
|
)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let open_leads: i64 = sqlx::query_scalar!(
|
let open_leads: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'"
|
"SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'",
|
||||||
)
|
)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let pending_approvals: i64 = sqlx::query_scalar!(
|
let pending_approvals: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*) FROM (
|
SELECT COUNT(*) FROM (
|
||||||
SELECT id FROM company_profiles WHERE status = 'PENDING_APPROVAL'
|
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
|
UNION ALL
|
||||||
SELECT id FROM professionals WHERE status = 'PENDING_APPROVAL'
|
SELECT id FROM professionals WHERE status = 'PENDING_APPROVAL'
|
||||||
) sub
|
) sub
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
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'"
|
"SELECT COALESCE(SUM(amount_inr), 0) FROM payments WHERE status = 'SUCCESS'",
|
||||||
)
|
)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let kpis = vec![
|
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)
|
// 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#"
|
r#"
|
||||||
SELECT TO_CHAR(DATE_TRUNC('day', created_at), 'Dy') AS day_name,
|
SELECT TO_CHAR(DATE_TRUNC('day', created_at), 'Dy') AS day_name,
|
||||||
COUNT(*) AS count
|
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'
|
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||||
GROUP BY DATE_TRUNC('day', created_at), day_name
|
GROUP BY DATE_TRUNC('day', created_at), day_name
|
||||||
ORDER BY DATE_TRUNC('day', created_at)
|
ORDER BY DATE_TRUNC('day', created_at)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -92,7 +93,13 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Revenue per week (last 4 weeks)
|
// 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#"
|
r#"
|
||||||
SELECT TO_CHAR(DATE_TRUNC('week', created_at), '"Week" W') AS week_name,
|
SELECT TO_CHAR(DATE_TRUNC('week', created_at), '"Week" W') AS week_name,
|
||||||
COALESCE(SUM(amount_inr), 0) AS total
|
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'
|
WHERE status = 'SUCCESS' AND created_at >= NOW() - INTERVAL '28 days'
|
||||||
GROUP BY DATE_TRUNC('week', created_at), week_name
|
GROUP BY DATE_TRUNC('week', created_at), week_name
|
||||||
ORDER BY DATE_TRUNC('week', created_at)
|
ORDER BY DATE_TRUNC('week', created_at)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -112,7 +119,16 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Recent open leads
|
// 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#"
|
r#"
|
||||||
SELECT r.id, r.title, r.status, r.created_at,
|
SELECT r.id, r.title, r.status, r.created_at,
|
||||||
u.full_name AS requester_name
|
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')
|
WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED')
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,17 @@ struct ListResponse {
|
||||||
per_page: i64,
|
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(
|
async fn list_external_roles(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -73,7 +84,7 @@ async fn list_external_roles(
|
||||||
let category = q.category.unwrap_or_default().to_lowercase();
|
let category = q.category.unwrap_or_default().to_lowercase();
|
||||||
|
|
||||||
// Join roles with active runtime_config for that role (optional) and count assigned user_roles
|
// 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#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
|
|
@ -81,8 +92,8 @@ async fn list_external_roles(
|
||||||
r.key as code,
|
r.key as code,
|
||||||
r.is_active,
|
r.is_active,
|
||||||
r.created_at as created_date,
|
r.created_at as created_date,
|
||||||
rc.updated_at as "updated_at?",
|
rc.updated_at as "updated_at",
|
||||||
rc.config_json as "config_json?"
|
rc.config_json as "config_json"
|
||||||
FROM roles r
|
FROM roles r
|
||||||
LEFT JOIN runtime_configs rc
|
LEFT JOIN runtime_configs rc
|
||||||
ON rc.role_id = r.id AND rc.is_active = true
|
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
|
ORDER BY r.created_at DESC
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
search,
|
|
||||||
status,
|
|
||||||
per_page,
|
|
||||||
offset
|
|
||||||
)
|
)
|
||||||
|
.bind(&search)
|
||||||
|
.bind(&status)
|
||||||
|
.bind(per_page)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
||||||
// Compute total with same filters
|
// Compute total with same filters
|
||||||
let total: i64 = sqlx::query_scalar!(
|
let total: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM roles r
|
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 ($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))
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let mut roles: Vec<ExternalRoleRow> = Vec::with_capacity(rows.len());
|
let mut roles: Vec<ExternalRoleRow> = Vec::with_capacity(rows.len());
|
||||||
for row in rows {
|
for row in rows {
|
||||||
|
|
@ -147,13 +157,12 @@ async fn list_external_roles(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Count assigned users from user_roles (approved)
|
// 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'",
|
"SELECT COUNT(*) FROM user_roles WHERE role_id = $1 AND status = 'APPROVED'",
|
||||||
row.id
|
|
||||||
)
|
)
|
||||||
|
.bind(row.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
roles.push(ExternalRoleRow {
|
roles.push(ExternalRoleRow {
|
||||||
|
|
@ -192,6 +201,18 @@ struct ExternalRoleDetail {
|
||||||
updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
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(
|
async fn get_external_role(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -200,15 +221,15 @@ async fn get_external_role(
|
||||||
if let Err(_e) = require_admin(&auth) {
|
if let Err(_e) = require_admin(&auth) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||||
}
|
}
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
|
||||||
r#"
|
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
|
FROM roles r
|
||||||
LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
|
LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
|
||||||
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
|
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
|
||||||
"#,
|
"#,
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.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
|
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(
|
async fn create_external_role(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -244,30 +280,30 @@ async fn create_external_role(
|
||||||
}
|
}
|
||||||
let is_active = payload.is_active.unwrap_or(true);
|
let is_active = payload.is_active.unwrap_or(true);
|
||||||
// Insert role
|
// Insert role
|
||||||
let role = sqlx::query!(
|
let role = sqlx::query_as::<_, InsertedRole>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO roles (key, name, audience, is_active)
|
INSERT INTO roles (key, name, audience, is_active)
|
||||||
VALUES ($1, $2, 'EXTERNAL', $3)
|
VALUES ($1, $2, 'EXTERNAL', $3)
|
||||||
RETURNING id, key, name, audience, is_active, created_at
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
||||||
// Create runtime config version 1
|
// Create runtime config version 1
|
||||||
let rc = sqlx::query!(
|
let rc = sqlx::query_as::<_, InsertedRc>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
|
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
|
||||||
VALUES ($1, $2, 1, true)
|
VALUES ($1, $2, 1, true)
|
||||||
RETURNING updated_at
|
RETURNING updated_at
|
||||||
"#,
|
"#,
|
||||||
role.id,
|
|
||||||
payload.runtime
|
|
||||||
)
|
)
|
||||||
|
.bind(role.id)
|
||||||
|
.bind(&payload.runtime)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -305,35 +341,35 @@ async fn update_external_role(
|
||||||
}
|
}
|
||||||
// Update role basic fields
|
// Update role basic fields
|
||||||
if payload.name.is_some() || payload.is_active.is_some() {
|
if payload.name.is_some() || payload.is_active.is_some() {
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE roles
|
UPDATE roles
|
||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
is_active = COALESCE($2, is_active)
|
is_active = COALESCE($2, is_active)
|
||||||
WHERE id = $3 AND audience = 'EXTERNAL'
|
WHERE id = $3 AND audience = 'EXTERNAL'
|
||||||
"#,
|
"#,
|
||||||
payload.name,
|
|
||||||
payload.is_active,
|
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(payload.name)
|
||||||
|
.bind(payload.is_active)
|
||||||
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
}
|
}
|
||||||
// Create a new runtime config version if provided
|
// Create a new runtime config version if provided
|
||||||
if let Some(runtime) = payload.runtime {
|
if let Some(runtime) = payload.runtime {
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE runtime_configs
|
UPDATE runtime_configs
|
||||||
SET is_active = false
|
SET is_active = false
|
||||||
WHERE role_id = $1 AND is_active = true
|
WHERE role_id = $1 AND is_active = true
|
||||||
"#,
|
"#,
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
|
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
|
||||||
VALUES (
|
VALUES (
|
||||||
|
|
@ -343,9 +379,9 @@ async fn update_external_role(
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
id,
|
|
||||||
runtime
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(runtime)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.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) {
|
if let Err(_e) = require_admin(&auth) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
|
||||||
|
|
@ -85,16 +85,86 @@ struct AdminArticleDto {
|
||||||
updated_at: String,
|
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 ────────────────────────────────────────────────────────
|
// ── Public: categories ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn public_list_categories(State(state): State<AppState>) -> impl IntoResponse {
|
async fn public_list_categories(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, CategoryRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, slug, description, display_order
|
SELECT id, name, slug, description, display_order, is_active
|
||||||
FROM kb_categories
|
FROM kb_categories
|
||||||
WHERE is_active = true
|
WHERE is_active = true
|
||||||
ORDER BY display_order ASC, name ASC
|
ORDER BY display_order ASC, name ASC
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -146,10 +216,10 @@ async fn public_list_articles(
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
let role_filter = params.role.as_deref().unwrap_or("").to_uppercase();
|
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 q = params.q.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, PublicArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
|
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
|
ORDER BY a.updated_at DESC
|
||||||
LIMIT $4 OFFSET $5
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -216,7 +286,7 @@ async fn public_get_article(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query_as::<_, PublicArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
|
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
|
JOIN kb_categories c ON c.id = a.category_id
|
||||||
WHERE a.slug = $1 AND a.is_published = true AND c.is_active = true
|
WHERE a.slug = $1 AND a.is_published = true AND c.is_active = true
|
||||||
"#,
|
"#,
|
||||||
slug
|
|
||||||
)
|
)
|
||||||
|
.bind(&slug)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -235,10 +305,10 @@ async fn public_get_article(
|
||||||
let pool = state.pool.clone();
|
let pool = state.pool.clone();
|
||||||
let slug_clone = slug.clone();
|
let slug_clone = slug.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query(
|
||||||
"UPDATE kb_articles SET views = views + 1 WHERE slug = $1",
|
"UPDATE kb_articles SET views = views + 1 WHERE slug = $1",
|
||||||
slug_clone
|
|
||||||
)
|
)
|
||||||
|
.bind(slug_clone)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
@ -282,7 +352,7 @@ async fn admin_list_categories(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, CategoryWithCountRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.name, c.slug, c.description, c.display_order, c.is_active,
|
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
|
LEFT JOIN kb_articles a ON a.category_id = c.id
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
ORDER BY c.display_order ASC, c.name ASC
|
ORDER BY c.display_order ASC, c.name ASC
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -337,17 +407,17 @@ async fn admin_create_category(
|
||||||
Json(body): Json<CreateCategoryBody>,
|
Json(body): Json<CreateCategoryBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let order = body.display_order.unwrap_or(0);
|
let order = body.display_order.unwrap_or(0);
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, CategoryRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO kb_categories (name, slug, description, display_order)
|
INSERT INTO kb_categories (name, slug, description, display_order)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, name, slug, description, display_order, is_active
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -396,7 +466,7 @@ async fn admin_update_category(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateCategoryBody>,
|
Json(body): Json<UpdateCategoryBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, CategoryRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE kb_categories SET
|
UPDATE kb_categories SET
|
||||||
name = COALESCE($2, name),
|
name = COALESCE($2, name),
|
||||||
|
|
@ -407,13 +477,13 @@ async fn admin_update_category(
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, slug, description, display_order, is_active
|
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)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -452,10 +522,13 @@ async fn admin_delete_category(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> 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",
|
"DELETE FROM kb_categories WHERE id = $1 RETURNING id",
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -498,7 +571,7 @@ async fn admin_list_articles(
|
||||||
let q = params.q.as_deref().unwrap_or("").to_lowercase();
|
let q = params.q.as_deref().unwrap_or("").to_lowercase();
|
||||||
let published_filter: Option<bool> = params.status.as_deref().map(|s| s == "PUBLISHED");
|
let published_filter: Option<bool> = params.status.as_deref().map(|s| s == "PUBLISHED");
|
||||||
|
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, AdminArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
|
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
|
ORDER BY a.updated_at DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
"#,
|
"#,
|
||||||
q,
|
|
||||||
params.category_id as Option<Uuid>,
|
|
||||||
published_filter,
|
|
||||||
)
|
)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(params.category_id)
|
||||||
|
.bind(published_filter)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -577,7 +650,7 @@ async fn admin_create_article(
|
||||||
let roles: Vec<String> = body.target_roles.unwrap_or_default();
|
let roles: Vec<String> = body.target_roles.unwrap_or_default();
|
||||||
let tags: Vec<String> = body.tags.unwrap_or_default();
|
let tags: Vec<String> = body.tags.unwrap_or_default();
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, InsertedArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO kb_articles
|
INSERT INTO kb_articles
|
||||||
(title, slug, summary, body, category_id, is_published, target_roles, tags, created_by)
|
(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,
|
RETURNING id, title, slug, summary, body, category_id, is_published,
|
||||||
target_roles, tags, views, created_at, updated_at
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -644,7 +717,7 @@ async fn admin_get_article(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query_as::<_, AdminArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
a.id, a.title, a.slug, a.summary, a.body, a.category_id,
|
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
|
JOIN kb_categories c ON c.id = a.category_id
|
||||||
WHERE a.id = $1
|
WHERE a.id = $1
|
||||||
"#,
|
"#,
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -715,7 +788,7 @@ async fn admin_update_article(
|
||||||
Json(body): Json<UpdateArticleBody>,
|
Json(body): Json<UpdateArticleBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let is_published: Option<bool> = body.status.as_deref().map(|s| s == "PUBLISHED");
|
let is_published: Option<bool> = body.status.as_deref().map(|s| s == "PUBLISHED");
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, InsertedArticleRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE kb_articles SET
|
UPDATE kb_articles SET
|
||||||
title = COALESCE($2, title),
|
title = COALESCE($2, title),
|
||||||
|
|
@ -731,16 +804,16 @@ async fn admin_update_article(
|
||||||
RETURNING id, title, slug, summary, body, category_id,
|
RETURNING id, title, slug, summary, body, category_id,
|
||||||
target_roles, tags, is_published, views, created_at, updated_at
|
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)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -785,8 +858,13 @@ async fn admin_delete_article(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let result =
|
#[derive(sqlx::FromRow)]
|
||||||
sqlx::query!("DELETE FROM kb_articles WHERE id = $1 RETURNING id", id)
|
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)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,19 @@ pub struct Pagination {
|
||||||
pub total_pages: i64,
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn list_notifications(
|
async fn list_notifications(
|
||||||
|
|
@ -65,7 +78,7 @@ async fn list_notifications(
|
||||||
let limit = params.limit.unwrap_or(20).clamp(1, 100);
|
let limit = params.limit.unwrap_or(20).clamp(1, 100);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, NotificationRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, title, body, type AS notification_type,
|
SELECT id, title, body, type AS notification_type,
|
||||||
reference_id, is_read, created_at
|
reference_id, is_read, created_at
|
||||||
|
|
@ -74,20 +87,19 @@ async fn list_notifications(
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
auth.user_id,
|
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
)
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar!(
|
let total: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM notifications WHERE user_id = $1",
|
"SELECT COUNT(*) FROM notifications WHERE user_id = $1",
|
||||||
auth.user_id
|
|
||||||
)
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
match rows {
|
match rows {
|
||||||
|
|
@ -131,13 +143,12 @@ async fn unread_count(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> 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",
|
"SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND is_read = false",
|
||||||
auth.user_id
|
|
||||||
)
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "unread_count": count })))
|
(StatusCode::OK, Json(serde_json::json!({ "unread_count": count })))
|
||||||
|
|
@ -148,11 +159,11 @@ async fn mark_read(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query(
|
||||||
"UPDATE notifications SET is_read = true WHERE id = $1 AND user_id = $2",
|
"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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -182,10 +193,10 @@ async fn mark_all_read(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query(
|
||||||
"UPDATE notifications SET is_read = true WHERE user_id = $1 AND is_read = false",
|
"UPDATE notifications SET is_read = true WHERE user_id = $1 AND is_read = false",
|
||||||
auth.user_id
|
|
||||||
)
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,31 @@ struct DateRangeQuery {
|
||||||
to: Option<String>,
|
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 ──────────────────────────────────────────────────────────
|
// ── Package handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -94,7 +119,7 @@ async fn public_list_packages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<PackageQuery>,
|
Query(params): Query<PackageQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, PackageRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
||||||
FROM pricing_packages
|
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')
|
AND ($1::text IS NULL OR role_key = $1 OR role_key = 'ALL')
|
||||||
ORDER BY role_key, price_inr
|
ORDER BY role_key, price_inr
|
||||||
"#,
|
"#,
|
||||||
params.role as Option<String>
|
|
||||||
)
|
)
|
||||||
|
.bind(params.role)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -137,12 +162,12 @@ async fn list_packages(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, PackageRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
||||||
FROM pricing_packages
|
FROM pricing_packages
|
||||||
ORDER BY role_key, price_inr
|
ORDER BY role_key, price_inr
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -184,19 +209,19 @@ async fn create_package(
|
||||||
// Accept role (admin UI) or role_key
|
// Accept role (admin UI) or role_key
|
||||||
let role_key = body.role_key.or(body.role).unwrap_or_else(|| "ALL".to_string());
|
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#"
|
r#"
|
||||||
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description)
|
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -229,10 +254,10 @@ async fn update_package(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchPackageBody>,
|
Json(body): Json<PatchPackageBody>,
|
||||||
) -> impl IntoResponse {
|
) -> 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",
|
"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)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -253,22 +278,22 @@ async fn update_package(
|
||||||
let description = body.description.or(existing.description);
|
let description = body.description.or(existing.description);
|
||||||
let is_active = body.is_active.unwrap_or(existing.is_active);
|
let is_active = body.is_active.unwrap_or(existing.is_active);
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE pricing_packages
|
UPDATE pricing_packages
|
||||||
SET name = $1, role_key = $2, package_type = $3, tracecoins_amount = $4,
|
SET name = $1, role_key = $2, package_type = $3, tracecoins_amount = $4,
|
||||||
price_inr = $5, description = $6, is_active = $7
|
price_inr = $5, description = $6, is_active = $7
|
||||||
WHERE id = $8
|
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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -286,7 +311,8 @@ async fn delete_package(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> 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)
|
.execute(&state.pool)
|
||||||
.await;
|
.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",
|
"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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(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'",
|
"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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
// Active = email-verified users registered in range
|
// 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",
|
"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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(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",
|
"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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(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",
|
"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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(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",
|
"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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,30 @@ struct PatchReviewBody {
|
||||||
is_published: Option<bool>,
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn admin_list_reviews(
|
async fn admin_list_reviews(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, ReviewRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
|
|
@ -73,7 +90,7 @@ async fn admin_list_reviews(
|
||||||
r.created_at
|
r.created_at
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -117,21 +134,21 @@ async fn admin_create_review(
|
||||||
let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string());
|
let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string());
|
||||||
let status = "PUBLISHED".to_string();
|
let status = "PUBLISHED".to_string();
|
||||||
|
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query_as::<_, ReviewRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status, is_published)
|
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status, is_published)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
||||||
RETURNING id, subject_type, subject_id, reviewer_name, customer_id AS reviewer_id,
|
RETURNING id, subject_type, subject_id, reviewer_name, customer_id AS reviewer_id,
|
||||||
rating, title, comment, status, is_published, created_at
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.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",
|
"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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -204,7 +221,8 @@ async fn admin_delete_review(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> 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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,64 @@ struct UpdateRolePayload {
|
||||||
permission_keys: Option<Vec<String>>,
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn list_roles(
|
async fn list_roles(
|
||||||
|
|
@ -104,9 +162,9 @@ async fn list_roles(
|
||||||
let per_page = params.per_page.unwrap_or(20).min(100);
|
let per_page = params.per_page.unwrap_or(20).min(100);
|
||||||
let offset = (page - 1) * per_page;
|
let offset = (page - 1) * per_page;
|
||||||
let search = params.q.as_deref().unwrap_or("").to_lowercase();
|
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#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
|
|
@ -115,7 +173,7 @@ async fn list_roles(
|
||||||
r.audience,
|
r.audience,
|
||||||
r.description,
|
r.description,
|
||||||
r.department_id,
|
r.department_id,
|
||||||
d.name AS "department_name?",
|
d.name AS department_name,
|
||||||
r.is_active,
|
r.is_active,
|
||||||
r.can_approve_requests,
|
r.can_approve_requests,
|
||||||
r.can_manage_system_settings,
|
r.can_manage_system_settings,
|
||||||
|
|
@ -132,28 +190,27 @@ async fn list_roles(
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
"#,
|
"#,
|
||||||
audience,
|
|
||||||
search,
|
|
||||||
per_page,
|
|
||||||
offset
|
|
||||||
)
|
)
|
||||||
|
.bind(&audience)
|
||||||
|
.bind(&search)
|
||||||
|
.bind(per_page)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.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#"
|
r#"
|
||||||
SELECT COUNT(*) FROM roles r
|
SELECT COUNT(*) FROM roles r
|
||||||
WHERE ($1 = '' OR r.audience = $1)
|
WHERE ($1 = '' OR r.audience = $1)
|
||||||
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%')
|
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%')
|
||||||
"#,
|
"#,
|
||||||
audience,
|
|
||||||
search
|
|
||||||
)
|
)
|
||||||
|
.bind(&audience)
|
||||||
|
.bind(&search)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let roles = rows
|
let roles = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -181,28 +238,28 @@ async fn get_role(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query_as::<_, RoleDetailRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
r.id, r.key, r.name, r.audience, r.description,
|
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.is_active, r.can_approve_requests, r.can_manage_system_settings,
|
||||||
r.created_at
|
r.created_at
|
||||||
FROM roles r
|
FROM roles r
|
||||||
LEFT JOIN departments d ON d.id = r.department_id
|
LEFT JOIN departments d ON d.id = r.department_id
|
||||||
WHERE r.id = $1
|
WHERE r.id = $1
|
||||||
"#,
|
"#,
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
|
.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",
|
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.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_approve = payload.can_approve_requests.unwrap_or(false);
|
||||||
let can_manage = payload.can_manage_system_settings.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#"
|
r#"
|
||||||
INSERT INTO roles (key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings)
|
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)
|
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
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -253,21 +310,21 @@ async fn create_role(
|
||||||
// Insert permission keys
|
// Insert permission keys
|
||||||
if let Some(keys) = &payload.permission_keys {
|
if let Some(keys) = &payload.permission_keys {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
"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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.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",
|
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
|
||||||
role.id
|
|
||||||
)
|
)
|
||||||
|
.bind(role.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
@ -297,10 +354,10 @@ async fn update_role(
|
||||||
Json(payload): Json<UpdateRolePayload>,
|
Json(payload): Json<UpdateRolePayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
// Fetch current values first
|
// 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",
|
"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)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
.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_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);
|
let can_manage = payload.can_manage_system_settings.unwrap_or(current.can_manage_system_settings);
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE roles SET
|
UPDATE roles SET
|
||||||
name = $1,
|
name = $1,
|
||||||
|
|
@ -324,31 +381,32 @@ async fn update_role(
|
||||||
can_manage_system_settings = $6
|
can_manage_system_settings = $6
|
||||||
WHERE id = $7
|
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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
||||||
// Replace permissions if provided
|
// Replace permissions if provided
|
||||||
if let Some(keys) = &payload.permission_keys {
|
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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
||||||
for key in keys {
|
for key in keys {
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
id,
|
|
||||||
key
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(key)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -363,7 +421,8 @@ async fn delete_role(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> 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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.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(),
|
user.full_name.as_deref().unwrap_or_default(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query(
|
||||||
r#"INSERT INTO notifications (user_id, title, body, type)
|
r#"INSERT INTO notifications (user_id, title, body, type)
|
||||||
VALUES ($1, $2, $3, $4)"#,
|
VALUES ($1, $2, $3, $4)"#,
|
||||||
auth.user_id,
|
)
|
||||||
"Account Deleted",
|
.bind(auth.user_id)
|
||||||
format!(
|
.bind("Account Deleted")
|
||||||
|
.bind(format!(
|
||||||
"Your account was deleted{}.",
|
"Your account was deleted{}.",
|
||||||
payload
|
payload
|
||||||
.reason
|
.reason
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|r| format!(" Reason: {r}"))
|
.map(|r| format!(" Reason: {r}"))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
),
|
))
|
||||||
"ACCOUNT"
|
.bind("ACCOUNT")
|
||||||
)
|
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,33 @@ struct MessageDto {
|
||||||
created_at: String,
|
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 ───────────────────────────────────────────────────────
|
// ── User: create ticket ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -83,19 +110,19 @@ async fn user_create_ticket(
|
||||||
let category = body.category.unwrap_or_else(|| "customer_query".to_string());
|
let category = body.category.unwrap_or_else(|| "customer_query".to_string());
|
||||||
let priority = body.priority.unwrap_or_else(|| "medium".to_string());
|
let priority = body.priority.unwrap_or_else(|| "medium".to_string());
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, TicketRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO support_tickets (user_id, subject, description, category, priority, status)
|
INSERT INTO support_tickets (user_id, subject, description, category, priority, status)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'new')
|
VALUES ($1, $2, $3, $4, $5, 'new')
|
||||||
RETURNING id, subject, description, category, priority, status,
|
RETURNING id, subject, description, category, priority, status,
|
||||||
requester_name, requester_email, assigned_to, created_at, updated_at
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -145,9 +172,9 @@ async fn user_list_tickets(
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
let limit = params.limit.unwrap_or(20).clamp(1, 100);
|
let limit = params.limit.unwrap_or(20).clamp(1, 100);
|
||||||
let offset = (page - 1) * limit;
|
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#"
|
r#"
|
||||||
SELECT id, subject, description, category, priority, status,
|
SELECT id, subject, description, category, priority, status,
|
||||||
requester_name, requester_email, assigned_to, created_at, updated_at
|
requester_name, requester_email, assigned_to, created_at, updated_at
|
||||||
|
|
@ -157,11 +184,11 @@ async fn user_list_tickets(
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT $3 OFFSET $4
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -203,30 +230,30 @@ async fn user_get_ticket(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ticket = sqlx::query!(
|
let ticket = sqlx::query_as::<_, TicketRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, subject, description, category, priority, status,
|
SELECT id, subject, description, category, priority, status,
|
||||||
requester_name, requester_email, assigned_to, created_at, updated_at
|
requester_name, requester_email, assigned_to, created_at, updated_at
|
||||||
FROM support_tickets
|
FROM support_tickets
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
"#,
|
"#,
|
||||||
id,
|
|
||||||
auth.user_id,
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(auth.user_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match ticket {
|
match ticket {
|
||||||
Ok(Some(t)) => {
|
Ok(Some(t)) => {
|
||||||
let messages = sqlx::query!(
|
let messages = sqlx::query_as::<_, MessageRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, ticket_id, sender_id, body, is_internal, created_at
|
SELECT id, ticket_id, sender_id, body, is_internal, created_at
|
||||||
FROM support_ticket_messages
|
FROM support_ticket_messages
|
||||||
WHERE ticket_id = $1 AND is_internal = false
|
WHERE ticket_id = $1 AND is_internal = false
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
"#,
|
"#,
|
||||||
id,
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
@ -289,14 +316,13 @@ async fn user_add_message(
|
||||||
Json(body): Json<AddMessageBody>,
|
Json(body): Json<AddMessageBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Verify ticket belongs to user
|
// 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)",
|
"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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(false))
|
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -307,24 +333,24 @@ async fn user_add_message(
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, MessageRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO support_ticket_messages (ticket_id, sender_id, body, is_internal)
|
INSERT INTO support_ticket_messages (ticket_id, sender_id, body, is_internal)
|
||||||
VALUES ($1, $2, $3, false)
|
VALUES ($1, $2, $3, false)
|
||||||
RETURNING id, ticket_id, sender_id, body, is_internal, created_at
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Update ticket updated_at
|
// 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",
|
"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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -363,6 +389,23 @@ struct AdminListQuery {
|
||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AdminTicketRow {
|
||||||
|
id: Uuid,
|
||||||
|
subject: String,
|
||||||
|
description: Option<String>,
|
||||||
|
category: String,
|
||||||
|
priority: String,
|
||||||
|
status: String,
|
||||||
|
requester_name: Option<String>,
|
||||||
|
requester_email: Option<String>,
|
||||||
|
assigned_to: Option<Uuid>,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
user_name: Option<String>,
|
||||||
|
user_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
async fn admin_list_cases(
|
async fn admin_list_cases(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -371,11 +414,11 @@ async fn admin_list_cases(
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
let limit = params.limit.unwrap_or(50).clamp(1, 200);
|
let limit = params.limit.unwrap_or(50).clamp(1, 200);
|
||||||
let offset = (page - 1) * limit;
|
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 priority_filter = params.priority.as_deref().unwrap_or("");
|
let priority_filter = params.priority.as_deref().unwrap_or("").to_string();
|
||||||
let type_filter = params.ticket_type.as_deref().unwrap_or("");
|
let type_filter = params.ticket_type.as_deref().unwrap_or("").to_string();
|
||||||
|
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, AdminTicketRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.subject, t.description, t.category, t.priority, t.status,
|
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
|
ORDER BY t.updated_at DESC
|
||||||
LIMIT $4 OFFSET $5
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.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)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
match rows {
|
match rows {
|
||||||
|
|
@ -464,7 +506,7 @@ async fn admin_create_case(
|
||||||
let category = body.ticket_type.unwrap_or_else(|| "customer_query".to_string());
|
let category = body.ticket_type.unwrap_or_else(|| "customer_query".to_string());
|
||||||
let priority = body.priority.unwrap_or_else(|| "medium".to_string());
|
let priority = body.priority.unwrap_or_else(|| "medium".to_string());
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, TicketRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO support_tickets
|
INSERT INTO support_tickets
|
||||||
(subject, description, category, priority, status,
|
(subject, description, category, priority, status,
|
||||||
|
|
@ -473,13 +515,13 @@ async fn admin_create_case(
|
||||||
RETURNING id, subject, description, category, priority, status,
|
RETURNING id, subject, description, category, priority, status,
|
||||||
requester_name, requester_email, assigned_to, created_at, updated_at
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -518,7 +560,7 @@ async fn admin_get_case(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ticket = sqlx::query!(
|
let ticket = sqlx::query_as::<_, AdminTicketRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.subject, t.description, t.category, t.priority, t.status,
|
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
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
WHERE t.id = $1
|
WHERE t.id = $1
|
||||||
"#,
|
"#,
|
||||||
id,
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match ticket {
|
match ticket {
|
||||||
Ok(Some(t)) => {
|
Ok(Some(t)) => {
|
||||||
let messages = sqlx::query!(
|
let messages = sqlx::query_as::<_, MessageRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, ticket_id, sender_id, body, is_internal, created_at
|
SELECT id, ticket_id, sender_id, body, is_internal, created_at
|
||||||
FROM support_ticket_messages
|
FROM support_ticket_messages
|
||||||
WHERE ticket_id = $1
|
WHERE ticket_id = $1
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
"#,
|
"#,
|
||||||
id,
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
@ -606,13 +648,27 @@ struct UpdateCaseBody {
|
||||||
assigned_to: Option<Uuid>,
|
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(
|
async fn admin_update_case(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateCaseBody>,
|
Json(body): Json<UpdateCaseBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, UpdatedTicketRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE support_tickets SET
|
UPDATE support_tickets SET
|
||||||
status = COALESCE($2, status),
|
status = COALESCE($2, status),
|
||||||
|
|
@ -624,11 +680,11 @@ async fn admin_update_case(
|
||||||
RETURNING id, subject, category, priority, status,
|
RETURNING id, subject, category, priority, status,
|
||||||
requester_name, requester_email, assigned_to, created_at, updated_at
|
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)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -681,13 +737,12 @@ async fn admin_add_message(
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let is_internal = body.is_internal.unwrap_or(false);
|
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)",
|
"SELECT EXISTS(SELECT 1 FROM support_tickets WHERE id = $1)",
|
||||||
id,
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Some(false))
|
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -698,33 +753,33 @@ async fn admin_add_message(
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query_as::<_, MessageRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO support_ticket_messages (ticket_id, sender_id, body, is_internal)
|
INSERT INTO support_ticket_messages (ticket_id, sender_id, body, is_internal)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, ticket_id, sender_id, body, is_internal, created_at
|
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)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Move to waiting_for_user if this is a non-internal reply
|
// Move to waiting_for_user if this is a non-internal reply
|
||||||
if !is_internal {
|
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",
|
"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)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query(
|
||||||
"UPDATE support_tickets SET updated_at = NOW() WHERE id = $1",
|
"UPDATE support_tickets SET updated_at = NOW() WHERE id = $1",
|
||||||
id
|
|
||||||
)
|
)
|
||||||
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use axum::{
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::AuthUser;
|
||||||
use db::models::role::RoleRepository;
|
use db::models::role::RoleRepository;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
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(
|
async fn list_my_roles(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query_as::<_, UserRoleRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT r.key, r.name, ur.status, ur.approved_at
|
SELECT r.key, r.name, ur.status, ur.approved_at
|
||||||
FROM user_roles ur
|
FROM user_roles ur
|
||||||
|
|
@ -57,8 +66,8 @@ async fn list_my_roles(
|
||||||
WHERE ur.user_id = $1
|
WHERE ur.user_id = $1
|
||||||
ORDER BY ur.created_at ASC
|
ORDER BY ur.created_at ASC
|
||||||
"#,
|
"#,
|
||||||
auth.user_id
|
|
||||||
)
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
@ -90,16 +99,16 @@ async fn register_role(
|
||||||
.await
|
.await
|
||||||
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?;
|
.map_err(|_| (StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO user_roles (user_id, role_id, status, approved_at)
|
INSERT INTO user_roles (user_id, role_id, status, approved_at)
|
||||||
VALUES ($1, $2, 'APPROVED', NOW())
|
VALUES ($1, $2, 'APPROVED', NOW())
|
||||||
ON CONFLICT (user_id, role_id)
|
ON CONFLICT (user_id, role_id)
|
||||||
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
|
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
|
||||||
"#,
|
"#,
|
||||||
auth.user_id,
|
|
||||||
role.id
|
|
||||||
)
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.bind(role.id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
|
||||||
|
|
@ -255,15 +255,15 @@ async fn request_documents(
|
||||||
{
|
{
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
// Notify the user
|
// Notify the user
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
|
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
VALUES ($1, $2, $3, $4, $5)"#,
|
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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
|
@ -297,15 +297,15 @@ async fn request_revision(
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
sqlx::query!(
|
sqlx::query(
|
||||||
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
|
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
VALUES ($1, $2, $3, $4, $5)"#,
|
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)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue