nxtgauge-backend-rust/apps/users/src/handlers/profile.rs
Ashwin Kumar Sivakumar e4c9fc31ee
All checks were successful
build-and-release / build (developers) (push) Successful in 1m42s
build-and-release / build (companies) (push) Successful in 1m48s
build-and-release / build (catering-services) (push) Successful in 1m55s
build-and-release / build (cron) (push) Successful in 2m4s
build-and-release / build (customers) (push) Successful in 2m26s
build-and-release / build (employees) (push) Successful in 1m24s
build-and-release / build (fitness-trainers) (push) Successful in 1m37s
build-and-release / build (gateway) (push) Successful in 1m38s
build-and-release / build (graphic-designers) (push) Successful in 1m54s
build-and-release / build (jobs) (push) Successful in 1m48s
build-and-release / build (leads) (push) Successful in 1m40s
build-and-release / build (job-seekers) (push) Successful in 2m52s
build-and-release / build (photographers) (push) Successful in 1m50s
build-and-release / build (payments) (push) Successful in 2m16s
build-and-release / build (makeup-artists) (push) Successful in 2m48s
build-and-release / build (tutors) (push) Successful in 2m16s
build-and-release / build (social-media-managers) (push) Successful in 2m42s
build-and-release / build (ugc-content-creators) (push) Successful in 2m36s
build-and-release / build (video-editors) (push) Successful in 2m19s
build-and-release / build (users) (push) Successful in 5m10s
feat: human-readable reference numbers + fix verification document visibility
Adds a DB-trigger-generated reference_number (NXT-{TYPE}-{YY}-{000001}) to
verifications, support_tickets, payments, job_applications, lead_requests,
and users, replacing raw UUIDs shown to customers/admins. Also fixes
verification-status endpoint to return uploaded documents (previously
omitted, so documents never appeared after submission), and adds a
reference-number lookup endpoint for the AI support assistant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 16:34:00 +05:30

803 lines
26 KiB
Rust

