nxtgauge-backend-rust/apps/users/src/handlers/verifications.rs
Ashwin Kumar Sivakumar 308daf4959
All checks were successful
build-and-release / build (cron) (push) Successful in 16s
build-and-release / build (social-media-managers) (push) Successful in 3s
build-and-release / build (developers) (push) Successful in 15s
build-and-release / build (ugc-content-creators) (push) Successful in 4s
build-and-release / build (tutors) (push) Successful in 6s
build-and-release / build (video-editors) (push) Successful in 5s
build-and-release / build (job-seekers) (push) Successful in 4s
build-and-release / build (customers) (push) Successful in 7s
build-and-release / build (jobs) (push) Successful in 4s
build-and-release / build (catering-services) (push) Successful in 10s
build-and-release / build (makeup-artists) (push) Successful in 4s
build-and-release / build (leads) (push) Successful in 6s
build-and-release / build (payments) (push) Successful in 4s
build-and-release / build (fitness-trainers) (push) Successful in 3s
build-and-release / build (photographers) (push) Successful in 5s
build-and-release / build (gateway) (push) Successful in 3s
build-and-release / build (users) (push) Successful in 8m30s
build-and-release / build (employees) (push) Successful in 13s
build-and-release / build (companies) (push) Successful in 16s
build-and-release / build (graphic-designers) (push) Successful in 3s
fix(verification): align document keys and dedupe role maps across handlers
extract_documents() recognized a stale set of document keys that no longer
matched what the frontend actually uploads (portfolio_ownership_proof,
professional_certifications, qualification_proof, tax_document), so every
non-COMPANY role's verification case was created with an empty documents
array. Also extracts role_key_to_display/role_to_table into a shared
role_meta module — verifications.rs and approvals.rs were each missing
UGC_CONTENT_CREATOR from their inline copies, so that role's rejections
and final approvals were silently no-ops.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 21:23:30 +05:30

