feat: update DB schema - split users.first_name, users.last_name, roles split

This commit is contained in:
Tracewebstudio Dev 2026-04-15 06:23:27 +02:00
parent 92ded2b43d
commit a3076ed526
38 changed files with 1324 additions and 1595 deletions

View file

@ -258,7 +258,7 @@ async fn submit_job(
Ok(updated) => {
// Fire email to company user (ignore failures)
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await {
let _ = state.mail.send_job_submitted_email(&user.email, user.name.as_deref().unwrap_or("User"), &updated.title).await;
let _ = state.mail.send_job_submitted_email(&user.email, &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &updated.title).await;
}
// Create verification case so the request appears in Verification Management first.
@ -367,7 +367,7 @@ async fn update_application_status(
Ok(updated) => {
// Notify applicant of status change (ignore failures)
let applicant_info = sqlx::query_as::<_, (String, String)>(
"SELECT u.name, u.email FROM users u WHERE u.id = $1",
"SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone FROM users u WHERE u.id = $1",
)
.bind(app.applicant_user_id)
.fetch_optional(&state.pool)
@ -439,7 +439,7 @@ async fn view_contact(
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
r#"
SELECT u.name, u.email, u.phone
SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone
FROM users u
WHERE u.id = $1
"#,

View file

@ -28,7 +28,7 @@ pub async fn expire_stale_jobs(
WHERE jobs.company_id = c.id
AND jobs.status = 'LIVE'
AND jobs.expires_at < $1
RETURNING jobs.id as job_id, jobs.title, u.email, u.name
RETURNING jobs.id as job_id, jobs.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name
"#
)
.bind(now)

View file

@ -26,7 +26,7 @@ pub async fn expire_stale_lead_requests(
lr.tracecoins_reserved,
urp.user_id,
u.email,
u.name
CONCAT(u.first_name, ' ', u.last_name) AS name
FROM lead_requests lr
INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id
INNER JOIN users u ON u.id = urp.user_id

View file

@ -26,7 +26,7 @@ pub async fn expire_stale_leads(
WHERE leads.created_by_user_id = u.id
AND leads.status = 'OPEN'
AND leads.expires_at < $1
RETURNING leads.id as lead_id, leads.title, u.email, u.name
RETURNING leads.id as lead_id, leads.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name
"#
)
.bind(now)

View file

@ -11,7 +11,7 @@ pub struct AdminLeadRow {
pub description: Option<String>,
pub profession_key: String,
pub location: String,
pub budget: Option<i32>,
pub budget_inr: Option<i32>,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
@ -25,7 +25,7 @@ impl From<Requirement> for AdminLeadRow {
description: Some(r.description),
profession_key: r.profession_key,
location: r.location,
budget: r.budget,
budget_inr: r.budget_inr,
status: r.status,
created_at: r.created_at,
updated_at: r.updated_at,

View file

@ -132,8 +132,8 @@ async fn create_requirement(
title: payload.title,
description: payload.description,
location: payload.location,
budget: payload.budget,
preferred_date: p_date,
budget_inr: payload.budget,
required_date: p_date,
extra_data_json: payload.extra_data_json,
};
@ -190,7 +190,7 @@ async fn submit_requirement(
Ok(updated) => {
// Fire email to customer (ignore failures)
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await {
let _ = state.mail.send_requirement_submitted_email(&user.email, user.name.as_deref().unwrap_or("User"), &updated.title).await;
let _ = state.mail.send_requirement_submitted_email(&user.email, &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &updated.title).await;
}
// Create verification case so this request enters Verification Management first.
@ -200,7 +200,7 @@ async fn submit_requirement(
"title": updated.title,
"profession_key": updated.profession_key,
"location": updated.location,
"budget": updated.budget,
"budget_inr": updated.budget_inr,
"status": updated.status,
"created_by_user_id": updated.created_by_user_id,
});

View file

@ -245,19 +245,19 @@ async fn apply_to_job(
// Send email notification to company
// Get company user details via raw query
let company_user = sqlx::query_as::<_, (String, Option<String>)>(
"SELECT u.email, u.name FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1"
"SELECT u.email, CONCAT(u.first_name, ' ', u.last_name) AS name FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1"
)
.bind(job.company_id)
.fetch_optional(&state.pool)
.await;
if let Ok(Some((email, name))) = company_user {
let seeker_name = seeker.full_name.as_deref().unwrap_or("A candidate");
let seeker_name = format!("{} {}", seeker.first_name.unwrap_or_default(), seeker.last_name.unwrap_or_default());
let _ = state.mail.send_new_application_email(
&email,
name.as_deref().unwrap_or("Company"),
&job.title,
seeker_name
&seeker_name
).await;
}

View file

@ -31,7 +31,8 @@ pub struct ListQuery {
pub struct AdminUserRow {
pub id: Uuid,
pub email: String,
pub full_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub roles: Vec<String>,
@ -49,12 +50,12 @@ async fn list_users(
// Generic list: users + their approved roles
r#"
SELECT
u.id, u.email, u.name, u.status, u.created_at,
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
COALESCE(array_agg(r.key) FILTER (WHERE r.key IS NOT NULL), '{}') as roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
LEFT JOIN roles r ON r.id = ur.role_id
WHERE ($1 = '' OR LOWER(u.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT 100
@ -80,11 +81,11 @@ async fn list_users(
format!(
r#"
SELECT
u.id, u.email, u.name, p.status, u.created_at,
u.id, u.email, u.first_name, u.last_name, p.status, u.created_at,
ARRAY['{}']::text[] as roles
FROM users u
JOIN {} p ON p.user_id = u.id
WHERE ($1 = '' OR LOWER(u.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC
LIMIT 100
"#,
@ -110,12 +111,12 @@ async fn list_customers(
let sql = r#"
SELECT
u.id, u.email, u.name, u.status, u.created_at,
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['CUSTOMER']::text[] as roles
FROM users u
JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN roles r ON r.id = ur.role_id AND r.key = 'CUSTOMER'
WHERE ($1 = '' OR LOWER(u.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC
LIMIT 50
"#;
@ -138,12 +139,12 @@ async fn list_candidates(
let sql = r#"
SELECT
u.id, u.email, u.name, u.status, u.created_at,
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['JOB_SEEKER']::text[] as roles
FROM users u
JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN roles r ON r.id = ur.role_id AND r.key = 'JOB_SEEKER'
WHERE ($1 = '' OR LOWER(u.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC
LIMIT 50
"#;

View file

@ -94,7 +94,7 @@ async fn get_submission(
Json(serde_json::json!({
"user": {
"id": user.id,
"name": user.name,
"name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
"email": user.email,
"phone": null,
"status": user.status,
@ -218,7 +218,7 @@ async fn activate_profile_after_final_approval(
};
let query = format!(
"UPDATE {} SET verification_status = 'APPROVED', updated_at = NOW() WHERE id = $1",
"UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE id = $1",
table
);
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
@ -243,13 +243,10 @@ async fn activate_profile_after_final_approval(
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state
.mail
.send_approval_approved_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&display,
)
.send_approval_approved_email(&user.email, &user_name, &display)
.await;
}
@ -292,18 +289,19 @@ async fn reject_profile_after_final_approval(
};
let query = format!(
"UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1",
"UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1",
table
);
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state
.mail
.send_approval_rejected_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&user_name,
&display,
reason.unwrap_or("Rejected by final approval"),
)
@ -440,7 +438,7 @@ async fn approve_job(
.await;
let company_info = sqlx::query_as::<_, (String, String)>(
"SELECT u.name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
"SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email FROM company_profiles c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
)
.bind(existing.company_id)
.fetch_optional(&state.pool)
@ -490,7 +488,7 @@ async fn reject_job(
.await;
let company_info = sqlx::query_as::<_, (String, String)>(
"SELECT u.name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
"SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email FROM company_profiles c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
)
.bind(existing.company_id)
.fetch_optional(&state.pool)

View file

@ -40,8 +40,6 @@ pub struct RegisterPayload {
#[serde(default)]
pub last_name: Option<String>,
#[serde(default)]
pub full_name: Option<String>,
#[serde(default)]
pub name: Option<String>,
pub email: String,
pub phone: Option<String>,
@ -179,7 +177,7 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid
return None;
}
if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE code = $1")
if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
.bind(&normalized)
.fetch_optional(pool)
.await
@ -190,20 +188,29 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid
}
let display_name = role_display_name_from_code(&normalized);
sqlx::query_scalar::<_, Uuid>(
let role_id = sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO roles (name, code, audience, is_active)
VALUES ($1, $2, 'USER', true)
ON CONFLICT (code)
DO UPDATE SET updated_at = NOW()
INSERT INTO roles (key, name, audience, is_active)
VALUES ($1, $2, 'EXTERNAL', true)
ON CONFLICT (key)
DO UPDATE SET is_active = true
RETURNING id
"#,
)
.bind(&normalized)
.bind(display_name)
.bind(normalized)
.fetch_one(pool)
.await
.ok()
.ok()?;
let _ = sqlx::query(
"INSERT INTO external_roles (role_id) VALUES ($1) ON CONFLICT (role_id) DO NOTHING",
)
.bind(role_id)
.execute(pool)
.await;
Some(role_id)
}
// ── Handlers ──────────────────────────────────────────────────────────────────
@ -264,15 +271,12 @@ async fn register(
let password_hash = hash_password(&payload.password)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
let full_name = match (&payload.first_name, &payload.last_name, &payload.full_name, &payload.name) {
(Some(fn_), Some(ln_), _, _) => format!("{} {}", fn_.trim(), ln_.trim()).trim().to_string(),
(_, _, Some(fn_), _) => fn_.trim().to_string(),
(_, _, _, Some(n)) => n.trim().to_string(),
_ => return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Name is required (full_name or first_name+last_name)", "VALIDATION_ERROR")),
};
let first_name = payload.first_name.unwrap_or_default().trim().to_string();
let last_name = payload.last_name.unwrap_or_default().trim().to_string();
let user = UserRepository::create(&state.pool, CreateUserPayload {
name: full_name,
first_name: Some(first_name),
last_name: Some(last_name),
email: email.clone(),
password_hash,
})
@ -332,13 +336,14 @@ async fn register(
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
let _ = state.mail.send_verification_email(&user.email, &user.name.clone().unwrap_or_default(), &otp).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await;
Ok((StatusCode::CREATED, Json(RegisterResponse {
user_id: user.id.to_string(),
email: user.email,
phone: None,
name: user.name.unwrap_or_default(),
name: user_name,
status: user.status,
email_verified: user.email_verified,
created_at: user.created_at.to_rfc3339(),
@ -400,6 +405,7 @@ async fn login(
);
let active_role = user_roles.first().cloned();
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({
"access_token": tokens.access_token,
"token_type": "Bearer",
@ -407,7 +413,7 @@ async fn login(
"user": {
"id": user.id.to_string(),
"email": user.email,
"full_name": user.name.unwrap_or_default(),
"name": user_name,
"email_verified": user.email_verified,
"active_role": active_role,
"roles": user_roles,
@ -516,10 +522,11 @@ async fn session(
.await
.unwrap_or_default();
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
Ok(Json(SessionUser {
id: user.id.to_string(),
email: user.email,
name: user.name.unwrap_or_default(),
name: user_name,
email_verified: user.email_verified,
active_role: user_roles.first().cloned(),
roles: user_roles,
@ -549,7 +556,8 @@ async fn verify_email(
// Get user details for welcome email
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let _ = state.mail.send_welcome_email(&user.email, &user.name.unwrap_or_default()).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_welcome_email(&user.email, &user_name).await;
}
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" }))))
@ -585,7 +593,8 @@ async fn resend_otp(
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
let _ = state.mail.send_verification_email(&user.email, &user.name.unwrap_or_default(), &otp).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await;
Ok(silent_ok)
}
@ -610,7 +619,8 @@ async fn forgot_password(
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
let _ = state.mail.send_password_reset_email(&user.email, &user.name.unwrap_or_default(), &token).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_password_reset_email(&user.email, &user_name, &token).await;
Ok(silent_ok)
}
@ -644,7 +654,8 @@ async fn reset_password(
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let _ = state.mail.send_password_changed_email(&user.email, user.name.as_deref().unwrap_or_default()).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_password_changed_email(&user.email, &user_name).await;
}
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
@ -677,7 +688,8 @@ async fn change_password(
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
let _ = state.mail.send_password_changed_email(&user.email, user.name.as_deref().unwrap_or_default()).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_password_changed_email(&user.email, &user_name).await;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" }))))
}

View file

@ -284,7 +284,7 @@ async fn get_my_runtime_config(
"user".to_string(),
serde_json::json!({
"id": user.id.to_string(),
"name": user.name.unwrap_or_default(),
"name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
"email": user.email,
"roles": roles,
"active_role": role_key,

View file

@ -125,7 +125,7 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
let recent_leads = sqlx::query_as::<_, LeadRow>(
r#"
SELECT r.id, r.title, r.status, r.created_at,
u.name AS requester_name
CONCAT(u.first_name, ' ', u.last_name) AS requester_name
FROM leads r
LEFT JOIN users u ON u.id = r.created_by_user_id
WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED')

View file

@ -20,9 +20,9 @@ pub fn router() -> Router<AppState> {
#[derive(Deserialize)]
struct ListQuery {
q: Option<String>,
status: Option<String>, // ACTIVE | INACTIVE
vertical: Option<String>, // jobs | marketplace
category: Option<String>, // provider | employer | consumer | specialist
status: Option<String>,
vertical: Option<String>,
category: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
@ -83,7 +83,6 @@ async fn list_external_roles(
let vertical = q.vertical.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
let rows = sqlx::query_as::<_, ExternalRoleListRow>(
r#"
SELECT
@ -95,8 +94,8 @@ async fn list_external_roles(
rc.updated_at as "updated_at",
rc.config_json as "config_json"
FROM roles r
LEFT JOIN runtime_configs rc
ON rc.role_id = r.id AND rc.is_active = true
JOIN external_roles er ON er.role_id = r.id
LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.audience = 'EXTERNAL'
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))
@ -112,11 +111,11 @@ async fn list_external_roles(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Compute total with same filters
let total: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM roles r
JOIN external_roles er ON er.role_id = r.id
WHERE r.audience = 'EXTERNAL'
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))
@ -149,14 +148,12 @@ async fn list_external_roles(
assigned_user_types = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
}
}
// Additional filters by vertical/category after extracting from config
if !vertical.is_empty() && vertical_v.as_deref() != Some(vertical.as_str()) {
continue;
}
if !category.is_empty() && category_v.as_deref() != Some(category.as_str()) {
continue;
}
// Count assigned users from user_roles (approved)
let assigned_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_roles WHERE role_id = $1 AND status = 'APPROVED'",
)
@ -223,8 +220,10 @@ async fn get_external_role(
}
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
r#"
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at, rc.updated_at as updated_at, rc.config_json as config_json
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at,
rc.updated_at as updated_at, rc.config_json as config_json
FROM roles r
JOIN external_roles er ON er.role_id = r.id
LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
"#,
@ -252,7 +251,7 @@ struct CreateExternalRolePayload {
name: String,
code: String,
is_active: Option<bool>,
runtime: JsonValue, // carries vertical/category/modules/permissions/assigned_user_types/requires/feature_limits/onboarding_schema_id
runtime: JsonValue,
}
#[derive(sqlx::FromRow)]
@ -279,7 +278,6 @@ async fn create_external_role(
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let is_active = payload.is_active.unwrap_or(true);
// Insert role
let role = sqlx::query_as::<_, InsertedRole>(
r#"
INSERT INTO roles (key, name, audience, is_active)
@ -294,7 +292,14 @@ async fn create_external_role(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Create runtime config version 1
sqlx::query(
"INSERT INTO external_roles (role_id) VALUES ($1)",
)
.bind(role.id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let rc = sqlx::query_as::<_, InsertedRc>(
r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
@ -339,7 +344,6 @@ async fn update_external_role(
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
// Update role basic fields
if payload.name.is_some() || payload.is_active.is_some() {
sqlx::query(
r#"
@ -356,7 +360,6 @@ async fn update_external_role(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
// Create a new runtime config version if provided
if let Some(runtime) = payload.runtime {
sqlx::query(
r#"

View file

@ -132,7 +132,7 @@ struct AdminArticleRow {
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
is_published: bool,
status: String,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
@ -149,7 +149,7 @@ struct InsertedArticleRow {
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
is_published: bool,
status: String,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
@ -227,7 +227,7 @@ async fn public_list_articles(
c.name AS category_name, c.slug AS category_slug
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE a.is_published = true
WHERE a.status = 'PUBLISHED'
AND c.is_active = true
AND ($1 = '' OR c.slug = $1)
AND ($2 = '' OR $2 = 'ALL'
@ -294,7 +294,7 @@ async fn public_get_article(
c.name AS category_name, c.slug AS category_slug
FROM kb_articles a
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.status = 'PUBLISHED' AND c.is_active = true
"#,
)
.bind(&slug)
@ -569,26 +569,26 @@ async fn admin_list_articles(
Query(params): Query<AdminArticleQuery>,
) -> impl IntoResponse {
let q = params.q.as_deref().unwrap_or("").to_lowercase();
let published_filter: Option<bool> = params.status.as_deref().map(|s| s == "PUBLISHED");
let status_filter: Option<String> = params.status.as_deref().map(|s| s.to_string());
let rows = sqlx::query_as::<_, AdminArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
a.is_published, a.views, a.category_id, a.created_at, a.updated_at,
a.status, a.views, a.category_id, a.created_at, a.updated_at,
c.name AS category_name
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE ($1 = '' OR LOWER(a.title) LIKE '%' || $1 || '%')
AND ($2::uuid IS NULL OR a.category_id = $2)
AND ($3::bool IS NULL OR a.is_published = $3)
AND ($3::text IS NULL OR a.status = $3)
ORDER BY a.updated_at DESC
LIMIT 200
"#,
)
.bind(&q)
.bind(params.category_id)
.bind(published_filter)
.bind(status_filter)
.fetch_all(&state.pool)
.await;
@ -604,7 +604,7 @@ async fn admin_list_articles(
category_id: Some(r.category_id),
category: Some(r.category_name),
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
@ -646,16 +646,16 @@ async fn admin_create_article(
.slug
.filter(|s| !s.is_empty())
.unwrap_or_else(|| slugify(&body.title));
let is_published = body.status.as_deref() == Some("PUBLISHED");
let status = body.status.as_deref().unwrap_or("DRAFT").to_string();
let roles: Vec<String> = body.target_roles.unwrap_or_default();
let tags: Vec<String> = body.tags.unwrap_or_default();
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
INSERT INTO kb_articles
(title, slug, summary, body, category_id, is_published, target_roles, tags, created_by)
(title, slug, summary, body, category_id, status, target_roles, tags, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, title, slug, summary, body, category_id, is_published,
RETURNING id, title, slug, summary, body, category_id, status,
target_roles, tags, views, created_at, updated_at
"#,
)
@ -664,7 +664,7 @@ async fn admin_create_article(
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(is_published)
.bind(&status)
.bind(&roles)
.bind(&tags)
.bind(auth.user_id)
@ -682,7 +682,7 @@ async fn admin_create_article(
category_id: Some(r.category_id),
category: None,
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
@ -721,7 +721,7 @@ async fn admin_get_article(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.category_id,
a.target_roles, a.tags, a.is_published, a.views,
a.target_roles, a.tags, a.status, a.views,
a.created_at, a.updated_at,
c.name AS category_name
FROM kb_articles a
@ -744,7 +744,7 @@ async fn admin_get_article(
category_id: Some(r.category_id),
category: Some(r.category_name),
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
@ -787,7 +787,7 @@ async fn admin_update_article(
Path(id): Path<Uuid>,
Json(body): Json<UpdateArticleBody>,
) -> impl IntoResponse {
let is_published: Option<bool> = body.status.as_deref().map(|s| s == "PUBLISHED");
let status: Option<String> = body.status.as_deref().map(|s| s.to_string());
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
UPDATE kb_articles SET
@ -796,13 +796,13 @@ async fn admin_update_article(
summary = COALESCE($4, summary),
body = COALESCE($5, body),
category_id = COALESCE($6, category_id),
is_published = COALESCE($7, is_published),
status = COALESCE($7, status),
target_roles = COALESCE($8, target_roles),
tags = COALESCE($9, tags),
updated_at = NOW()
WHERE id = $1
RETURNING id, title, slug, summary, body, category_id,
target_roles, tags, is_published, views, created_at, updated_at
target_roles, tags, status, views, created_at, updated_at
"#,
)
.bind(id)
@ -811,7 +811,7 @@ async fn admin_update_article(
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(is_published)
.bind(&status)
.bind(body.target_roles.as_deref())
.bind(body.tags.as_deref())
.fetch_optional(&state.pool)
@ -828,7 +828,7 @@ async fn admin_update_article(
category_id: Some(r.category_id),
category: None,
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,

View file

@ -173,12 +173,11 @@ async fn submit(
let query = format!(
r#"
INSERT INTO {} (id, "profileData", verification_status, submitted_at, updated_at)
VALUES ($1, $2, 'PENDING', NOW(), NOW())
INSERT INTO {} (id, custom_data, status, updated_at)
VALUES ($1, $2, 'PENDING', NOW())
ON CONFLICT (id) DO UPDATE SET
"profileData" = EXCLUDED."profileData",
verification_status = 'PENDING',
submitted_at = NOW(),
custom_data = EXCLUDED.custom_data,
status = 'PENDING',
updated_at = NOW()
"#,
tbl
@ -194,11 +193,11 @@ async fn submit(
// Simple companies upsert (using basic fields if possible)
sqlx::query(
r#"
INSERT INTO companies ("userId", status, "updatedAt")
INSERT INTO company_profiles (user_id, status, updated_at)
VALUES ($1, 'PENDING', NOW())
ON CONFLICT ("userId") DO UPDATE SET
ON CONFLICT (user_id) DO UPDATE SET
status = 'PENDING',
"updatedAt" = NOW()
updated_at = NOW()
"#,
)
.bind(auth.user_id)
@ -211,7 +210,7 @@ async fn submit(
sqlx::query(
r#"
UPDATE user_roles
SET status = 'PENDING', updated_at = NOW()
SET status = 'PENDING'
WHERE user_id = $1 AND role_id = $2
"#,
)
@ -283,15 +282,14 @@ async fn get_or_create_user_role_profile_id(
sqlx::query_scalar::<_, uuid::Uuid>(
r#"
INSERT INTO user_role_profiles (user_id, role_key, role_id, status)
VALUES ($1, $2, $3, 'DRAFT')
INSERT INTO user_role_profiles (user_id, role_key, status)
VALUES ($1, $2, 'DRAFT')
ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW()
RETURNING id
"#,
)
.bind(user_id)
.bind(role_key)
.bind(role_id)
.fetch_one(pool)
.await
}

View file

@ -115,7 +115,7 @@ async fn get_profile(
if role_key == "COMPANY" {
let row = sqlx::query(
r#"SELECT name, status, "updatedAt" FROM companies WHERE "userId" = $1"#,
r#"SELECT company_name, status, updated_at FROM company_profiles WHERE user_id = $1"#,
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
@ -124,7 +124,7 @@ async fn get_profile(
return match row {
Ok(Some(r)) => {
use sqlx::Row;
let name: Option<String> = r.try_get("name").ok();
let name: Option<String> = r.try_get("company_name").ok();
let status: String = r.try_get("status").unwrap_or_default();
(
StatusCode::OK,
@ -161,7 +161,7 @@ async fn get_profile(
};
let query = format!(
r#"SELECT "profileData", verification_status FROM {} WHERE id = $1"#,
r#"SELECT custom_data, status FROM {} WHERE id = $1"#,
table
);
@ -189,10 +189,10 @@ async fn get_profile(
Ok(Some(row)) => {
use sqlx::Row;
let profile_data: serde_json::Value = row
.try_get("profileData")
.try_get("custom_data")
.unwrap_or(serde_json::Value::Null);
let verification_status: String =
row.try_get("verification_status").unwrap_or_default();
row.try_get("status").unwrap_or_default();
(
StatusCode::OK,
Json(serde_json::json!({
@ -234,11 +234,11 @@ async fn save_profile(
return match sqlx::query(
r#"
INSERT INTO companies ("userId", name, status, "updatedAt")
INSERT INTO company_profiles (user_id, company_name, status, updated_at)
VALUES ($1, $2, 'DRAFT', NOW())
ON CONFLICT ("userId") DO UPDATE SET
name = EXCLUDED.name,
"updatedAt" = NOW()
ON CONFLICT (user_id) DO UPDATE SET
company_name = EXCLUDED.company_name,
updated_at = NOW()
"#,
)
.bind(auth.user_id)
@ -268,10 +268,10 @@ async fn save_profile(
let query = format!(
r#"
INSERT INTO {table} (id, "profileData", verification_status, updated_at)
INSERT INTO {table} (id, custom_data, status, updated_at)
VALUES ($1, $2, 'DRAFT', NOW())
ON CONFLICT (id) DO UPDATE SET
"profileData" = EXCLUDED."profileData",
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
"#
);
@ -441,14 +441,14 @@ async fn fetch_saved_profile(
role_key: &str,
) -> serde_json::Value {
if role_key == "COMPANY" {
return match sqlx::query(r#"SELECT name FROM companies WHERE "userId" = $1"#)
return match sqlx::query(r#"SELECT company_name FROM company_profiles WHERE user_id = $1"#)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(r)) => {
use sqlx::Row;
let name: Option<String> = r.try_get("name").ok();
let name: Option<String> = r.try_get("company_name").ok();
serde_json::json!({ "company_name": name })
}
_ => serde_json::Value::Object(Default::default()),
@ -465,7 +465,7 @@ async fn fetch_saved_profile(
async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, status: &str) {
if role_key == "COMPANY" {
sqlx::query(
r#"UPDATE companies SET status = $1, "updatedAt" = NOW() WHERE "userId" = $2"#,
r#"UPDATE company_profiles SET status = $1, updated_at = NOW() WHERE user_id = $2"#,
)
.bind(status)
.bind(user_id)
@ -483,7 +483,7 @@ async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, sta
if let Some(table) = role_to_table(role_key) {
let q = format!(
"UPDATE {} SET verification_status = $1, submitted_at = NOW(), updated_at = NOW() WHERE id = $2",
"UPDATE {} SET status = $1, updated_at = NOW() WHERE id = $2",
table
);
sqlx::query(&q)
@ -525,15 +525,14 @@ async fn get_or_create_user_role_profile_id(
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO user_role_profiles (user_id, role_key, role_id, status)
VALUES ($1, $2, $3, 'DRAFT')
INSERT INTO user_role_profiles (user_id, role_key, status)
VALUES ($1, $2, 'DRAFT')
ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW()
RETURNING id
"#,
)
.bind(user_id)
.bind(role_key)
.bind(role.id)
.fetch_one(pool)
.await
}
@ -544,7 +543,7 @@ async fn fetch_saved_profile_by_urp_id(
role_key: &str,
) -> serde_json::Value {
if let Some(table) = role_to_table(role_key) {
let q = format!(r#"SELECT "profileData" FROM {} WHERE id = $1"#, table);
let q = format!(r#"SELECT custom_data FROM {} WHERE id = $1"#, table);
if let Ok(Some(row)) = sqlx::query(&q)
.bind(user_role_profile_id)
.fetch_optional(&state.pool)
@ -552,7 +551,7 @@ async fn fetch_saved_profile_by_urp_id(
{
use sqlx::Row;
return row
.try_get::<serde_json::Value, _>("profileData")
.try_get::<serde_json::Value, _>("custom_data")
.unwrap_or(serde_json::Value::Object(Default::default()));
}
}

View file

@ -31,7 +31,6 @@ struct ReviewDto {
title: Option<String>,
comment: Option<String>,
status: String,
is_published: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@ -48,7 +47,6 @@ struct CreateReviewBody {
#[derive(Deserialize)]
struct PatchReviewBody {
status: Option<String>,
is_published: Option<bool>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
@ -64,7 +62,6 @@ struct ReviewRow {
title: Option<String>,
comment: Option<String>,
status: String,
is_published: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@ -81,12 +78,11 @@ async fn admin_list_reviews(
r.subject_type,
r.subject_id,
r.reviewer_name,
r.customer_id AS reviewer_id,
r.reviewer_user_id AS reviewer_id,
r.rating,
r.title,
r.comment,
r.status,
r.is_published,
r.created_at
FROM reviews r
ORDER BY r.created_at DESC
@ -109,7 +105,6 @@ async fn admin_list_reviews(
title: r.title,
comment: r.comment,
status: r.status,
is_published: r.is_published,
created_at: r.created_at,
})
.collect();
@ -136,10 +131,10 @@ async fn admin_create_review(
let row = sqlx::query_as::<_, ReviewRow>(
r#"
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status, is_published)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
RETURNING id, subject_type, subject_id, reviewer_name, customer_id AS reviewer_id,
rating, title, comment, status, is_published, created_at
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id,
rating, title, comment, status, created_at
"#,
)
.bind(&subject_type)
@ -164,7 +159,6 @@ async fn admin_create_review(
title: r.title,
comment: r.comment,
status: r.status,
is_published: r.is_published,
created_at: r.created_at,
};
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
@ -182,24 +176,13 @@ async fn admin_update_review(
Path(id): Path<Uuid>,
Json(body): Json<PatchReviewBody>,
) -> impl IntoResponse {
// Derive is_published from status string, or use explicit field
let (status, published) = match (body.status.as_deref(), body.is_published) {
(Some("PUBLISHED"), _) => ("PUBLISHED".to_string(), true),
(Some("HIDDEN"), _) => ("HIDDEN".to_string(), false),
(Some(s), _) => (s.to_string(), false),
(None, Some(p)) => {
if p { ("PUBLISHED".to_string(), true) } else { ("HIDDEN".to_string(), false) }
}
(None, None) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Provide status or is_published" }))).into_response();
}
};
let status = body.status.as_deref().unwrap_or("PUBLISHED").to_string();
let result = sqlx::query(
"UPDATE reviews SET status = $1, is_published = $2, updated_at = NOW() WHERE id = $3",
"UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2",
)
.bind(&status)
.bind(published)
.bind(id)
.bind(id)
.execute(&state.pool)
.await;

View file

@ -15,18 +15,13 @@ pub fn router() -> Router<AppState> {
.route("/{id}", get(get_role).patch(update_role).delete(delete_role))
}
// ── Query params ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ListQuery {
audience: Option<String>,
q: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
// ── Response types ───────────────────────────────────────────────────────────
#[derive(Serialize)]
struct RoleRow {
id: Uuid,
@ -68,13 +63,10 @@ struct RoleDetail {
created_at: chrono::DateTime<chrono::Utc>,
}
// ── Request types ────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct CreateRolePayload {
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: Option<bool>,
@ -94,8 +86,6 @@ struct UpdateRolePayload {
permission_keys: Option<Vec<String>>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct RoleListRow {
id: Uuid,
@ -134,11 +124,7 @@ struct InsertedRoleRow {
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>,
}
@ -152,8 +138,6 @@ struct CurrentRoleRow {
can_manage_system_settings: bool,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
async fn list_roles(
State(state): State<AppState>,
Query(params): Query<ListQuery>,
@ -162,36 +146,35 @@ async fn list_roles(
let per_page = params.per_page.unwrap_or(20).min(100);
let offset = (page - 1) * per_page;
let search = params.q.as_deref().unwrap_or("").to_lowercase();
let audience = params.audience.as_deref().unwrap_or("").to_string();
let rows = sqlx::query_as::<_, RoleListRow>(
r#"
SELECT
r.id,
r.code AS key,
r.key,
r.name,
r.audience,
r.description,
r.department_id,
ir.description,
ir.department_id,
d.name AS department_name,
r.is_active,
r.can_approve_requests,
r.can_manage_system_settings,
COALESCE(ir.can_approve_requests, false) AS can_approve_requests,
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings,
r.created_at,
COUNT(DISTINCT e.id) AS users_assigned,
COUNT(DISTINCT rp.id) AS permissions_count
FROM roles r
LEFT JOIN departments d ON d.id = r.department_id
LEFT JOIN employees e ON e.role_code = r.code
JOIN internal_roles ir ON ir.role_id = r.id
LEFT JOIN departments d ON d.id = ir.department_id
LEFT JOIN employees e ON e.role_code = r.key
LEFT JOIN role_permissions rp ON rp.role_id = r.id
WHERE ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%')
GROUP BY r.id, d.name
WHERE r.audience = 'INTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
GROUP BY r.id, ir.description, ir.department_id, ir.can_approve_requests, ir.can_manage_system_settings, d.name
ORDER BY r.created_at DESC
LIMIT $3 OFFSET $4
LIMIT $2 OFFSET $3
"#,
)
.bind(&audience)
.bind(&search)
.bind(per_page)
.bind(offset)
@ -202,11 +185,11 @@ async fn list_roles(
let total: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*) FROM roles r
WHERE ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%')
JOIN internal_roles ir ON ir.role_id = r.id
WHERE r.audience = 'INTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
"#,
)
.bind(&audience)
.bind(&search)
.fetch_one(&state.pool)
.await
@ -241,13 +224,17 @@ async fn get_role(
let row = sqlx::query_as::<_, RoleDetailRow>(
r#"
SELECT
r.id, r.code AS key, r.name, r.audience, r.description,
r.department_id, d.name AS department_name,
r.is_active, r.can_approve_requests, r.can_manage_system_settings,
r.id, r.key, r.name, r.audience,
ir.description,
ir.department_id, d.name AS department_name,
r.is_active,
COALESCE(ir.can_approve_requests, false) AS can_approve_requests,
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings,
r.created_at
FROM roles r
LEFT JOIN departments d ON d.id = r.department_id
WHERE r.id = $1
JOIN internal_roles ir ON ir.role_id = r.id
LEFT JOIN departments d ON d.id = ir.department_id
WHERE r.id = $1 AND r.audience = 'INTERNAL'
"#,
)
.bind(id)
@ -290,24 +277,33 @@ async fn create_role(
let role = sqlx::query_as::<_, InsertedRoleRow>(
r#"
INSERT INTO roles (code, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, code AS key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at
INSERT INTO roles (key, name, audience, is_active)
VALUES ($1, $2, 'INTERNAL', $3)
RETURNING id, key, name, audience, is_active, created_at
"#,
)
.bind(&payload.key)
.bind(&payload.name)
.bind(&payload.audience)
.bind(&payload.description)
.bind(payload.department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Insert permission keys
sqlx::query(
r#"
INSERT INTO internal_roles (role_id, description, department_id, can_approve_requests, can_manage_system_settings)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(role.id)
.bind(&payload.description)
.bind(payload.department_id)
.bind(can_approve)
.bind(can_manage)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
if let Some(keys) = &payload.permission_keys {
for key in keys {
sqlx::query(
@ -336,12 +332,12 @@ async fn create_role(
key: role.key,
name: role.name,
audience: role.audience,
description: role.description,
department_id: role.department_id,
description: payload.description,
department_id: payload.department_id,
department_name: None,
is_active: role.is_active,
can_approve_requests: role.can_approve_requests,
can_manage_system_settings: role.can_manage_system_settings,
can_approve_requests: can_approve,
can_manage_system_settings: can_manage,
permission_keys,
created_at: role.created_at,
}),
@ -353,9 +349,15 @@ async fn update_role(
Path(id): Path<Uuid>,
Json(payload): Json<UpdateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// Fetch current values first
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",
r#"
SELECT r.name, ir.description, ir.department_id, r.is_active,
COALESCE(ir.can_approve_requests, false) AS can_approve_requests,
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings
FROM roles r
JOIN internal_roles ir ON ir.role_id = r.id
WHERE r.id = $1 AND r.audience = 'INTERNAL'
"#,
)
.bind(id)
.fetch_optional(&state.pool)
@ -364,28 +366,35 @@ async fn update_role(
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
let name = payload.name.unwrap_or(current.name);
let is_active = payload.is_active.unwrap_or(current.is_active);
sqlx::query(
"UPDATE roles SET name = $1, is_active = $2 WHERE id = $3",
)
.bind(&name)
.bind(is_active)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let description = payload.description.or(current.description);
let department_id = payload.department_id.or(current.department_id);
let is_active = payload.is_active.unwrap_or(current.is_active);
let can_approve = payload.can_approve_requests.unwrap_or(current.can_approve_requests);
let can_manage = payload.can_manage_system_settings.unwrap_or(current.can_manage_system_settings);
sqlx::query(
r#"
UPDATE roles SET
name = $1,
description = $2,
department_id = $3,
is_active = $4,
can_approve_requests = $5,
can_manage_system_settings = $6
WHERE id = $7
UPDATE internal_roles SET
description = $1,
department_id = $2,
can_approve_requests = $3,
can_manage_system_settings = $4
WHERE role_id = $5
"#,
)
.bind(name)
.bind(description)
.bind(&description)
.bind(department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.bind(id)
@ -393,7 +402,6 @@ async fn update_role(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Replace permissions if provided
if let Some(keys) = &payload.permission_keys {
sqlx::query("DELETE FROM role_permissions WHERE role_id = $1")
.bind(id)
@ -413,7 +421,6 @@ async fn update_role(
}
}
// Return updated role
get_role(State(state), Path(id)).await
}
@ -421,7 +428,7 @@ async fn delete_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let result = sqlx::query("DELETE FROM roles WHERE id = $1")
let result = sqlx::query("DELETE FROM roles WHERE id = $1 AND audience = 'INTERNAL'")
.bind(id)
.execute(&state.pool)
.await

View file

@ -225,7 +225,7 @@ async fn create_delete_account_request(
.mail
.send_account_deleted_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
)
.await;
let _ = sqlx::query(

View file

@ -137,7 +137,7 @@ async fn user_create_ticket(
};
let _ = state.mail.send_support_ticket_created_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
&r.id.to_string(),
&body.subject,
&category,
@ -444,14 +444,10 @@ async fn admin_list_cases(
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
u.name AS user_name, u.email AS user_email
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id
WHERE ($1 = '' OR t.status = $1)
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
WHERE t.id = $1
"#,
)
.bind(&status_filter)
@ -586,10 +582,14 @@ async fn admin_get_case(
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
u.name AS user_name, u.email AS user_email
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = $1
WHERE ($1 = '' OR t.status = $1)
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#,
)
.bind(id)
@ -832,7 +832,7 @@ async fn admin_add_message(
if let Some(user_email) = ticket.requester_email {
// Try to get user name from user table
let user_name = if let Ok(user) = db::models::user::UserRepository::get_by_email(&state.pool, &user_email).await {
user.name.unwrap_or_default()
format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default())
} else {
ticket.requester_name.unwrap_or_default()
};

View file

@ -136,7 +136,7 @@ async fn trigger_rejection(
};
let query = format!(
"UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1",
"UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1",
table
);
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
@ -144,12 +144,8 @@ async fn trigger_rejection(
// Send Email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let _ = state.mail.send_approval_rejected_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&display,
reason_str
).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_approval_rejected_email(&user.email, &user_name, &display, reason_str).await;
}
}
@ -180,11 +176,8 @@ async fn approve_verification(
// Send approval email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let _ = state.mail.send_approval_approved_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&display
).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_approval_approved_email(&user.email, &user_name, &display).await;
}
(StatusCode::OK, Json(v)).into_response()
}
@ -294,12 +287,8 @@ async fn request_documents(
// Send email notification
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let _ = state.mail.send_documents_requested_email(
&user.email,
user.name.as_deref().unwrap_or_default(),
&display,
&payload.message
).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_documents_requested_email(&user.email, &user_name, &display, &payload.message).await;
}
(StatusCode::OK, Json(v)).into_response()

1
companies.pid Normal file
View file

@ -0,0 +1 @@
9692

View file

@ -183,7 +183,7 @@ async fn send_lead_request(
Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
};
if wallet.current_balance < 25 {
if wallet.balance < 25 {
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
}
@ -374,7 +374,7 @@ async fn my_requests(
sqlx::query_as::<_, RichLeadReq>(
r#"
SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget,
CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name,
CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name,
CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email,
CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone
FROM lead_requests lr
@ -390,7 +390,7 @@ async fn my_requests(
sqlx::query_as::<_, RichLeadReq>(
r#"
SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget,
CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name,
CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name,
CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email,
CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone
FROM lead_requests lr
@ -567,7 +567,7 @@ async fn accepted_lead_detail(
r.location AS requirement_location,
r.profession_key,
r.custom_fields,
u.name AS customer_name,
CONCAT(u.first_name, ' ', u.last_name) AS name AS customer_name,
u.email AS customer_email,
u.phone AS customer_phone
FROM lead_requests lr

View file

@ -32,6 +32,7 @@ pub struct CreateOnboardingConfigPayload {
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct DashboardConfigListItem {
pub id: Uuid,
pub role_id: Uuid,
pub role_key: String,
pub audience: String,
pub config_json: serde_json::Value,
@ -42,7 +43,7 @@ pub struct DashboardConfigListItem {
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct DashboardConfig {
pub id: Uuid,
pub role_key: String,
pub role_id: Uuid,
pub audience: String,
pub config_json: serde_json::Value,
pub is_active: bool,
@ -172,40 +173,31 @@ impl ConfigRepository {
pool: &PgPool,
payload: CreateDashboardConfigPayload,
) -> Result<DashboardConfig, sqlx::Error> {
let role_key = sqlx::query_scalar::<_, String>(
"SELECT code FROM roles WHERE id = $1",
)
.bind(payload.role_id)
.fetch_one(pool)
.await?;
// Soft-disable previous active configs for this role
sqlx::query(
r#"
UPDATE dashboard_configs
SET is_active = false
WHERE UPPER(role_key) = UPPER($1) AND audience = $2::text AND is_active = true
WHERE role_id = $1 AND audience = $2::text AND is_active = true
"#,
)
.bind(&role_key)
.bind(payload.role_id)
.bind(&payload.audience)
.execute(pool)
.await?;
// Insert new config
let config = sqlx::query_as::<_, DashboardConfig>(
r#"
INSERT INTO dashboard_configs (role_key, audience, widgets, is_active)
INSERT INTO dashboard_configs (role_id, audience, config_json, is_active)
VALUES (
$1,
$2::text,
$3,
true
)
RETURNING id, role_key, audience, widgets as config_json, is_active, updated_at
RETURNING id, role_id, audience, config_json, is_active, updated_at
"#,
)
.bind(&role_key)
.bind(payload.role_id)
.bind(&payload.audience)
.bind(payload.config_json)
.fetch_one(pool)
@ -219,21 +211,14 @@ impl ConfigRepository {
role_id: Uuid,
audience: &str,
) -> Result<DashboardConfig, sqlx::Error> {
let role_key = sqlx::query_scalar::<_, String>(
"SELECT code FROM roles WHERE id = $1",
)
.bind(role_id)
.fetch_one(pool)
.await?;
let config = sqlx::query_as::<_, DashboardConfig>(
r#"
SELECT id, role_key, audience, widgets as config_json, is_active, updated_at
SELECT id, role_id, audience, config_json, is_active, updated_at
FROM dashboard_configs
WHERE UPPER(role_key) = UPPER($1) AND audience = $2 AND is_active = true
WHERE role_id = $1 AND audience = $2 AND is_active = true
"#,
)
.bind(role_key)
.bind(role_id)
.bind(audience)
.fetch_one(pool)
.await?;
@ -247,8 +232,9 @@ impl ConfigRepository {
let configs = sqlx::query_as::<_, DashboardConfigListItem>(
r#"
SELECT
c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at
c.id, c.role_id, r.key as role_key, c.audience, c.config_json, c.is_active, c.updated_at
FROM dashboard_configs c
JOIN roles r ON c.role_id = r.id
ORDER BY c.updated_at DESC
"#,
)
@ -265,9 +251,10 @@ impl ConfigRepository {
) -> Result<DashboardConfig, sqlx::Error> {
let config = sqlx::query_as::<_, DashboardConfig>(
r#"
SELECT c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at
SELECT c.id, c.role_id, c.audience, c.config_json, c.is_active, c.updated_at
FROM dashboard_configs c
WHERE UPPER(c.role_key) = UPPER($1) AND c.audience = $2 AND c.is_active = true
JOIN roles r ON c.role_id = r.id
WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true
"#,
)
.bind(role_key)

View file

@ -7,7 +7,8 @@ use uuid::Uuid;
pub struct CustomerProfile {
pub id: Uuid,
pub user_id: Uuid,
pub full_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub phone: Option<String>,
pub city: Option<String>,
pub area: Option<String>,
@ -15,7 +16,6 @@ pub struct CustomerProfile {
pub active_requirement_count: i32,
pub status: String,
pub bio: Option<String>,
pub experience_years: Option<i32>,
pub custom_data: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@ -23,7 +23,8 @@ pub struct CustomerProfile {
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertCustomerProfilePayload {
pub full_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub phone: Option<String>,
pub city: Option<String>,
pub area: Option<String>,
@ -42,8 +43,8 @@ impl CustomerRepository {
let profile = sqlx::query_as::<_, CustomerProfile>(
r#"
SELECT
id, user_id, full_name, phone, city, area, preferred_professions,
active_requirement_count, status, bio, experience_years, custom_data,
id, user_id, first_name, last_name, phone, city, area, preferred_professions,
active_requirement_count, status, bio, custom_data,
created_at, updated_at
FROM customer_profiles
WHERE user_id = $1
@ -64,11 +65,12 @@ impl CustomerRepository {
let profile = sqlx::query_as::<_, CustomerProfile>(
r#"
INSERT INTO customer_profiles (
user_id, full_name, phone, city, area, preferred_professions, bio, custom_data, status
user_id, first_name, last_name, phone, city, area, preferred_professions, bio, custom_data, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'PENDING')
ON CONFLICT (user_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
phone = EXCLUDED.phone,
city = EXCLUDED.city,
area = EXCLUDED.area,
@ -81,13 +83,14 @@ impl CustomerRepository {
END,
updated_at = NOW()
RETURNING
id, user_id, full_name, phone, city, area, preferred_professions,
active_requirement_count, status, bio, experience_years, custom_data,
id, user_id, first_name, last_name, phone, city, area, preferred_professions,
active_requirement_count, status, bio, custom_data,
created_at, updated_at
"#,
)
.bind(user_id)
.bind(payload.full_name)
.bind(payload.first_name)
.bind(payload.last_name)
.bind(payload.phone)
.bind(payload.city)
.bind(payload.area)
@ -125,8 +128,8 @@ impl CustomerRepository {
SET status = 'PENDING_REVIEW', updated_at = NOW()
WHERE user_id = $1
RETURNING
id, user_id, full_name, phone, city, area, preferred_professions,
active_requirement_count, status, bio, experience_years, custom_data,
id, user_id, first_name, last_name, phone, city, area, preferred_professions,
active_requirement_count, status, bio, custom_data,
created_at, updated_at
"#,
)

View file

@ -12,8 +12,6 @@ pub struct Department {
pub department_head: Option<String>,
pub department_email: Option<String>,
pub is_active: bool,
pub visibility: String,
pub transfers_enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@ -25,9 +23,7 @@ pub struct CreateDepartmentPayload {
pub description: Option<String>,
pub department_head: Option<String>,
pub department_email: Option<String>,
pub status: Option<String>, // ACTIVE | INACTIVE
pub visibility: Option<String>, // INTERNAL | EXTERNAL
pub transfers_enabled: Option<bool>,
pub status: Option<String>,
}
pub struct DepartmentRepository;
@ -35,17 +31,15 @@ pub struct DepartmentRepository;
impl DepartmentRepository {
pub async fn create(pool: &PgPool, payload: CreateDepartmentPayload) -> Result<Department, sqlx::Error> {
let is_active = payload.status.map(|s| s.to_uppercase() == "ACTIVE").unwrap_or(true);
let visibility = payload.visibility.unwrap_or_else(|| "INTERNAL".to_string());
let transfers_enabled = payload.transfers_enabled.unwrap_or(false);
sqlx::query_as::<_, Department>(
r#"
INSERT INTO departments (
name, code, description, department_head, department_email,
is_active, visibility, transfers_enabled
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, code, description, department_head, department_email, is_active, created_at, updated_at
"#
)
.bind(payload.name)
@ -54,8 +48,6 @@ impl DepartmentRepository {
.bind(payload.department_head)
.bind(payload.department_email)
.bind(is_active)
.bind(visibility)
.bind(transfers_enabled)
.fetch_one(pool)
.await
}
@ -68,8 +60,6 @@ impl DepartmentRepository {
let department_email = payload.get("department_email").map(|v| v.as_str().unwrap_or_default());
let status = payload.get("status").and_then(|v| v.as_str());
let is_active = status.map(|s| s.to_uppercase() == "ACTIVE");
let visibility = payload.get("visibility").and_then(|v| v.as_str());
let transfers_enabled = payload.get("transfers_enabled").and_then(|v| v.as_bool());
sqlx::query_as::<_, Department>(
r#"
@ -80,11 +70,9 @@ impl DepartmentRepository {
department_head = COALESCE($5, department_head),
department_email = COALESCE($6, department_email),
is_active = COALESCE($7, is_active),
visibility = COALESCE($8, visibility),
transfers_enabled = COALESCE($9, transfers_enabled),
updated_at = NOW()
WHERE id = $1
RETURNING id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at
RETURNING id, name, code, description, department_head, department_email, is_active, created_at, updated_at
"#
)
.bind(id)
@ -94,8 +82,6 @@ impl DepartmentRepository {
.bind(department_head)
.bind(department_email)
.bind(is_active)
.bind(visibility)
.bind(transfers_enabled)
.fetch_one(pool)
.await
}
@ -110,7 +96,7 @@ impl DepartmentRepository {
pub async fn list(pool: &PgPool) -> Result<Vec<Department>, sqlx::Error> {
sqlx::query_as::<_, Department>(
"SELECT id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at FROM departments ORDER BY name ASC"
"SELECT id, name, code, description, department_head, department_email, is_active, created_at, updated_at FROM departments ORDER BY name ASC"
)
.fetch_all(pool)
.await
@ -118,7 +104,7 @@ impl DepartmentRepository {
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Department>, sqlx::Error> {
sqlx::query_as::<_, Department>(
"SELECT id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at FROM departments WHERE id = $1"
"SELECT id, name, code, description, department_head, department_email, is_active, created_at, updated_at FROM departments WHERE id = $1"
)
.bind(id)
.fetch_optional(pool)

View file

@ -15,7 +15,7 @@ pub struct Employee {
pub designation_id: Option<Uuid>,
pub role_code: String,
pub status: String,
pub joined_at: NaiveDate,
pub joining_date: NaiveDate,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@ -50,7 +50,7 @@ impl EmployeeRepository {
r#"
INSERT INTO employees (first_name, last_name, email, password_hash, department_id, designation_id, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at
RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
"#
)
.bind(payload.first_name)
@ -88,7 +88,7 @@ impl EmployeeRepository {
status = COALESCE($7, status),
updated_at = NOW()
WHERE id = $8
RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at
RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
"#
)
.bind(first_name)
@ -113,7 +113,7 @@ impl EmployeeRepository {
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<Employee>, sqlx::Error> {
sqlx::query_as::<_, Employee>(
"SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at FROM employees WHERE email = $1"
"SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1"
)
.bind(email.to_lowercase())
.fetch_optional(pool)
@ -124,7 +124,7 @@ impl EmployeeRepository {
let search = q.unwrap_or_default().to_lowercase();
sqlx::query_as::<_, Employee>(
r#"
SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at
SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at
FROM employees
WHERE ($1 = '' OR LOWER(first_name) LIKE '%' || $1 || '%' OR LOWER(last_name) LIKE '%' || $1 || '%' OR LOWER(email) LIKE '%' || $1 || '%')
ORDER BY last_name, first_name

View file

@ -7,7 +7,8 @@ use uuid::Uuid;
pub struct JobSeekerProfile {
pub id: Uuid,
pub user_id: Uuid,
pub full_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub location: Option<String>,
pub summary: Option<String>,
pub experience_years: Option<i32>,
@ -23,7 +24,8 @@ pub struct JobSeekerProfile {
#[derive(Debug, Serialize, Deserialize)]
pub struct UpsertJobSeekerProfilePayload {
pub full_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub location: Option<String>,
pub summary: Option<String>,
pub experience_years: Option<i32>,
@ -43,7 +45,7 @@ impl JobSeekerRepository {
let profile = sqlx::query_as::<_, JobSeekerProfile>(
r#"
SELECT
id, user_id, full_name, location, summary, experience_years,
id, user_id, first_name, last_name, location, summary, experience_years,
skills, resume_url, active_application_count, status, bio, custom_data,
created_at, updated_at
FROM job_seeker_profiles
@ -65,12 +67,13 @@ impl JobSeekerRepository {
let profile = sqlx::query_as::<_, JobSeekerProfile>(
r#"
INSERT INTO job_seeker_profiles (
user_id, full_name, location, summary, experience_years,
user_id, first_name, last_name, location, summary, experience_years,
skills, resume_url, bio, custom_data
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
location = EXCLUDED.location,
summary = EXCLUDED.summary,
experience_years = EXCLUDED.experience_years,
@ -80,13 +83,14 @@ impl JobSeekerRepository {
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
RETURNING
id, user_id, full_name, location, summary, experience_years,
id, user_id, first_name, last_name, location, summary, experience_years,
skills, resume_url, active_application_count, status, bio, custom_data,
created_at, updated_at
"#,
)
.bind(user_id)
.bind(payload.full_name)
.bind(payload.first_name)
.bind(payload.last_name)
.bind(payload.location)
.bind(payload.summary)
.bind(payload.experience_years.unwrap_or(0))
@ -125,7 +129,7 @@ impl JobSeekerRepository {
SET status = 'PENDING_REVIEW', updated_at = NOW()
WHERE user_id = $1
RETURNING
id, user_id, full_name, location, summary, experience_years,
id, user_id, first_name, last_name, location, summary, experience_years,
skills, resume_url, active_application_count, status, bio, custom_data,
created_at, updated_at
"#,

View file

@ -10,8 +10,8 @@ pub struct Requirement {
pub title: String,
pub description: String,
pub location: String,
pub budget: Option<i32>,
pub preferred_date: Option<chrono::NaiveDate>,
pub budget_inr: Option<i32>,
pub required_date: Option<chrono::NaiveDate>,
pub extra_data_json: Option<serde_json::Value>,
pub status: String,
pub rejection_reason: Option<String>,
@ -22,7 +22,6 @@ pub struct Requirement {
pub approved_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub created_by_user_id: Option<Uuid>,
pub required_date: Option<chrono::NaiveDate>,
pub updated_at: DateTime<Utc>,
}
@ -32,8 +31,8 @@ pub struct CreateRequirementPayload {
pub title: String,
pub description: String,
pub location: String,
pub budget: Option<i32>,
pub preferred_date: Option<chrono::NaiveDate>,
pub budget_inr: Option<i32>,
pub required_date: Option<chrono::NaiveDate>,
pub extra_data_json: Option<serde_json::Value>,
}
@ -42,8 +41,8 @@ pub struct UpdateRequirementPayload {
pub title: Option<String>,
pub description: Option<String>,
pub location: Option<String>,
pub budget: Option<i32>,
pub preferred_date: Option<chrono::NaiveDate>,
pub budget_inr: Option<i32>,
pub required_date: Option<chrono::NaiveDate>,
pub extra_data_json: Option<serde_json::Value>,
}
@ -58,7 +57,7 @@ impl RequirementRepository {
r#"
INSERT INTO leads (
profession_key, title, description, location,
budget, preferred_date, extra_data_json
budget_inr, required_date, extra_data_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
@ -68,8 +67,8 @@ impl RequirementRepository {
.bind(payload.title)
.bind(payload.description)
.bind(payload.location)
.bind(payload.budget)
.bind(payload.preferred_date)
.bind(payload.budget_inr)
.bind(payload.required_date)
.bind(payload.extra_data_json)
.fetch_one(pool)
.await?;
@ -119,8 +118,8 @@ impl RequirementRepository {
title = COALESCE($1, title),
description = COALESCE($2, description),
location = COALESCE($3, location),
budget = COALESCE($4, budget),
preferred_date = COALESCE($5, preferred_date),
budget_inr = COALESCE($4, budget_inr),
required_date = COALESCE($5, required_date),
extra_data_json = COALESCE($6, extra_data_json),
updated_at = NOW()
WHERE id = $7
@ -130,8 +129,8 @@ impl RequirementRepository {
.bind(payload.title)
.bind(payload.description)
.bind(payload.location)
.bind(payload.budget)
.bind(payload.preferred_date)
.bind(payload.budget_inr)
.bind(payload.required_date)
.bind(payload.extra_data_json)
.bind(id)
.fetch_one(pool)

View file

@ -7,7 +7,7 @@ use uuid::Uuid;
pub struct Wallet {
pub id: Uuid,
pub user_id: Uuid,
pub current_balance: i32,
pub balance: i32,
pub reserved: i32,
pub updated_at: DateTime<Utc>,
}
@ -40,7 +40,7 @@ impl TracecoinWalletRepository {
pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO tracecoin_wallets (user_id, current_balance, reserved)
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES ($1, 0, 0)
ON CONFLICT (user_id) DO NOTHING
"#,
@ -61,7 +61,7 @@ impl TracecoinWalletRepository {
sqlx::query(
r#"
INSERT INTO tracecoin_wallets (user_id, current_balance, reserved)
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES ($1, 0, 0)
ON CONFLICT (user_id) DO NOTHING
"#,
@ -77,7 +77,7 @@ impl TracecoinWalletRepository {
.fetch_one(&mut *tx)
.await?;
if wallet.current_balance < amount {
if wallet.balance < amount {
tx.rollback().await?;
return Ok(false);
}
@ -85,7 +85,7 @@ impl TracecoinWalletRepository {
sqlx::query(
r#"
UPDATE tracecoin_wallets
SET current_balance = current_balance - $1, reserved = reserved + $1, updated_at = NOW()
SET balance = balance - $1, reserved = reserved + $1, updated_at = NOW()
WHERE id = $2
"#,
)
@ -192,7 +192,7 @@ impl TracecoinWalletRepository {
sqlx::query(
r#"
UPDATE tracecoin_wallets
SET reserved = reserved - $1, current_balance = current_balance + $1, updated_at = NOW()
SET reserved = reserved - $1, balance = balance + $1, updated_at = NOW()
WHERE id = $2
"#,
)

View file

@ -10,7 +10,8 @@ pub struct User {
pub id: Uuid,
pub email: String,
pub password_hash: String,
pub name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email_verified: bool,
pub phone_verified: bool,
pub status: String, // ACTIVE, SUSPENDED, BANNED
@ -26,7 +27,8 @@ pub struct User {
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateUserPayload {
pub name: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email: String,
pub password_hash: String,
}
@ -49,17 +51,18 @@ impl UserRepository {
pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result<User, sqlx::Error> {
let user = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (name, email, password_hash, email_verified, phone_verified)
INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified)
VALUES ($1, $2, $3, false, false)
RETURNING
id, email, password_hash, name,
id, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
created_at, updated_at, deleted_at
"#,
)
.bind(&payload.name)
.bind(&payload.first_name)
.bind(&payload.last_name)
.bind(payload.email.to_lowercase())
.bind(payload.password_hash)
.fetch_one(pool)
@ -71,7 +74,7 @@ impl UserRepository {
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, name,
SELECT id, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
@ -88,7 +91,7 @@ impl UserRepository {
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, name,
SELECT id, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
@ -106,7 +109,7 @@ impl UserRepository {
pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result<Vec<String>, sqlx::Error> {
let rows = sqlx::query_scalar::<_, String>(
r#"
SELECT r.code
SELECT r.key
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = $1 AND ur.status = 'APPROVED'
@ -145,7 +148,7 @@ impl UserRepository {
pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, name,
SELECT id, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,
@ -193,7 +196,7 @@ impl UserRepository {
pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, password_hash, name,
SELECT id, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
email_verification_token, email_verification_expires_at,
reset_password_token, reset_password_expires_at,

View file

@ -23,12 +23,12 @@ pub struct Verification {
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct VerificationLog {
pub id: Uuid,
pub verification_id: Uuid,
pub verification_request_id: Uuid,
pub action: String,
pub actor_id: Option<Uuid>,
pub acted_by_user_id: Option<Uuid>,
pub old_status: Option<String>,
pub new_status: Option<String>,
pub message: Option<String>,
pub remarks: Option<String>,
pub created_at: DateTime<Utc>,
}
@ -81,9 +81,8 @@ impl VerificationRepository {
if status.is_some() { query.push_str(" AND status = $1"); }
if case_type.is_some() { query.push_str(if status.is_some() { " AND case_type = $2" } else { " AND case_type = $1" }); }
query.push_str(" ORDER BY created_at DESC LIMIT $3 OFFSET $4"); // This simplified query string concatenation is for readability, handle properly in prod.
query.push_str(" ORDER BY created_at DESC LIMIT $3 OFFSET $4");
// Actually implementing with sqlx properly:
sqlx::query_as::<_, Verification>(
r#"
SELECT * FROM verifications
@ -135,7 +134,7 @@ impl VerificationRepository {
sqlx::query(
r#"
INSERT INTO verification_logs (verification_id, action, actor_id, old_status, new_status, message)
INSERT INTO verification_logs (verification_request_id, action, acted_by_user_id, old_status, new_status, remarks)
VALUES ($1, 'STATUS_CHANGE', $2, $3, $4, $5)
"#
)

1
customers.pid Normal file
View file

@ -0,0 +1 @@
9694

View file

@ -1 +1 @@
97314
9690

1
job_seekers.pid Normal file
View file

@ -0,0 +1 @@
9693

File diff suppressed because it is too large Load diff

View file

@ -4,10 +4,26 @@
-- ── 1. Roles ─────────────────────────────────────────────────────────────────
-- Internal roles
INSERT INTO roles (key, name, audience) VALUES
('SUPER_ADMIN', 'Super Admin', 'INTERNAL'),
('ADMIN', 'Admin', 'INTERNAL'),
('SUPPORT', 'Support Agent', 'INTERNAL'),
('SUPPORT', 'Support Agent', 'INTERNAL')
ON CONFLICT (key) DO NOTHING;
-- Internal role extensions
INSERT INTO internal_roles (role_id, description)
SELECT id, 'Full system administrator with all permissions' FROM roles WHERE key = 'SUPER_ADMIN'
ON CONFLICT (role_id) DO NOTHING;
INSERT INTO internal_roles (role_id, description, can_approve_requests, can_manage_system_settings)
SELECT id, 'Standard administrator', true, true FROM roles WHERE key = 'ADMIN'
ON CONFLICT (role_id) DO NOTHING;
INSERT INTO internal_roles (role_id, description)
SELECT id, 'Customer support agent' FROM roles WHERE key = 'SUPPORT'
ON CONFLICT (role_id) DO NOTHING;
-- External roles
INSERT INTO roles (key, name, audience) VALUES
('COMPANY', 'Company', 'EXTERNAL'),
('JOB_SEEKER', 'Job Seeker', 'EXTERNAL'),
('CUSTOMER', 'Customer', 'EXTERNAL'),
@ -22,6 +38,11 @@ INSERT INTO roles (key, name, audience) VALUES
('CATERING_SERVICES', 'Catering Services', 'EXTERNAL')
ON CONFLICT (key) DO NOTHING;
-- External role extensions
INSERT INTO external_roles (role_id)
SELECT id FROM roles WHERE audience = 'EXTERNAL'
ON CONFLICT (role_id) DO NOTHING;
-- ── 2. Super Admin User ──────────────────────────────────────────────────────
-- Default password: Admin@nxtgauge1 (bcrypt hash)
-- CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN
@ -33,13 +54,14 @@ DECLARE
BEGIN
SELECT id INTO super_admin_role_id FROM roles WHERE key = 'SUPER_ADMIN';
INSERT INTO users (email, password_hash, status, role_id, full_name, email_verified)
INSERT INTO users (email, password_hash, status, role_id, first_name, last_name, email_verified)
VALUES (
'admin@nxtgauge.com',
'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TiGniB9GSmJBGp0K7RqUi/4hY/Ii',
'ACTIVE',
super_admin_role_id,
'Super Admin',
'Super',
'Admin',
true
)
ON CONFLICT (email) DO NOTHING

View file

@ -1 +1 @@
96200
9691