nxtgauge-backend-rust/apps/users/src/handlers/approvals.rs
Ashwin Kumar ec34423b86 feat(phase1): wire email notifications, shared email crate, AppState for services
- Create crates/email shared Mailer with 18+ templates (auth, approvals, jobs, leads, tracecoins)
- users/mail.rs now re-exports from shared crate (lettre dep removed)
- Wire password changed/reset emails in users auth handlers
- Wire profile approval/rejection emails in users approvals handlers (company, customer, all 9 professional types)
- Wire job approved/rejected emails in users approvals handlers
- Wire requirement approved email in users approvals handlers
- Add AppState (pool + mail) to companies service; wire submit_job and update_application_status emails
- Add AppState (pool + mail) to customers service; wire submit_requirement, approve_request, reject_request emails (incl. contact-exchange on lead acceptance)
- Add AppState (pool + storage) to job_seekers service with resume upload multipart handler
- Wire lead cancellation and accepted-leads handlers in contracts/profession_shared.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:42:48 +02:00

712 lines
24 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::{require_admin, AuthUser};
use db::models::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;
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/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))
.route("/requirements/{id}/reject", post(reject_requirement))
}
#[derive(Deserialize)]
pub struct RoleKeyQuery {
#[serde(rename = "roleKey", alias = "role_key")]
pub role_key: Option<String>,
}
/// 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>,
Path(user_id): Path<Uuid>,
Query(q): Query<RoleKeyQuery>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
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!({
"user": {
"id": user.id,
"name": user.full_name,
"email": user.email,
"phone": user.phone,
"status": user.status,
"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,
})),
})),
)
.into_response()
}
#[derive(Deserialize)]
pub struct ListQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Deserialize)]
pub struct RejectPayload {
pub reason: Option<String>,
}
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
)
.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, e.to_string()).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 => {
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, "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, e.to_string()).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 => {
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, "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, e.to_string()).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, "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, e.to_string()).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, "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, e.to_string()).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(role_key: &str) -> &'static 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 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, 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, e.to_string()).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 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, 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, e.to_string()).into_response(),
}
}
async fn approve_job(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let existing = match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(job)) => job,
Ok(None) => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if existing.status != "PENDING_APPROVAL" {
return (StatusCode::BAD_REQUEST, "Job is not pending approval").into_response();
}
match JobRepository::approve(&state.pool, id, auth.user_id).await {
Ok(job) => {
// 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;
}
(StatusCode::OK, Json(job)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn reject_job(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<RejectPayload>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
return e.into_response();
}
let existing = match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(job)) => job,
Ok(None) => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if existing.status != "PENDING_APPROVAL" {
return (StatusCode::BAD_REQUEST, "Job is not pending approval").into_response();
}
let reason = payload.reason.clone();
match JobRepository::reject(&state.pool, id, payload.reason).await {
Ok(job) => {
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 r = reason.as_deref().unwrap_or("Rejected by admin");
let _ = state.mail.send_job_rejected_email(&email, &name, &existing.title, r).await;
}
(StatusCode::OK, Json(job)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn approve_requirement(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
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, e.to_string()).into_response(),
};
if existing.status != "PENDING_APPROVAL" {
return (StatusCode::BAD_REQUEST, "Requirement is not pending approval").into_response();
}
match RequirementRepository::approve(&state.pool, id, auth.user_id).await {
Ok(req) => {
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, e.to_string()).into_response(),
}
}
async fn reject_requirement(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<RejectPayload>,
) -> impl IntoResponse {
if let Err(e) = require_admin(&auth) {
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, e.to_string()).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).await {
Ok(req) => (StatusCode::OK, Json(req)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}