use crate::AppState;
use axum::{
extract::{Multipart, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use db::models::{role::RoleRepository, user::UserRepository, verification::VerificationRepository};
use serde::Deserialize;
use uuid::Uuid;
use super::role_meta::role_to_table;
// ── Routers ───────────────────────────────────────────────────────────────────
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(get_profile).patch(save_profile))
.route("/submit-for-verification", post(submit_for_verification))
.route("/photo", post(upload_photo))
}
pub fn me_router() -> Router<AppState> {
Router::new()
.route("/", get(get_me))
.route("/verification-status", get(verification_status))
}
// ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct RoleKeyQuery {
#[serde(rename = "roleKey", alias = "role_key")]
pub role_key: Option<String>,
}
#[derive(Deserialize)]
pub struct SaveProfileInput {
#[serde(rename = "roleKey", alias = "role_key")]
pub role_key: String,
pub profile_data: serde_json::Value,
}
#[derive(Deserialize)]
pub struct SubmitInput {
#[serde(rename = "roleKey", alias = "role_key")]
pub role_key: String,
/// Optional: if provided, saves this data before submitting.
/// If omitted, reads previously saved profile data from DB.
pub profile_data: Option<serde_json::Value>,
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn is_dummy_account_email(email: &str) -> bool {
email.ends_with("@demo.com")
|| email == "paymentgateway@demo.com"
|| email.contains("+dummy@")
|| email.starts_with("dummy+")
}
fn extract_documents(profile_data: &serde_json::Value) -> serde_json::Value {
// Keep in sync with DOC_FIELDS in nxtgauge-frontend-solid/src/lib/profile-fields-config.ts —
// each entry there is the source of truth for the document key a role's upload writes.
let doc_keys = [
"aadhar_doc",
"registration_doc",
"gst_doc",
"address_proof",
"portfolio_ownership_proof",
"professional_certifications",
"qualification_proof",
"certification_doc",
"fssai_license",
"tax_document",
];
let mut docs = vec![];
for key in &doc_keys {
if let Some(val) = profile_data.get(key) {
if !val.is_null() {
docs.push(serde_json::json!({
"type": key,
"value": val,
"status": "SUBMITTED"
}));
}
}
}
serde_json::Value::Array(docs)
}
fn resolve_role_key(auth_role: &str, query_role: Option<String>) -> String {
query_role
.filter(|k| !k.is_empty())
.unwrap_or_else(|| auth_role.to_string())
.to_uppercase()
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// GET /api/profile?roleKey=PHOTOGRAPHER
async fn get_profile(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<RoleKeyQuery>,
) -> impl IntoResponse {
let role_key = resolve_role_key(&auth.claims.active_role, q.role_key);
if role_key == "COMPANY" {
let row = sqlx::query(
r#"SELECT company_name, status, updated_at FROM company_profiles WHERE user_id = $1"#,
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await;
return match row {
Ok(Some(r)) => {
use sqlx::Row;
let name: Option<String> = r.try_get("company_name").ok();
let status: String = r.try_get("status").unwrap_or_default();
(
StatusCode::OK,
Json(serde_json::json!({
"role_key": role_key,
"profile_data": { "company_name": name },
"verification_status": status,
})),
)
.into_response()
}
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({
"role_key": role_key,
"profile_data": null,
"verification_status": "NOT_STARTED",
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
}
let table = match role_to_table(&role_key) {
Some(t) => t,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": format!("Unknown role: {}", role_key) })),
)
.into_response()
}
};
let query = format!(
r#"SELECT custom_data, status FROM {} WHERE user_role_profile_id = $1"#,
table
);
let user_role_profile_id = match get_user_role_profile_id(&state.pool, auth.user_id, &role_key).await {
Ok(Some(id)) => id,
Ok(None) => {
return (
StatusCode::OK,
Json(serde_json::json!({
"role_key": role_key,
"profile_data": null,
"verification_status": "NOT_STARTED",
})),
)
.into_response();
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
match sqlx::query(&query)
.bind(user_role_profile_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(row)) => {
use sqlx::Row;
let profile_data: serde_json::Value = row
.try_get("custom_data")
.unwrap_or(serde_json::Value::Null);
let verification_status: String =
row.try_get("status").unwrap_or_default();
(
StatusCode::OK,
Json(serde_json::json!({
"role_key": role_key,
"profile_data": profile_data,
"verification_status": verification_status,
})),
)
.into_response()
}
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({
"role_key": role_key,
"profile_data": null,
"verification_status": "NOT_STARTED",
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
/// PATCH /api/profile
async fn save_profile(
auth: AuthUser,
State(state): State<AppState>,
Json(input): Json<SaveProfileInput>,
) -> impl IntoResponse {
let role_key = input.role_key.to_uppercase();
if role_key == "COMPANY" {
let name = input
.profile_data
.get("company_name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let email = input
.profile_data
.get("company_email")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let phone = input
.profile_data
.get("company_phone")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let website = input
.profile_data
.get("website")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let city = input
.profile_data
.get("location")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let state_val = input
.profile_data
.get("state")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let postal = input
.profile_data
.get("pin_code")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let address = input
.profile_data
.get("address")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let gst = input
.profile_data
.get("gst_number")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
return match sqlx::query(
r#"
INSERT INTO company_profiles (
user_id, company_name, contact_email, contact_phone, website_url,
address_line1, city, state, postal_code, gst_number, status, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'DRAFT', NOW())
ON CONFLICT (user_id) DO UPDATE SET
company_name = EXCLUDED.company_name,
contact_email = EXCLUDED.contact_email,
contact_phone = EXCLUDED.contact_phone,
website_url = EXCLUDED.website_url,
address_line1 = EXCLUDED.address_line1,
city = EXCLUDED.city,
state = EXCLUDED.state,
postal_code = EXCLUDED.postal_code,
gst_number = EXCLUDED.gst_number,
updated_at = NOW()
"#,
)
.bind(auth.user_id)
.bind(&name)
.bind(&email)
.bind(&phone)
.bind(&website)
.bind(&address)
.bind(&city)
.bind(&state_val)
.bind(&postal)
.bind(&gst)
.execute(&state.pool)
.await
{
Ok(_) => (
StatusCode::OK,
Json(serde_json::json!({ "saved": true, "role_key": role_key })),
)
.into_response(),
Err(e) => {
tracing::error!("save_profile(COMPANY) failed for user {}: {}", auth.user_id, e);
(StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response()
}
};
}
let table = match role_to_table(&role_key) {
Some(t) => t,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": format!("Unknown role: {}", role_key) })),
)
.into_response()
}
};
let query = format!(
r#"
INSERT INTO {table} (user_id, user_role_profile_id, custom_data, status, updated_at)
VALUES ($1, $2, $3, 'DRAFT', NOW())
ON CONFLICT (user_id) DO UPDATE SET
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
"#
);
let user_role_profile_id = match get_or_create_user_role_profile_id(&state.pool, auth.user_id, &role_key).await {
Ok(id) => id,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
match sqlx::query(&query)
.bind(auth.user_id)
.bind(user_role_profile_id)
.bind(&input.profile_data)
.execute(&state.pool)
.await
{
Ok(_) => (
StatusCode::OK,
Json(serde_json::json!({ "saved": true, "role_key": role_key })),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
/// POST /api/profile/submit-for-verification
async fn submit_for_verification(
auth: AuthUser,
State(state): State<AppState>,
Json(input): Json<SubmitInput>,
) -> impl IntoResponse {
let role_key = input.role_key.to_uppercase();
// Check if user is a demo account
let is_demo = sqlx::query_scalar::<_, String>("SELECT email FROM users WHERE id = $1")
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.map(|email| is_dummy_account_email(&email))
.unwrap_or(false);
// For demo accounts: auto-approve verification
if is_demo {
tracing::info!(user_id = %auth.user_id, role_key = %role_key, "Demo account auto-approved for verification");
// Update role assignment to APPROVED
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query(
"UPDATE user_role_assignments SET status = 'APPROVED' WHERE user_id = $1 AND role_id = $2",
)
.bind(auth.user_id)
.bind(role.id)
.execute(&state.pool)
.await
.ok();
}
// Mark profile as VERIFIED
set_profile_status(&state, auth.user_id, &role_key, "VERIFIED").await;
// Create a verification record with APPROVED status
let profile_data = input.profile_data.unwrap_or_else(|| {
serde_json::json!({
"company_name": "Payment Gateway Demo Company",
"company_description": "Demo account for reviewing packages",
"industry": "Technology",
"location": "India"
})
});
let documents = extract_documents(&profile_data);
match VerificationRepository::create_approved(
&state.pool,
auth.user_id,
&role_key,
"PROFILE_VERIFICATION",
profile_data,
documents,
)
.await
{
Ok(v) => (
StatusCode::CREATED,
Json(serde_json::json!({
"verification_id": v.id,
"reference_number": v.reference_number,
"status": "APPROVED",
"message": "Your profile has been auto-approved for demo access."
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
} else {
// Regular verification flow for non-demo accounts
// Guard: reject if an active verification already exists
let existing: Result<Option<Uuid>, sqlx::Error> = sqlx::query_scalar(
r#"
SELECT id FROM verifications
WHERE user_id = $1 AND role_key = $2
AND status IN ('PENDING', 'UNDER_REVIEW', 'DOCUMENTS_REQUESTED', 'REVISION_REQUESTED')
LIMIT 1
"#,
)
.bind(auth.user_id)
.bind(&role_key)
.fetch_optional(&state.pool)
.await;
if existing.unwrap_or(None).is_some() {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "A verification is already in progress for this role. Please wait for it to be reviewed."
})),
)
.into_response();
}
// Fetch saved profile data or use submitted data
let profile_data = match input.profile_data {
Some(data) => data,
None => fetch_saved_profile(&state, auth.user_id, &role_key).await,
};
let documents = extract_documents(&profile_data);
// Mark profile as PENDING in role-specific table
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;
// Mark user_role as PENDING
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query(
"UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2",
)
.bind(auth.user_id)
.bind(role.id)
.execute(&state.pool)
.await
.ok();
}
// Create verification record — appears in admin Verification Management
match VerificationRepository::create(
&state.pool,
auth.user_id,
&role_key,
"PROFILE_VERIFICATION",
"MEDIUM",
profile_data,
documents,
)
.await
{
Ok(v) => (
StatusCode::CREATED,
Json(serde_json::json!({
"verification_id": v.id,
"reference_number": v.reference_number,
"status": v.status,
"message": "Your profile has been submitted for verification. We will notify you once it has been reviewed."
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
}
/// GET /api/me/verification-status?roleKey=PHOTOGRAPHER
pub async fn verification_status(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<RoleKeyQuery>,
) -> impl IntoResponse {
let role_key = resolve_role_key(&auth.claims.active_role, q.role_key);
let row = sqlx::query(
r#"
SELECT id, reference_number, status, notes, rejection_reason, updated_at, documents
FROM verifications
WHERE user_id = $1 AND role_key = $2
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(auth.user_id)
.bind(&role_key)
.fetch_optional(&state.pool)
.await;
match row {
Ok(Some(r)) => {
use sqlx::Row;
let id: Uuid = r.try_get("id").unwrap_or(Uuid::nil());
let reference_number: String = r.try_get("reference_number").unwrap_or_default();
let status: String = r.try_get("status").unwrap_or_default();
let notes: Option<String> = r.try_get("notes").ok().flatten();
let rejection_reason: Option<String> = r.try_get("rejection_reason").ok().flatten();
let updated_at: Option<chrono::DateTime<chrono::Utc>> =
r.try_get("updated_at").ok();
let documents: serde_json::Value = r
.try_get("documents")
.unwrap_or(serde_json::Value::Array(vec![]));
(
StatusCode::OK,
Json(serde_json::json!({
"has_verification": true,
"verification_id": id,
"reference_number": reference_number,
"status": status,
"document_request": notes,
"rejection_reason": rejection_reason,
"updated_at": updated_at,
"documents": documents,
})),
)
.into_response()
}
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({
"has_verification": false,
"status": "NOT_SUBMITTED",
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
async fn fetch_saved_profile(
state: &AppState,
user_id: Uuid,
role_key: &str,
) -> serde_json::Value {
if role_key == "COMPANY" {
return match sqlx::query(r#"SELECT company_name FROM company_profiles WHERE user_id = $1"#)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(r)) => {
use sqlx::Row;
let name: Option<String> = r.try_get("company_name").ok();
serde_json::json!({ "company_name": name })
}
_ => serde_json::Value::Object(Default::default()),
};
}
if let Some(urp_id) = get_user_role_profile_id(&state.pool, user_id, role_key).await.ok().flatten() {
return fetch_saved_profile_by_urp_id(state, urp_id, role_key).await;
}
serde_json::Value::Object(Default::default())
}
async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, status: &str) {
if role_key == "COMPANY" {
sqlx::query(
r#"UPDATE company_profiles SET status = $1, updated_at = NOW() WHERE user_id = $2"#,
)
.bind(status)
.bind(user_id)
.execute(&state.pool)
.await
.ok();
return;
}
let user_role_profile_id = match get_user_role_profile_id(&state.pool, user_id, role_key).await {
Ok(Some(id)) => id,
Ok(None) => return,
Err(_) => return,
};
if let Some(table) = role_to_table(role_key) {
let q = format!(
"UPDATE {} SET status = $1, updated_at = NOW() WHERE user_role_profile_id = $2",
table
);
sqlx::query(&q)
.bind(status)
.bind(user_role_profile_id)
.execute(&state.pool)
.await
.ok();
}
}
async fn get_user_role_profile_id(
pool: &sqlx::PgPool,
user_id: Uuid,
role_key: &str,
) -> Result<Option<Uuid>, sqlx::Error> {
sqlx::query_scalar::<_, Uuid>(
r#"
SELECT id FROM user_role_profiles
WHERE user_id = $1 AND role_key = $2
"#,
)
.bind(user_id)
.bind(role_key)
.fetch_optional(pool)
.await
}
async fn get_or_create_user_role_profile_id(
pool: &sqlx::PgPool,
user_id: Uuid,
role_key: &str,
) -> Result<Uuid, sqlx::Error> {
if let Some(id) = get_user_role_profile_id(pool, user_id, role_key).await? {
return Ok(id);
}
let _role = RoleRepository::get_by_key(pool, role_key).await?;
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO user_role_profiles (user_id, role_key, status)
VALUES ($1, $2, 'DRAFT')
ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW()
RETURNING id
"#,
)
.bind(user_id)
.bind(role_key)
.fetch_one(pool)
.await
}
async fn fetch_saved_profile_by_urp_id(
state: &AppState,
user_role_profile_id: Uuid,
role_key: &str,
) -> serde_json::Value {
if let Some(table) = role_to_table(role_key) {
let q = format!(r#"SELECT custom_data FROM {} WHERE user_role_profile_id = $1"#, table);
if let Ok(Some(row)) = sqlx::query(&q)
.bind(user_role_profile_id)
.fetch_optional(&state.pool)
.await
{
use sqlx::Row;
return row
.try_get::<serde_json::Value, _>("custom_data")
.unwrap_or(serde_json::Value::Object(Default::default()));
}
}
serde_json::Value::Object(Default::default())
}
/// POST /api/profile/photo — multipart upload, updates users.avatar_url
async fn upload_photo(
auth: AuthUser,
State(state): State<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
use bytes::Bytes;
let mut file_bytes: Option<Bytes> = None;
let mut content_type = String::from("image/jpeg");
let mut extension = String::from("jpg");
while let Ok(Some(field)) = multipart.next_field().await {
let ct = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "image/jpeg".to_string());
let ext = match ct.as_str() {
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "jpg",
};
if let Ok(data) = field.bytes().await {
if !data.is_empty() {
content_type = ct;
extension = ext.to_string();
file_bytes = Some(data);
break;
}
}
}
let bytes = match file_bytes {
Some(b) => b,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "No file received" })),
)
.into_response()
}
};
let url = match state
.storage
.upload("photos", &extension, bytes, &content_type)
.await
{
Ok(u) => u,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Upload failed: {e}") })),
)
.into_response()
}
};
let res = sqlx::query(
"UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2",
)
.bind(&url)
.bind(auth.user_id)
.execute(&state.pool)
.await;
match res {
Ok(_) => (
StatusCode::OK,
Json(serde_json::json!({ "avatar_url": url })),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
/// GET /api/me — returns the authenticated user's basic info
pub async fn get_me(
auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
let avatar_url: Option<String> = sqlx::query_scalar(
"SELECT avatar_url FROM users WHERE id = $1",
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
Ok(Json(serde_json::json!({
"id": user.id,
"email": user.email,
"firstName": user.first_name,
"lastName": user.last_name,
"activeRole": auth.claims.active_role,
"emailVerified": user.email_verified,
"avatar_url": avatar_url,
})))
}