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
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>
407 lines
13 KiB
Rust
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(¬es),
|
|
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(),
|
|
}
|
|
}
|