feat: implement user verification system and database migrations
This commit is contained in:
parent
d5cfef0fa6
commit
5cd00b74bc
9 changed files with 492 additions and 626 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ pub mod roles;
|
|||
pub mod support;
|
||||
pub mod user_roles;
|
||||
pub mod external_roles;
|
||||
pub mod verifications;
|
||||
|
|
|
|||
280
apps/users/src/handlers/verifications.rs
Normal file
280
apps/users/src/handlers/verifications.rs
Normal 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(¬es),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -24,4 +24,5 @@ pub mod professional;
|
|||
pub mod employee;
|
||||
pub mod department;
|
||||
pub mod designation;
|
||||
pub mod verification;
|
||||
|
||||
|
|
|
|||
153
crates/db/src/models/verification.rs
Normal file
153
crates/db/src/models/verification.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue