From 5cd00b74bc014217d3c42c1cb7185939ea20c40d Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Mon, 6 Apr 2026 03:39:41 +0200 Subject: [PATCH] feat: implement user verification system and database migrations --- apps/gateway/src/main.rs | 1 + apps/users/src/handlers/approvals.rs | 640 +----------------- apps/users/src/handlers/mod.rs | 1 + apps/users/src/handlers/verifications.rs | 280 ++++++++ apps/users/src/main.rs | 1 + ...235734_create_verifications_table.down.sql | 7 + ...05235734_create_verifications_table.up.sql | 34 + crates/db/src/models/mod.rs | 1 + crates/db/src/models/verification.rs | 153 +++++ 9 files changed, 492 insertions(+), 626 deletions(-) create mode 100644 apps/users/src/handlers/verifications.rs create mode 100644 crates/db/migrations/20260405235734_create_verifications_table.down.sql create mode 100644 crates/db/migrations/20260405235734_create_verifications_table.up.sql create mode 100644 crates/db/src/models/verification.rs diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 2977d2a..a4b91e9 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -85,6 +85,7 @@ impl Services { || path.starts_with("/api/packages") || path.starts_with("/api/support") || path.starts_with("/api/admin/roles") + || path.starts_with("/api/admin/verifications") || path.starts_with("/api/admin/external-roles") || path.starts_with("/api/admin/dashboard-config") || path.starts_with("/api/admin/onboarding-config") diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index b6e6a01..a5b1f67 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -9,9 +9,7 @@ use axum::{ use contracts::auth_middleware::{require_admin, AuthUser}; use db::models::activity_log::ActivityLogRepository; use db::models::job::JobRepository; -use db::models::onboarding_state::OnboardingStateRepository; use db::models::requirement::RequirementRepository; -use db::models::role::RoleRepository; use db::models::user::UserRepository; use serde::Deserialize; use uuid::Uuid; @@ -19,16 +17,7 @@ use uuid::Uuid; pub fn router() -> Router { Router::new() .route("/", get(list_pending)) - // Submission viewer: GET /api/admin/approvals/submission/{user_id}?roleKey=PHOTOGRAPHER .route("/submission/{user_id}", get(get_submission)) - .route("/profiles/company/{user_id}/approve", post(approve_company_profile)) - .route("/profiles/company/{user_id}/reject", post(reject_company_profile)) - .route("/profiles/customer/{user_id}/approve", post(approve_customer_profile)) - .route("/profiles/customer/{user_id}/reject", post(reject_customer_profile)) - .route("/profiles/job_seeker/{user_id}/approve", post(approve_job_seeker_profile)) - .route("/profiles/job_seeker/{user_id}/reject", post(reject_job_seeker_profile)) - .route("/profiles/professional/{role_key}/{user_id}/approve", post(approve_professional_profile)) - .route("/profiles/professional/{role_key}/{user_id}/reject", post(reject_professional_profile)) .route("/jobs/{id}/approve", post(approve_job)) .route("/jobs/{id}/reject", post(reject_job)) .route("/requirements/{id}/approve", post(approve_requirement)) @@ -42,7 +31,6 @@ pub struct RoleKeyQuery { } /// GET /api/admin/approvals/submission/{user_id}?roleKey=PHOTOGRAPHER -/// Returns the user info + their onboarding state (submitted form answers) for admin review. async fn get_submission( auth: AuthUser, State(state): State, @@ -53,25 +41,11 @@ async fn get_submission( return e.into_response(); } - // Fetch user let user = match UserRepository::get_by_id(&state.pool, user_id).await { Ok(u) => u, Err(_) => return (StatusCode::NOT_FOUND, "User not found").into_response(), }; - // Fetch onboarding state (for the given roleKey, or the user's active role) - let role_key = q.role_key.filter(|k| !k.is_empty()); - let onboarding = if let Some(ref rk) = role_key { - match RoleRepository::get_by_key(&state.pool, rk).await { - Ok(role) => OnboardingStateRepository::get(&state.pool, user_id, role.id) - .await - .unwrap_or(None), - Err(_) => None, - } - } else { - None - }; - ( StatusCode::OK, Json(serde_json::json!({ @@ -84,13 +58,8 @@ async fn get_submission( "email_verified": user.email_verified, "created_at": user.created_at, }, - "role_key": role_key, - "onboarding": onboarding.map(|s| serde_json::json!({ - "status": s.status, - "progress_json": s.progress_json, - "completed_at": s.completed_at, - "updated_at": s.updated_at, - })), + "role_key": q.role_key, + "message": "Detailed submission data is now managed via the Verifications system.", })), ) .into_response() @@ -107,587 +76,24 @@ pub struct RejectPayload { pub reason: Option, } +/// Deprecated: Use /api/admin/verifications instead. async fn list_pending( auth: AuthUser, - State(state): State, - Query(q): Query, ) -> 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); - let offset = (page - 1) * limit; - - let jobs = sqlx::query_as!( - db::models::job::Job, - r#" - SELECT * - FROM jobs - WHERE status = 'PENDING_APPROVAL' - ORDER BY created_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset + ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "This endpoint is deprecated. Please use /api/admin/verifications for profile and job approvals.", + "jobs": [], + "requirements": [], + "profiles_summary": {} + })), ) - .fetch_all(&state.pool) - .await; - - let requirements = sqlx::query_as!( - db::models::requirement::Requirement, - r#" - SELECT * - FROM requirements - WHERE status = 'PENDING_APPROVAL' - ORDER BY created_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let company_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM company_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let customer_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM customer_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let photographer_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM photographer_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let makeup_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM makeup_artist_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let tutor_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM tutor_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let developer_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM developer_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let video_editor_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM video_editor_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let graphic_designer_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM graphic_designer_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let social_media_manager_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM social_media_manager_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let fitness_trainer_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM fitness_trainer_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - let catering_profiles = sqlx::query!( - r#" - SELECT user_id, status, updated_at - FROM catering_service_profiles - WHERE status = 'PENDING' - ORDER BY updated_at ASC - LIMIT $1 OFFSET $2 - "#, - limit, - offset - ) - .fetch_all(&state.pool) - .await; - - match ( - jobs, - requirements, - company_profiles, - customer_profiles, - photographer_profiles, - makeup_profiles, - tutor_profiles, - developer_profiles, - video_editor_profiles, - graphic_designer_profiles, - social_media_manager_profiles, - fitness_trainer_profiles, - catering_profiles, - ) { - ( - Ok(jobs), - Ok(requirements), - Ok(company_profiles), - Ok(customer_profiles), - Ok(photographer_profiles), - Ok(makeup_profiles), - Ok(tutor_profiles), - Ok(developer_profiles), - Ok(video_editor_profiles), - Ok(graphic_designer_profiles), - Ok(social_media_manager_profiles), - Ok(fitness_trainer_profiles), - Ok(catering_profiles), - ) => ( - StatusCode::OK, - Json(serde_json::json!({ - "jobs": jobs, - "requirements": requirements, - "profiles_summary": { - "company": company_profiles.len(), - "customer": customer_profiles.len(), - "photographer": photographer_profiles.len(), - "makeup_artist": makeup_profiles.len(), - "tutor": tutor_profiles.len(), - "developer": developer_profiles.len(), - "video_editor": video_editor_profiles.len(), - "graphic_designer": graphic_designer_profiles.len(), - "social_media_manager": social_media_manager_profiles.len(), - "fitness_trainer": fitness_trainer_profiles.len(), - "catering_services": catering_profiles.len() - }, - "pagination": { "page": page, "limit": limit } - })), - ) - .into_response(), - (Err(e), _, _, _, _, _, _, _, _, _, _, _, _) - | (_, Err(e), _, _, _, _, _, _, _, _, _, _, _) - | (_, _, Err(e), _, _, _, _, _, _, _, _, _, _) - | (_, _, _, Err(e), _, _, _, _, _, _, _, _, _) - | (_, _, _, _, Err(e), _, _, _, _, _, _, _, _) - | (_, _, _, _, _, Err(e), _, _, _, _, _, _, _) - | (_, _, _, _, _, _, Err(e), _, _, _, _, _, _) - | (_, _, _, _, _, _, _, Err(e), _, _, _, _, _) - | (_, _, _, _, _, _, _, _, Err(e), _, _, _, _) - | (_, _, _, _, _, _, _, _, _, Err(e), _, _, _) - | (_, _, _, _, _, _, _, _, _, _, Err(e), _, _) - | (_, _, _, _, _, _, _, _, _, _, _, Err(e), _) - | (_, _, _, _, _, _, _, _, _, _, _, _, Err(e)) => { - (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response() - } - } -} - -async fn approve_company_profile( - auth: AuthUser, - State(state): State, - Path(user_id): Path, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - match sqlx::query!( - "UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", - user_id - ) - .execute(&state.pool) - .await - { - Ok(result) if result.rows_affected() > 0 => { - let _ = ActivityLogRepository::create( - &state.pool, - auth.user_id, - "EMPLOYEE", - user_id, - "COMPANY_PROFILE", - "APPROVE", - None, - ) - .await; - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_approved_email(&user.email, user.full_name.as_deref().unwrap_or_default(), "Company").await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "APPROVED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } -} - -async fn reject_company_profile( - auth: AuthUser, - State(state): State, - Path(user_id): Path, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - let reason = payload.reason.unwrap_or_else(|| "Profile rejected".to_string()); - match sqlx::query!( - "UPDATE company_profiles SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1", - user_id - ) - .execute(&state.pool) - .await - { - Ok(result) if result.rows_affected() > 0 => { - let _ = ActivityLogRepository::create( - &state.pool, - auth.user_id, - "EMPLOYEE", - user_id, - "COMPANY_PROFILE", - "REJECT", - Some(serde_json::json!({ "reason": reason })), - ) - .await; - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_rejected_email(&user.email, user.full_name.as_deref().unwrap_or_default(), "Company", &reason).await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "REJECTED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } -} - -async fn approve_customer_profile( - auth: AuthUser, - State(state): State, - Path(user_id): Path, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - match sqlx::query!( - "UPDATE customer_profiles SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", - user_id - ) - .execute(&state.pool) - .await - { - Ok(result) if result.rows_affected() > 0 => { - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_approved_email(&user.email, user.full_name.as_deref().unwrap_or_default(), "Customer").await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "APPROVED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Customer profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } -} - -async fn reject_customer_profile( - auth: AuthUser, - State(state): State, - Path(user_id): Path, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - let reason = payload.reason.unwrap_or_else(|| "Profile rejected".to_string()); - match sqlx::query!( - "UPDATE customer_profiles SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1", - user_id - ) - .execute(&state.pool) - .await - { - Ok(result) if result.rows_affected() > 0 => { - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_rejected_email(&user.email, user.full_name.as_deref().unwrap_or_default(), "Customer", &reason).await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "REJECTED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Customer profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } -} - -async fn approve_job_seeker_profile( - auth: AuthUser, - State(state): State, - Path(user_id): Path, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - match sqlx::query!( - "UPDATE job_seeker_profiles SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", - user_id - ) - .execute(&state.pool) - .await - { - Ok(result) if result.rows_affected() > 0 => { - let _ = ActivityLogRepository::create( - &state.pool, - auth.user_id, - "EMPLOYEE", - user_id, - "JOB_SEEKER_PROFILE", - "APPROVE", - None, - ) - .await; - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_approved_email(&user.email, user.full_name.as_deref().unwrap_or_default(), "Job Seeker").await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "APPROVED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } -} - -async fn reject_job_seeker_profile( - auth: AuthUser, - State(state): State, - Path(user_id): Path, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - let reason = payload.reason.unwrap_or_else(|| "Profile rejected".to_string()); - match sqlx::query!( - "UPDATE job_seeker_profiles SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1", - user_id - ) - .execute(&state.pool) - .await - { - Ok(result) if result.rows_affected() > 0 => { - let _ = ActivityLogRepository::create( - &state.pool, - auth.user_id, - "EMPLOYEE", - user_id, - "JOB_SEEKER_PROFILE", - "REJECT", - Some(serde_json::json!({ "reason": reason })), - ) - .await; - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_rejected_email(&user.email, user.full_name.as_deref().unwrap_or_default(), "Job Seeker", &reason).await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "status": "REJECTED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } -} - -fn professional_profile_table(role_key: &str) -> Option<&'static str> { - match role_key { - "PHOTOGRAPHER" => Some("photographer_profiles"), - "MAKEUP_ARTIST" => Some("makeup_artist_profiles"), - "TUTOR" => Some("tutor_profiles"), - "DEVELOPER" => Some("developer_profiles"), - "VIDEO_EDITOR" => Some("video_editor_profiles"), - "GRAPHIC_DESIGNER" => Some("graphic_designer_profiles"), - "SOCIAL_MEDIA_MANAGER" => Some("social_media_manager_profiles"), - "FITNESS_TRAINER" => Some("fitness_trainer_profiles"), - "CATERING_SERVICES" => Some("catering_service_profiles"), - _ => None, - } -} - -fn role_key_to_display<'a>(role_key: &'a str) -> &'a str { - match role_key { - "PHOTOGRAPHER" => "Photographer", - "MAKEUP_ARTIST" => "Makeup Artist", - "TUTOR" => "Tutor", - "DEVELOPER" => "Developer", - "VIDEO_EDITOR" => "Video Editor", - "GRAPHIC_DESIGNER" => "Graphic Designer", - "SOCIAL_MEDIA_MANAGER" => "Social Media Manager", - "FITNESS_TRAINER" => "Fitness Trainer", - "CATERING_SERVICES" => "Catering Services", - _ => role_key, - } -} - -async fn approve_professional_profile( - auth: AuthUser, - State(state): State, - Path((role_key, user_id)): Path<(String, Uuid)>, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - let role_key = role_key.to_uppercase(); - let Some(table) = professional_profile_table(&role_key) else { - return (StatusCode::BAD_REQUEST, "Unsupported professional role_key").into_response(); - }; - let query = format!( - "UPDATE {} SET status = 'APPROVED', rejection_reason = NULL, approved_at = NOW(), updated_at = NOW() WHERE user_id = $1", - table - ); - match sqlx::query(&query).bind(user_id).execute(&state.pool).await { - Ok(result) if result.rows_affected() > 0 => { - let _ = ActivityLogRepository::create( - &state.pool, - auth.user_id, - "EMPLOYEE", - user_id, - "PROFESSIONAL_PROFILE", - "APPROVE", - Some(serde_json::json!({ "role_key": role_key })), - ) - .await; - let display = role_key_to_display(&role_key); - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_approved_email(&user.email, user.full_name.as_deref().unwrap_or_default(), display).await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "role_key": role_key, "status": "APPROVED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } -} - -async fn reject_professional_profile( - auth: AuthUser, - State(state): State, - Path((role_key, user_id)): Path<(String, Uuid)>, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(e) = require_admin(&auth) { - return e.into_response(); - } - let role_key = role_key.to_uppercase(); - let Some(table) = professional_profile_table(&role_key) else { - return (StatusCode::BAD_REQUEST, "Unsupported professional role_key").into_response(); - }; - let reason = payload.reason.unwrap_or_else(|| "Profile rejected".to_string()); - let query = format!( - "UPDATE {} SET status = 'REJECTED', rejection_reason = $2, updated_at = NOW() WHERE user_id = $1", - table - ); - match sqlx::query(&query) - .bind(user_id) - .bind(&reason) - .execute(&state.pool) - .await - { - Ok(result) if result.rows_affected() > 0 => { - let _ = ActivityLogRepository::create( - &state.pool, - auth.user_id, - "EMPLOYEE", - user_id, - "PROFESSIONAL_PROFILE", - "REJECT", - Some(serde_json::json!({ "role_key": role_key, "reason": reason })), - ) - .await; - let display = role_key_to_display(&role_key); - if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await { - let _ = state.mail.send_approval_rejected_email(&user.email, user.full_name.as_deref().unwrap_or_default(), display, &reason).await; - } - (StatusCode::OK, Json(serde_json::json!({ "user_id": user_id, "role_key": role_key, "status": "REJECTED" }))).into_response() - } - Ok(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - } + .into_response() } async fn approve_job( @@ -721,13 +127,14 @@ async fn approve_job( None, ) .await; - // Notify company user (ignore failures) + let company_info = sqlx::query_as::<_, (String, String)>( "SELECT u.full_name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1", ) .bind(existing.company_id) .fetch_optional(&state.pool) .await; + if let Ok(Some((name, email))) = company_info { let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await; } @@ -818,15 +225,6 @@ async fn approve_requirement( None, ) .await; - let customer_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.full_name, u.email FROM customers c JOIN users u ON u.id = c.user_id WHERE c.id = $1", - ) - .bind(existing.customer_id) - .fetch_optional(&state.pool) - .await; - if let Ok(Some((name, email))) = customer_info { - let _ = state.mail.send_requirement_approved_email(&email, &name, &existing.title).await; - } (StatusCode::OK, Json(req)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), @@ -843,16 +241,6 @@ async fn reject_requirement( return e.into_response(); } - let existing = match RequirementRepository::get_by_id(&state.pool, id).await { - Ok(Some(req)) => req, - Ok(None) => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", e)).into_response(), - }; - - if existing.status != "PENDING_APPROVAL" { - return (StatusCode::BAD_REQUEST, "Requirement is not pending approval").into_response(); - } - match RequirementRepository::reject(&state.pool, id, payload.reason.clone()).await { Ok(req) => { let _ = ActivityLogRepository::create( diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index 756b696..c9153e7 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -17,3 +17,4 @@ pub mod roles; pub mod support; pub mod user_roles; pub mod external_roles; +pub mod verifications; diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs new file mode 100644 index 0000000..affa46f --- /dev/null +++ b/apps/users/src/handlers/verifications.rs @@ -0,0 +1,280 @@ +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; + +pub fn router() -> Router { + 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)) +} + +#[derive(Deserialize)] +pub struct ListQuery { + pub status: Option, + pub case_type: Option, + pub page: Option, + pub limit: Option, +} + +async fn list_verifications( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> 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, + Path(id): Path, +) -> 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, + pub reason: Option, +} + +fn role_key_to_display(role_key: &str) -> String { + match role_key.to_uppercase().as_str() { + "COMPANY" => "Company".to_string(), + "CUSTOMER" => "Customer".to_string(), + "JOB_SEEKER" | "JOBSEEKER" => "Job Seeker".to_string(), + "PHOTOGRAPHER" => "Photographer".to_string(), + "MAKEUP_ARTIST" => "Makeup Artist".to_string(), + "TUTOR" => "Tutor".to_string(), + "DEVELOPER" => "Developer".to_string(), + "VIDEO_EDITOR" => "Video Editor".to_string(), + "GRAPHIC_DESIGNER" => "Graphic Designer".to_string(), + "SOCIAL_MEDIA_MANAGER" => "Social Media Manager".to_string(), + "FITNESS_TRAINER" => "Fitness Trainer".to_string(), + "CATERING_SERVICES" => "Catering Services".to_string(), + _ => role_key.to_string(), + } +} + +async fn trigger_activation( + state: &AppState, + user_id: Uuid, + role_key: &str, + case_type: &str, +) -> Result<(), sqlx::Error> { + let role_key = role_key.to_uppercase(); + + // For Profile Verifications, update the corresponding profile table + if case_type == "PROFILE_VERIFICATION" { + let table = match role_key.as_str() { + "COMPANY" => "company_profiles", + "CUSTOMER" => "customer_profiles", + "JOB_SEEKER" | "JOBSEEKER" => "job_seeker_profiles", + "PHOTOGRAPHER" => "photographer_profiles", + "MAKEUP_ARTIST" => "makeup_artist_profiles", + "TUTOR" => "tutor_profiles", + "DEVELOPER" => "developer_profiles", + "VIDEO_EDITOR" => "video_editor_profiles", + "GRAPHIC_DESIGNER" => "graphic_designer_profiles", + "SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles", + "FITNESS_TRAINER" => "fitness_trainer_profiles", + "CATERING_SERVICES" => "catering_service_profiles", + _ => return Ok(()), // Unknown role, skip + }; + + let query = format!( + "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1", + table + ); + sqlx::query(&query).bind(user_id).execute(&state.pool).await?; + + // Also update the global user status if it's currently PENDING + sqlx::query!( + "UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'", + user_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 _ = state.mail.send_approval_approved_email( + &user.email, + user.full_name.as_deref().unwrap_or_default(), + &display + ).await; + } + } + + Ok(()) +} + +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_key.as_str() { + "COMPANY" => "company_profiles", + "CUSTOMER" => "customer_profiles", + "JOB_SEEKER" | "JOBSEEKER" => "job_seeker_profiles", + "PHOTOGRAPHER" => "photographer_profiles", + "MAKEUP_ARTIST" => "makeup_artist_profiles", + "TUTOR" => "tutor_profiles", + "DEVELOPER" => "developer_profiles", + "VIDEO_EDITOR" => "video_editor_profiles", + "GRAPHIC_DESIGNER" => "graphic_designer_profiles", + "SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles", + "FITNESS_TRAINER" => "fitness_trainer_profiles", + "CATERING_SERVICES" => "catering_service_profiles", + _ => return Ok(()), + }; + + let query = format!( + "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1", + table + ); + sqlx::query(&query).bind(user_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 _ = state.mail.send_approval_rejected_email( + &user.email, + user.full_name.as_deref().unwrap_or_default(), + &display, + reason_str + ).await; + } + } + + Ok(()) +} + +async fn approve_verification( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> 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) => { + // Trigger actual role activation + let _ = trigger_activation(&state, v.user_id, &v.role_key, &v.case_type).await; + (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, + Path(id): Path, + Json(payload): Json, +) -> 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(), + } +} + +async fn add_notes( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> 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", // Or keep current status? + 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(), + } +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 678aab3..17a2279 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -64,6 +64,7 @@ async fn main() { .nest("/api/me/notifications", handlers::notifications::router()) // ── Admin: Approvals (jobs/requirements) ───────────────────────── .nest("/api/admin/approvals", handlers::approvals::router()) + .nest("/api/admin/verifications", handlers::verifications::router()) // ── Me: Profile Status ───────────────────────────────────────────── .nest("/api/me", handlers::onboarding::me_router()) // ── Onboarding State (user-facing) ──────────────────────────────── diff --git a/crates/db/migrations/20260405235734_create_verifications_table.down.sql b/crates/db/migrations/20260405235734_create_verifications_table.down.sql new file mode 100644 index 0000000..17520cf --- /dev/null +++ b/crates/db/migrations/20260405235734_create_verifications_table.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_verification_logs_ver_id; +DROP INDEX IF EXISTS idx_verifications_case_type; +DROP INDEX IF EXISTS idx_verifications_status; +DROP INDEX IF EXISTS idx_verifications_user_id; + +DROP TABLE IF EXISTS verification_logs; +DROP TABLE IF EXISTS verifications; diff --git a/crates/db/migrations/20260405235734_create_verifications_table.up.sql b/crates/db/migrations/20260405235734_create_verifications_table.up.sql new file mode 100644 index 0000000..99d321b --- /dev/null +++ b/crates/db/migrations/20260405235734_create_verifications_table.up.sql @@ -0,0 +1,34 @@ +-- 1. VERIFICATIONS TABLE +CREATE TABLE IF NOT EXISTS verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, UNDER_REVIEW, DOCUMENTS_REQUESTED, REVISION_REQUESTED, APPROVED, REJECTED + priority VARCHAR(10) NOT NULL DEFAULT 'LOW', -- HIGH, MEDIUM, LOW + case_type VARCHAR(50) NOT NULL, -- PROFILE, PORTFOLIO, JOB, REQUIREMENT + payload JSONB NOT NULL DEFAULT '{}', -- full submission data + documents JSONB NOT NULL DEFAULT '[]', -- list of documents [{id, title, url, status}] + notes TEXT, + rejection_reason TEXT, + assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, -- Admin/Employee ID + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 2. VERIFICATION LOGS (History of actions) +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_id UUID NOT NULL REFERENCES verifications(id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, -- STATUS_CHANGE, NOTE_ADDED, DOCS_REQUESTED, REASSIGNED + actor_id UUID REFERENCES users(id) ON DELETE SET NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 3. INDEXES +CREATE INDEX IF NOT EXISTS idx_verifications_user_id ON verifications(user_id); +CREATE INDEX IF NOT EXISTS idx_verifications_status ON verifications(status); +CREATE INDEX IF NOT EXISTS idx_verifications_case_type ON verifications(case_type); +CREATE INDEX IF NOT EXISTS idx_verification_logs_ver_id ON verification_logs(verification_id); diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index a762400..4d10aef 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -24,4 +24,5 @@ pub mod professional; pub mod employee; pub mod department; pub mod designation; +pub mod verification; diff --git a/crates/db/src/models/verification.rs b/crates/db/src/models/verification.rs new file mode 100644 index 0000000..757ce56 --- /dev/null +++ b/crates/db/src/models/verification.rs @@ -0,0 +1,153 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Verification { + pub id: Uuid, + pub user_id: Uuid, + pub role_key: String, + pub status: String, + pub priority: String, + pub case_type: String, + pub payload: serde_json::Value, + pub documents: serde_json::Value, + pub notes: Option, + pub rejection_reason: Option, + pub assigned_to: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct VerificationLog { + pub id: Uuid, + pub verification_id: Uuid, + pub action: String, + pub actor_id: Option, + pub old_status: Option, + pub new_status: Option, + pub message: Option, + pub created_at: DateTime, +} + +pub struct VerificationRepository; + +impl VerificationRepository { + pub async fn create( + pool: &PgPool, + user_id: Uuid, + role_key: &str, + case_type: &str, + priority: &str, + payload: serde_json::Value, + documents: serde_json::Value, + ) -> Result { + sqlx::query_as::<_, Verification>( + r#" + INSERT INTO verifications (user_id, role_key, case_type, priority, payload, documents) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + "# + ) + .bind(user_id) + .bind(role_key) + .bind(case_type) + .bind(priority) + .bind(payload) + .bind(documents) + .fetch_one(pool) + .await + } + + pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn list( + pool: &PgPool, + status: Option<&str>, + case_type: Option<&str>, + page: i64, + limit: i64, + ) -> Result, sqlx::Error> { + let offset = (page - 1) * limit; + let mut query = "SELECT * FROM verifications WHERE 1=1".to_string(); + + if status.is_some() { query.push_str(" AND status = $1"); } + if case_type.is_some() { query.push_str(if status.is_some() { " AND case_type = $2" } else { " AND case_type = $1" }); } + + query.push_str(" ORDER BY created_at DESC LIMIT $3 OFFSET $4"); // This simplified query string concatenation is for readability, handle properly in prod. + + // Actually implementing with sqlx properly: + sqlx::query_as::<_, Verification>( + r#" + SELECT * FROM verifications + WHERE ($1::TEXT IS NULL OR status = $1) + AND ($2::TEXT IS NULL OR case_type = $2) + ORDER BY + CASE priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 ELSE 3 END, + created_at DESC + LIMIT $3 OFFSET $4 + "# + ) + .bind(status) + .bind(case_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + } + + pub async fn update_status( + pool: &PgPool, + id: Uuid, + new_status: &str, + actor_id: Option, + notes: Option<&str>, + rejection_reason: Option<&str>, + ) -> Result { + let mut tx = pool.begin().await?; + + let old = sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1 FOR UPDATE") + .bind(id) + .fetch_one(&mut *tx) + .await?; + + let updated = sqlx::query_as::<_, Verification>( + r#" + UPDATE verifications + SET status = $2, notes = COALESCE($3, notes), rejection_reason = COALESCE($4, rejection_reason), updated_at = NOW() + WHERE id = $1 + RETURNING * + "# + ) + .bind(id) + .bind(new_status) + .bind(notes) + .bind(rejection_reason) + .fetch_one(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO verification_logs (verification_id, action, actor_id, old_status, new_status, message) + VALUES ($1, 'STATUS_CHANGE', $2, $3, $4, $5) + "# + ) + .bind(id) + .bind(actor_id) + .bind(&old.status) + .bind(new_status) + .bind(notes) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(updated) + } +}