407 lines
13 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::{require_admin, AuthUser};
use db::models::verification::{VerificationRepository};
use serde::Deserialize;
use uuid::Uuid;
use super::role_meta::{role_key_to_display, role_to_table};
/// Creates an entry in approval_requests after verification is approved.
/// This is the bridge between Verification Management and Approval Management.
async fn create_approval_request_from_verification(
pool: &sqlx::PgPool,
verification: &db::models::verification::Verification,
) -> Result<(), sqlx::Error> {
// Determine entity_type and entity_id from the verification payload
let payload = &verification.payload;
let entity_type = match verification.case_type.as_str() {
"JOB_APPROVAL" => "JOB",
"REQUIREMENT_APPROVAL" => "REQUIREMENT",
"PORTFOLIO_APPROVAL" => "PORTFOLIO",
_ => "PROFILE",
};
// Extract entity_id from payload (could be entity_id, job_id, requirement_id, etc.)
let entity_id = payload
.get("entity_id")
.or_else(|| payload.get("job_id"))
.or_else(|| payload.get("requirement_id"))
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.unwrap_or(verification.user_id); // Fall back to user_id if no entity_id found
let approval_type = match verification.case_type.as_str() {
"JOB_APPROVAL" => "JOB",
"REQUIREMENT_APPROVAL" => "REQUIREMENT",
"PORTFOLIO_APPROVAL" => "PORTFOLIO",
"COMPANY_APPROVAL" => "BUSINESS",
_ => "PROFILE",
};
sqlx::query(
r#"
INSERT INTO approval_requests (entity_type, entity_id, approval_type, status, submitted_by_user_id)
VALUES ($1, $2, $3, 'PENDING', $4)
ON CONFLICT (entity_type, entity_id) DO UPDATE
SET status = 'PENDING', updated_at = NOW()
"#,
)
.bind(entity_type)
.bind(entity_id)
.bind(approval_type)
.bind(verification.user_id)
.execute(pool)
.await?;
Ok(())
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_verifications))
.route("/{id}", get(get_verification))
.route("/{id}/approve", post(approve_verification))
.route("/{id}/reject", post(reject_verification))
.route("/{id}/notes", post(add_notes))
.route("/{id}/request-documents", post(request_documents))
.route("/{id}/request-revision", post(request_revision))
}
#[derive(Deserialize)]
pub struct ListQuery {
pub status: Option<String>,
pub case_type: Option<String>,
pub page: Option<i64>,
pub limit: Option<i64>,
}
async fn list_verifications(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<ListQuery>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match VerificationRepository::list(
&state.pool,
q.status.as_deref(),
q.case_type.as_deref(),
page,
limit,
)
.await
{
Ok(items) => (StatusCode::OK, Json(items)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_verification(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match VerificationRepository::get_by_id(&state.pool, id).await {
Ok(Some(v)) => (StatusCode::OK, Json(v)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Verification not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
pub struct ActionPayload {
pub notes: Option<String>,
pub reason: Option<String>,
}
async fn trigger_rejection(
state: &AppState,
user_id: Uuid,
role_key: &str,
case_type: &str,
reason: Option<&str>,
) -> Result<(), sqlx::Error> {
let role_key = role_key.to_uppercase();
let reason_str = reason.unwrap_or("Verification rejected");
if case_type == "PROFILE_VERIFICATION" {
let table = match role_to_table(&role_key) {
Some(t) => t,
None => return Ok(()),
};
let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2",
)
.bind(user_id)
.bind(&role_key)
.fetch_optional(&state.pool)
.await?
{
Some(id) => id,
None => return Ok(()),
};
let query = format!(
"UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1",
table
);
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
// Send Email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_approval_rejected_email(&user.email, &user_name, &display, reason_str).await;
}
// Send in-app notification
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(user_id)
.bind("Profile Verification Update")
.bind(format!("Your {} profile was not approved. Reason: {}", role_key_to_display(&role_key), reason_str))
.bind("VERIFICATION")
.bind(user_id)
.execute(&state.pool)
.await
.ok();
}
Ok(())
}
async fn approve_verification(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<ActionPayload>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match VerificationRepository::update_status(
&state.pool,
id,
"APPROVED",
Some(auth.user_id),
payload.notes.as_deref(),
None,
)
.await
{
Ok(v) => {
// Create an entry in approval_requests so it appears in Approval Management
// for the second-level review (final approval/rejection)
if let Err(e) = create_approval_request_from_verification(&state.pool, &v).await {
eprintln!("Failed to create approval request: {}", e);
}
// Send notification that verification passed first stage
// (Approval Management will handle final approval email)
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
// Use a "verification passed" notification instead of final approval
let _ = state.mail.send_approval_approved_email(&user.email, &user_name, &display).await;
}
// Send in-app notification - profile verified, pending final approval
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(v.user_id)
.bind("Profile Verified — Pending Final Approval")
.bind(format!("Your {} profile has been verified and is now pending final approval. You'll be notified once approved.", role_key_to_display(&v.role_key)))
.bind("VERIFICATION")
.bind(v.id)
.execute(&state.pool)
.await
.ok();
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn reject_verification(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<ActionPayload>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match VerificationRepository::update_status(
&state.pool,
id,
"REJECTED",
Some(auth.user_id),
payload.notes.as_deref(),
payload.reason.as_deref(),
)
.await
{
Ok(v) => {
let _ = trigger_rejection(&state, v.user_id, &v.role_key, &v.case_type, payload.reason.as_deref()).await;
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
/// POST /api/admin/verifications/:id/notes
/// Adds internal notes without changing status (for reviewer comments).
async fn add_notes(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<ActionPayload>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let notes = payload.notes.unwrap_or_default();
match VerificationRepository::update_status(
&state.pool,
id,
"UNDER_REVIEW",
Some(auth.user_id),
Some(&notes),
None,
)
.await
{
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(serde::Deserialize)]
pub struct RequestDocumentsPayload {
/// Human-readable message to the user describing what's needed.
pub message: String,
}
/// POST /api/admin/verifications/:id/request-documents
/// Sets status to DOCUMENTS_REQUESTED and notifies the user.
async fn request_documents(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<RequestDocumentsPayload>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match VerificationRepository::update_status(
&state.pool,
id,
"DOCUMENTS_REQUESTED",
Some(auth.user_id),
Some(&payload.message),
None,
)
.await
{
Ok(v) => {
// Notify the user via in-app notification
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(v.user_id)
.bind("Action Required — Documents Needed")
.bind(format!("Please resubmit your documents: {}", payload.message))
.bind("DOCUMENT_REQUEST")
.bind(v.id)
.execute(&state.pool)
.await
.ok();
// Send email notification
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_documents_requested_email(&user.email, &user_name, &display, &payload.message).await;
}
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
/// POST /api/admin/verifications/:id/request-revision
/// Sets status to REVISION_REQUESTED and notifies the user.
async fn request_revision(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<RequestDocumentsPayload>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
match VerificationRepository::update_status(
&state.pool,
id,
"REVISION_REQUESTED",
Some(auth.user_id),
Some(&payload.message),
None,
)
.await
{
Ok(v) => {
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(v.user_id)
.bind("Action Required — Revision Requested")
.bind(format!("Please revise your submission: {}", payload.message))
.bind("REVISION_REQUEST")
.bind(v.id)
.execute(&state.pool)
.await
.ok();
// Send email notification
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_revision_requested_email(&user.email, &user_name, &display, &payload.message).await;
}
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}