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) => { Ok(updated) => {
// Fire email to company user (ignore failures) // Fire email to company user (ignore failures)
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await { 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. // Create verification case so the request appears in Verification Management first.
@ -367,7 +367,7 @@ async fn update_application_status(
Ok(updated) => { Ok(updated) => {
// Notify applicant of status change (ignore failures) // Notify applicant of status change (ignore failures)
let applicant_info = sqlx::query_as::<_, (String, String)>( 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) .bind(app.applicant_user_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@ -439,7 +439,7 @@ async fn view_contact(
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>( let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
r#" 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 FROM users u
WHERE u.id = $1 WHERE u.id = $1
"#, "#,

View file

@ -28,7 +28,7 @@ pub async fn expire_stale_jobs(
WHERE jobs.company_id = c.id WHERE jobs.company_id = c.id
AND jobs.status = 'LIVE' AND jobs.status = 'LIVE'
AND jobs.expires_at < $1 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) .bind(now)

View file

@ -26,7 +26,7 @@ pub async fn expire_stale_lead_requests(
lr.tracecoins_reserved, lr.tracecoins_reserved,
urp.user_id, urp.user_id,
u.email, u.email,
u.name CONCAT(u.first_name, ' ', u.last_name) AS name
FROM lead_requests lr FROM lead_requests lr
INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id
INNER JOIN users u ON u.id = urp.user_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 WHERE leads.created_by_user_id = u.id
AND leads.status = 'OPEN' AND leads.status = 'OPEN'
AND leads.expires_at < $1 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) .bind(now)

View file

@ -11,7 +11,7 @@ pub struct AdminLeadRow {
pub description: Option<String>, pub description: Option<String>,
pub profession_key: String, pub profession_key: String,
pub location: String, pub location: String,
pub budget: Option<i32>, pub budget_inr: Option<i32>,
pub status: String, pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_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), description: Some(r.description),
profession_key: r.profession_key, profession_key: r.profession_key,
location: r.location, location: r.location,
budget: r.budget, budget_inr: r.budget_inr,
status: r.status, status: r.status,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,

View file

@ -132,8 +132,8 @@ async fn create_requirement(
title: payload.title, title: payload.title,
description: payload.description, description: payload.description,
location: payload.location, location: payload.location,
budget: payload.budget, budget_inr: payload.budget,
preferred_date: p_date, required_date: p_date,
extra_data_json: payload.extra_data_json, extra_data_json: payload.extra_data_json,
}; };
@ -190,7 +190,7 @@ async fn submit_requirement(
Ok(updated) => { Ok(updated) => {
// Fire email to customer (ignore failures) // Fire email to customer (ignore failures)
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await { 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. // Create verification case so this request enters Verification Management first.
@ -200,7 +200,7 @@ async fn submit_requirement(
"title": updated.title, "title": updated.title,
"profession_key": updated.profession_key, "profession_key": updated.profession_key,
"location": updated.location, "location": updated.location,
"budget": updated.budget, "budget_inr": updated.budget_inr,
"status": updated.status, "status": updated.status,
"created_by_user_id": updated.created_by_user_id, "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 // Send email notification to company
// Get company user details via raw query // Get company user details via raw query
let company_user = sqlx::query_as::<_, (String, Option<String>)>( 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) .bind(job.company_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await; .await;
if let Ok(Some((email, name))) = company_user { 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( let _ = state.mail.send_new_application_email(
&email, &email,
name.as_deref().unwrap_or("Company"), name.as_deref().unwrap_or("Company"),
&job.title, &job.title,
seeker_name &seeker_name
).await; ).await;
} }

View file

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

View file

@ -94,7 +94,7 @@ async fn get_submission(
Json(serde_json::json!({ Json(serde_json::json!({
"user": { "user": {
"id": user.id, "id": user.id,
"name": user.name, "name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
"email": user.email, "email": user.email,
"phone": null, "phone": null,
"status": user.status, "status": user.status,
@ -218,7 +218,7 @@ async fn activate_profile_after_final_approval(
}; };
let query = format!( 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 table
); );
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; 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 { if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key); 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 let _ = state
.mail .mail
.send_approval_approved_email( .send_approval_approved_email(&user.email, &user_name, &display)
&user.email,
user.name.as_deref().unwrap_or_default(),
&display,
)
.await; .await;
} }
@ -292,18 +289,19 @@ async fn reject_profile_after_final_approval(
}; };
let query = format!( 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 table
); );
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; 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 { if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key); 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 let _ = state
.mail .mail
.send_approval_rejected_email( .send_approval_rejected_email(
&user.email, &user.email,
user.name.as_deref().unwrap_or_default(), &user_name,
&display, &display,
reason.unwrap_or("Rejected by final approval"), reason.unwrap_or("Rejected by final approval"),
) )
@ -439,13 +437,13 @@ async fn approve_job(
) )
.await; .await;
let company_info = sqlx::query_as::<_, (String, String)>( 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) .bind(existing.company_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await; .await;
if let Ok(Some((name, email))) = company_info { if let Ok(Some((name, email))) = company_info {
let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await; let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await;
} }
@ -490,7 +488,7 @@ async fn reject_job(
.await; .await;
let company_info = sqlx::query_as::<_, (String, String)>( 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) .bind(existing.company_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)

View file

@ -40,8 +40,6 @@ pub struct RegisterPayload {
#[serde(default)] #[serde(default)]
pub last_name: Option<String>, pub last_name: Option<String>,
#[serde(default)] #[serde(default)]
pub full_name: Option<String>,
#[serde(default)]
pub name: Option<String>, pub name: Option<String>,
pub email: String, pub email: String,
pub phone: Option<String>, pub phone: Option<String>,
@ -179,7 +177,7 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid
return None; 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) .bind(&normalized)
.fetch_optional(pool) .fetch_optional(pool)
.await .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); let display_name = role_display_name_from_code(&normalized);
sqlx::query_scalar::<_, Uuid>( let role_id = sqlx::query_scalar::<_, Uuid>(
r#" r#"
INSERT INTO roles (name, code, audience, is_active) INSERT INTO roles (key, name, audience, is_active)
VALUES ($1, $2, 'USER', true) VALUES ($1, $2, 'EXTERNAL', true)
ON CONFLICT (code) ON CONFLICT (key)
DO UPDATE SET updated_at = NOW() DO UPDATE SET is_active = true
RETURNING id RETURNING id
"#, "#,
) )
.bind(&normalized)
.bind(display_name) .bind(display_name)
.bind(normalized)
.fetch_one(pool) .fetch_one(pool)
.await .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 ────────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────────
@ -264,15 +271,12 @@ async fn register(
let password_hash = hash_password(&payload.password) let password_hash = hash_password(&payload.password)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; .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) { let first_name = payload.first_name.unwrap_or_default().trim().to_string();
(Some(fn_), Some(ln_), _, _) => format!("{} {}", fn_.trim(), ln_.trim()).trim().to_string(), let last_name = payload.last_name.unwrap_or_default().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 user = UserRepository::create(&state.pool, CreateUserPayload { let user = UserRepository::create(&state.pool, CreateUserPayload {
name: full_name, first_name: Some(first_name),
last_name: Some(last_name),
email: email.clone(), email: email.clone(),
password_hash, password_hash,
}) })
@ -332,13 +336,14 @@ async fn register(
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; .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(); 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 { Ok((StatusCode::CREATED, Json(RegisterResponse {
user_id: user.id.to_string(), user_id: user.id.to_string(),
email: user.email, email: user.email,
phone: None, phone: None,
name: user.name.unwrap_or_default(), name: user_name,
status: user.status, status: user.status,
email_verified: user.email_verified, email_verified: user.email_verified,
created_at: user.created_at.to_rfc3339(), created_at: user.created_at.to_rfc3339(),
@ -400,6 +405,7 @@ async fn login(
); );
let active_role = user_roles.first().cloned(); 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!({ Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({
"access_token": tokens.access_token, "access_token": tokens.access_token,
"token_type": "Bearer", "token_type": "Bearer",
@ -407,7 +413,7 @@ async fn login(
"user": { "user": {
"id": user.id.to_string(), "id": user.id.to_string(),
"email": user.email, "email": user.email,
"full_name": user.name.unwrap_or_default(), "name": user_name,
"email_verified": user.email_verified, "email_verified": user.email_verified,
"active_role": active_role, "active_role": active_role,
"roles": user_roles, "roles": user_roles,
@ -516,10 +522,11 @@ async fn session(
.await .await
.unwrap_or_default(); .unwrap_or_default();
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
Ok(Json(SessionUser { Ok(Json(SessionUser {
id: user.id.to_string(), id: user.id.to_string(),
email: user.email, email: user.email,
name: user.name.unwrap_or_default(), name: user_name,
email_verified: user.email_verified, email_verified: user.email_verified,
active_role: user_roles.first().cloned(), active_role: user_roles.first().cloned(),
roles: user_roles, roles: user_roles,
@ -549,7 +556,8 @@ async fn verify_email(
// Get user details for welcome email // Get user details for welcome email
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { 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" })))) 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"))?; .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(); 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) Ok(silent_ok)
} }
@ -610,7 +619,8 @@ async fn forgot_password(
.await .await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; .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) Ok(silent_ok)
} }
@ -643,8 +653,9 @@ async fn reset_password(
.await .await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; .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 { 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" })))) Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
@ -677,7 +688,8 @@ async fn change_password(
.await .await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; .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" })))) 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(), "user".to_string(),
serde_json::json!({ serde_json::json!({
"id": user.id.to_string(), "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, "email": user.email,
"roles": roles, "roles": roles,
"active_role": role_key, "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>( 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.name AS requester_name CONCAT(u.first_name, ' ', u.last_name) AS requester_name
FROM leads r FROM leads r
LEFT JOIN users u ON u.id = r.created_by_user_id LEFT JOIN users u ON u.id = r.created_by_user_id
WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED') WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED')

View file

@ -20,9 +20,9 @@ pub fn router() -> Router<AppState> {
#[derive(Deserialize)] #[derive(Deserialize)]
struct ListQuery { struct ListQuery {
q: Option<String>, q: Option<String>,
status: Option<String>, // ACTIVE | INACTIVE status: Option<String>,
vertical: Option<String>, // jobs | marketplace vertical: Option<String>,
category: Option<String>, // provider | employer | consumer | specialist category: Option<String>,
page: Option<i64>, page: Option<i64>,
per_page: Option<i64>, per_page: Option<i64>,
} }
@ -71,7 +71,7 @@ async fn list_external_roles(
auth: AuthUser, auth: AuthUser,
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)> {
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()));
} }
@ -83,7 +83,6 @@ async fn list_external_roles(
let vertical = q.vertical.unwrap_or_default().to_lowercase(); let vertical = q.vertical.unwrap_or_default().to_lowercase();
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
let rows = sqlx::query_as::<_, ExternalRoleListRow>( let rows = sqlx::query_as::<_, ExternalRoleListRow>(
r#" r#"
SELECT SELECT
@ -95,8 +94,8 @@ async fn list_external_roles(
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 JOIN external_roles er ON er.role_id = r.id
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.audience = 'EXTERNAL' WHERE r.audience = 'EXTERNAL'
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))
@ -112,11 +111,11 @@ async fn list_external_roles(
.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
let total: i64 = sqlx::query_scalar::<_, i64>( let total: i64 = sqlx::query_scalar::<_, i64>(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*)
FROM roles r FROM roles r
JOIN external_roles er ON er.role_id = r.id
WHERE r.audience = 'EXTERNAL' WHERE r.audience = 'EXTERNAL'
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))
@ -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(); 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()) { if !vertical.is_empty() && vertical_v.as_deref() != Some(vertical.as_str()) {
continue; continue;
} }
if !category.is_empty() && category_v.as_deref() != Some(category.as_str()) { if !category.is_empty() && category_v.as_deref() != Some(category.as_str()) {
continue; continue;
} }
// Count assigned users from user_roles (approved)
let assigned_users: i64 = sqlx::query_scalar::<_, i64>( 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'",
) )
@ -217,14 +214,16 @@ async fn get_external_role(
auth: AuthUser, auth: AuthUser,
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)> {
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_as::<_, ExternalRoleDetailRow>( 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
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 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'
"#, "#,
@ -252,7 +251,7 @@ struct CreateExternalRolePayload {
name: String, name: String,
code: String, code: String,
is_active: Option<bool>, 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)] #[derive(sqlx::FromRow)]
@ -274,12 +273,11 @@ async fn create_external_role(
auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreateExternalRolePayload>, Json(payload): Json<CreateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
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 is_active = payload.is_active.unwrap_or(true); let is_active = payload.is_active.unwrap_or(true);
// Insert role
let role = sqlx::query_as::<_, InsertedRole>( let role = sqlx::query_as::<_, InsertedRole>(
r#" r#"
INSERT INTO roles (key, name, audience, is_active) INSERT INTO roles (key, name, audience, is_active)
@ -294,7 +292,14 @@ async fn create_external_role(
.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 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>( 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)
@ -335,11 +340,10 @@ async fn update_external_role(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateExternalRolePayload>, Json(payload): Json<UpdateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
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()));
} }
// 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#"
@ -356,7 +360,6 @@ async fn update_external_role(
.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
if let Some(runtime) = payload.runtime { if let Some(runtime) = payload.runtime {
sqlx::query( sqlx::query(
r#" r#"
@ -393,7 +396,7 @@ async fn delete_external_role(
auth: AuthUser, auth: AuthUser,
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)> {
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()));
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -137,7 +137,7 @@ async fn user_create_ticket(
}; };
let _ = state.mail.send_support_ticket_created_email( let _ = state.mail.send_support_ticket_created_email(
&user.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(), &r.id.to_string(),
&body.subject, &body.subject,
&category, &category,
@ -444,14 +444,10 @@ async fn admin_list_cases(
t.id, t.subject, t.description, t.category, t.priority, t.status, t.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to, t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at, 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 FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE ($1 = '' OR t.status = $1) WHERE t.id = $1
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#, "#,
) )
.bind(&status_filter) .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.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to, t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at, 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 FROM support_tickets t
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 ($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) .bind(id)
@ -832,7 +832,7 @@ async fn admin_add_message(
if let Some(user_email) = ticket.requester_email { if let Some(user_email) = ticket.requester_email {
// Try to get user name from user table // 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 { 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 { } else {
ticket.requester_name.unwrap_or_default() ticket.requester_name.unwrap_or_default()
}; };

View file

@ -136,21 +136,17 @@ async fn trigger_rejection(
}; };
let query = format!( 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 table
); );
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
// Send Email // Send Email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await { 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 display = role_key_to_display(&role_key);
let _ = state.mail.send_approval_rejected_email( let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
&user.email, let _ = state.mail.send_approval_rejected_email(&user.email, &user_name, &display, reason_str).await;
user.name.as_deref().unwrap_or_default(), }
&display,
reason_str
).await;
}
} }
Ok(()) Ok(())
@ -180,11 +176,8 @@ async fn approve_verification(
// Send approval email // Send approval email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await { 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 display = role_key_to_display(&v.role_key);
let _ = state.mail.send_approval_approved_email( let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
&user.email, let _ = state.mail.send_approval_approved_email(&user.email, &user_name, &display).await;
user.name.as_deref().unwrap_or_default(),
&display
).await;
} }
(StatusCode::OK, Json(v)).into_response() (StatusCode::OK, Json(v)).into_response()
} }
@ -294,12 +287,8 @@ async fn request_documents(
// Send email notification // Send email notification
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await { 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 display = role_key_to_display(&v.role_key);
let _ = state.mail.send_documents_requested_email( let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
&user.email, let _ = state.mail.send_documents_requested_email(&user.email, &user_name, &display, &payload.message).await;
user.name.as_deref().unwrap_or_default(),
&display,
&payload.message
).await;
} }
(StatusCode::OK, Json(v)).into_response() (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(), 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(); return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
} }
@ -374,7 +374,7 @@ async fn my_requests(
sqlx::query_as::<_, RichLeadReq>( sqlx::query_as::<_, RichLeadReq>(
r#" 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, 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.email ELSE NULL END as customer_email,
CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone
FROM lead_requests lr FROM lead_requests lr
@ -390,7 +390,7 @@ async fn my_requests(
sqlx::query_as::<_, RichLeadReq>( sqlx::query_as::<_, RichLeadReq>(
r#" 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, 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.email ELSE NULL END as customer_email,
CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone
FROM lead_requests lr FROM lead_requests lr
@ -567,7 +567,7 @@ async fn accepted_lead_detail(
r.location AS requirement_location, r.location AS requirement_location,
r.profession_key, r.profession_key,
r.custom_fields, 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.email AS customer_email,
u.phone AS customer_phone u.phone AS customer_phone
FROM lead_requests lr FROM lead_requests lr

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,12 +23,12 @@ pub struct Verification {
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct VerificationLog { pub struct VerificationLog {
pub id: Uuid, pub id: Uuid,
pub verification_id: Uuid, pub verification_request_id: Uuid,
pub action: String, pub action: String,
pub actor_id: Option<Uuid>, pub acted_by_user_id: Option<Uuid>,
pub old_status: Option<String>, pub old_status: Option<String>,
pub new_status: Option<String>, pub new_status: Option<String>,
pub message: Option<String>, pub remarks: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@ -81,9 +81,8 @@ impl VerificationRepository {
if status.is_some() { query.push_str(" AND status = $1"); } 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" }); } 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>( sqlx::query_as::<_, Verification>(
r#" r#"
SELECT * FROM verifications SELECT * FROM verifications
@ -135,7 +134,7 @@ impl VerificationRepository {
sqlx::query( sqlx::query(
r#" 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) 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 ───────────────────────────────────────────────────────────────── -- ── 1. Roles ─────────────────────────────────────────────────────────────────
-- Internal roles
INSERT INTO roles (key, name, audience) VALUES INSERT INTO roles (key, name, audience) VALUES
('SUPER_ADMIN', 'Super Admin', 'INTERNAL'), ('SUPER_ADMIN', 'Super Admin', 'INTERNAL'),
('ADMIN', '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'), ('COMPANY', 'Company', 'EXTERNAL'),
('JOB_SEEKER', 'Job Seeker', 'EXTERNAL'), ('JOB_SEEKER', 'Job Seeker', 'EXTERNAL'),
('CUSTOMER', 'Customer', 'EXTERNAL'), ('CUSTOMER', 'Customer', 'EXTERNAL'),
@ -22,6 +38,11 @@ INSERT INTO roles (key, name, audience) VALUES
('CATERING_SERVICES', 'Catering Services', 'EXTERNAL') ('CATERING_SERVICES', 'Catering Services', 'EXTERNAL')
ON CONFLICT (key) DO NOTHING; 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 ────────────────────────────────────────────────────── -- ── 2. Super Admin User ──────────────────────────────────────────────────────
-- Default password: Admin@nxtgauge1 (bcrypt hash) -- Default password: Admin@nxtgauge1 (bcrypt hash)
-- CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN -- CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN
@ -33,13 +54,14 @@ DECLARE
BEGIN BEGIN
SELECT id INTO super_admin_role_id FROM roles WHERE key = 'SUPER_ADMIN'; 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 ( VALUES (
'admin@nxtgauge.com', 'admin@nxtgauge.com',
'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TiGniB9GSmJBGp0K7RqUi/4hY/Ii', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TiGniB9GSmJBGp0K7RqUi/4hY/Ii',
'ACTIVE', 'ACTIVE',
super_admin_role_id, super_admin_role_id,
'Super Admin', 'Super',
'Admin',
true true
) )
ON CONFLICT (email) DO NOTHING ON CONFLICT (email) DO NOTHING

View file

@ -1 +1 @@
96200 9691