feat: implement user verification system and database migrations

This commit is contained in:
Ashwin Kumar 2026-04-06 03:39:41 +02:00
parent d5cfef0fa6
commit 5cd00b74bc
9 changed files with 492 additions and 626 deletions

View file

@ -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")

View file

@ -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<AppState> {
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<AppState>,
@ -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<String>,
}
/// Deprecated: Use /api/admin/verifications instead.
async fn list_pending(
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);
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<AppState>,
Path(user_id): Path<Uuid>,
) -> 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<AppState>,
Path(user_id): Path<Uuid>,
Json(payload): Json<RejectPayload>,
) -> 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<AppState>,
Path(user_id): Path<Uuid>,
) -> 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<AppState>,
Path(user_id): Path<Uuid>,
Json(payload): Json<RejectPayload>,
) -> 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<AppState>,
Path(user_id): Path<Uuid>,
) -> 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<AppState>,
Path(user_id): Path<Uuid>,
Json(payload): Json<RejectPayload>,
) -> 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<AppState>,
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<AppState>,
Path((role_key, user_id)): Path<(String, Uuid)>,
Json(payload): Json<RejectPayload>,
) -> 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(

View file

@ -17,3 +17,4 @@ pub mod roles;
pub mod support;
pub mod user_roles;
pub mod external_roles;
pub mod verifications;

View file

@ -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<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))
}
#[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>,
}
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<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) => {
// 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<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(),
}
}
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", // Or keep current status?
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(),
}
}

View file

@ -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) ────────────────────────────────

View file

@ -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;

View file

@ -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);

View file

@ -24,4 +24,5 @@ pub mod professional;
pub mod employee;
pub mod department;
pub mod designation;
pub mod verification;

View file

@ -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<String>,
pub rejection_reason: Option<String>,
pub assigned_to: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct VerificationLog {
pub id: Uuid,
pub verification_id: Uuid,
pub action: String,
pub actor_id: Option<Uuid>,
pub old_status: Option<String>,
pub new_status: Option<String>,
pub message: Option<String>,
pub created_at: DateTime<Utc>,
}
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<Verification, sqlx::Error> {
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<Option<Verification>, 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<Vec<Verification>, 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<Uuid>,
notes: Option<&str>,
rejection_reason: Option<&str>,
) -> Result<Verification, sqlx::Error> {
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)
}
}