nxtgauge-backend-rust/apps/companies/src/handlers/mod.rs

491 lines
18 KiB
Rust
Raw Normal View History

pub mod admin;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch, post},
Json, Router,
};
use serde::Deserialize;
use uuid::Uuid;
use db::models::company::{CompanyRepository, UpsertCompanyProfilePayload};
use db::models::job::{JobRepository, CreateJobPayload as DbCreateJobPayload, UpdateJobPayload as DbUpdateJobPayload};
use db::models::application::ApplicationRepository;
use db::models::user::UserRepository;
use db::models::verification::VerificationRepository;
use contracts::auth_middleware::AuthUser;
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.route("/profile/submit", post(submit_for_verification))
.route("/jobs", get(list_jobs).post(create_job))
.route("/jobs/{id}", get(get_job).patch(update_job))
.route("/jobs/{id}/submit", post(submit_job))
.route("/jobs/{id}/close", post(close_job))
.route("/jobs/{id}/applications", get(list_applications))
.route("/applications/{id}/status", patch(update_application_status))
.route("/applications/{id}/contact", get(view_contact))
}
#[derive(Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub status: Option<String>,
}
#[derive(Deserialize)]
pub struct CreateJobRequest {
pub title: String,
pub description: String,
pub location: String,
pub job_type: Option<String>,
pub salary_min: Option<i32>,
pub salary_max: Option<i32>,
pub experience_years: Option<i32>,
pub skills: Option<Vec<String>>,
pub category: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateApplicationStatusPayload {
pub status: String,
}
async fn get_profile(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_profile(
State(state): State<AppState>,
auth: AuthUser,
Json(payload): Json<UpsertCompanyProfilePayload>,
) -> impl IntoResponse {
match CompanyRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn submit_for_verification(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
Ok(None) => return (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if matches!(
company.status.as_str(),
"PENDING_REVIEW"
| "PENDING"
| "UNDER_REVIEW"
| "DOCUMENTS_REQUESTED"
| "REVISION_REQUESTED"
| "APPROVED"
) {
return (StatusCode::BAD_REQUEST, format!("Profile is already {}", company.status)).into_response();
}
match CompanyRepository::submit_for_verification(&state.pool, auth.user_id).await {
Ok(profile) => (StatusCode::OK, Json(serde_json::json!({
"status": profile.status,
"message": "Profile submitted for verification"
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn list_jobs(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match JobRepository::list_by_company_id(&state.pool, company.id, q.status, page, limit).await {
Ok(jobs) => (StatusCode::OK, Json(serde_json::json!({
"data": jobs,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_job(
State(state): State<AppState>,
auth: AuthUser,
Json(payload): Json<CreateJobRequest>,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
if company.status != "APPROVED" {
return (StatusCode::FORBIDDEN, "Company profile approval is required before posting jobs").into_response();
}
// --- New Quota Logic ---
let jobs_this_month = match JobRepository::count_by_company_id_this_month(&state.pool, company.id).await {
Ok(count) => count,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if jobs_this_month >= 1 {
// Must use a purchased slot if they've already used their monthly freebie
if company.purchased_job_slots <= 0 {
return (
StatusCode::PAYMENT_REQUIRED,
Json(serde_json::json!({
"error": "Monthly free job quota exhausted. Please purchase job slots.",
"code": "QUOTA_EXHAUSTED",
"requires_tracecoins": true
}))
).into_response();
}
// Deduct ONE purchased slot
let deduct_result = sqlx::query(
"UPDATE company_profiles SET purchased_job_slots = purchased_job_slots - 1 WHERE id = $1",
)
.bind(company.id)
.execute(&state.pool)
.await;
if let Err(e) = deduct_result {
tracing::error!("Failed to deduct job slot: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to deduct quota").into_response();
}
}
// -----------------------
let db_payload = DbCreateJobPayload {
company_id: company.id,
title: payload.title,
category: payload.category,
description: payload.description,
location: payload.location,
job_type: payload.job_type,
salary_min: payload.salary_min,
salary_max: payload.salary_max,
experience_years: payload.experience_years,
skills: payload.skills,
};
match JobRepository::create(&state.pool, db_payload).await {
Ok(job) => (StatusCode::CREATED, Json(job)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_job(
State(state): State<AppState>,
Path(id): Path<Uuid>,
_auth: AuthUser,
) -> impl IntoResponse {
match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(job)) => (StatusCode::OK, Json(job)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Job not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_job(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
Json(payload): Json<DbUpdateJobPayload>,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
if company.status != "APPROVED" {
return (StatusCode::FORBIDDEN, "Company profile approval is required before submitting jobs").into_response();
}
let job = match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(j)) if j.company_id == company.id => j,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
match JobRepository::update(&state.pool, job.id, payload).await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn submit_job(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
let job = match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(j)) if j.company_id == company.id => j,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
if job.status != "DRAFT" {
return (StatusCode::BAD_REQUEST, "Job already submitted or live").into_response();
}
match JobRepository::update_status(&state.pool, job.id, "PENDING_APPROVAL").await {
Ok(updated) => {
// Fire email to company user (ignore failures)
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await {
let _ = state.mail.send_job_submitted_email(&user.email, user.name.as_deref().unwrap_or("User"), &updated.title).await;
}
// Create verification case so the request appears in Verification Management first.
let verification_payload = serde_json::json!({
"entity_type": "JOB",
"entity_id": updated.id,
"title": updated.title,
"category": updated.category,
"location": updated.location,
"job_type": updated.job_type,
"status": updated.status,
"company_id": updated.company_id,
});
let _ = VerificationRepository::create(
&state.pool,
auth.user_id,
"COMPANY",
"JOB_APPROVAL",
"MEDIUM",
verification_payload,
serde_json::json!([]),
)
.await;
(StatusCode::OK, Json(updated)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn close_job(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
let job = match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(j)) if j.company_id == company.id => j,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
match JobRepository::update_status(&state.pool, job.id, "CLOSED").await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn list_applications(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Company not found").into_response(),
};
let job = match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(j)) if j.company_id == company.id => j,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Job not found").into_response(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match ApplicationRepository::list_by_job_id(&state.pool, job.id, q.status, page, limit).await {
Ok(apps) => (StatusCode::OK, Json(serde_json::json!({
"data": apps,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_application_status(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
Json(payload): Json<UpdateApplicationStatusPayload>,
) -> impl IntoResponse {
let app = match ApplicationRepository::get_by_id(&state.pool, id).await {
Ok(Some(a)) => a,
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
};
let job = match JobRepository::get_by_id(&state.pool, app.job_id).await {
Ok(Some(j)) => j,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(),
};
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
};
if job.company_id != company.id {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
match ApplicationRepository::update_status(&state.pool, app.id, &payload.status).await {
Ok(updated) => {
// Notify applicant of status change (ignore failures)
let applicant_info = sqlx::query_as::<_, (String, String)>(
"SELECT u.name, u.email FROM users u WHERE u.id = $1",
)
.bind(app.applicant_user_id)
.fetch_optional(&state.pool)
.await;
if let Ok(Some((name, email))) = applicant_info {
let _ = state.mail.send_application_status_email(&email, &name, &job.title, &payload.status).await;
}
(StatusCode::OK, Json(updated)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn view_contact(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let app = match ApplicationRepository::get_by_id(&state.pool, id).await {
Ok(Some(a)) => a,
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
};
let job = match JobRepository::get_by_id(&state.pool, app.job_id).await {
Ok(Some(j)) => j,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "Job lost").into_response(),
};
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
};
if job.company_id != company.id {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
let free_views = company.free_contact_views;
let purchased_views = company.purchased_contact_views;
if free_views <= 0 && purchased_views <= 0 {
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
"error": "Contact view quota exhausted",
"code": "QUOTA_EXHAUSTED",
"requires_purchase": true,
"message": "You have used all your free contact views. Please purchase a contact view package to continue."
}))).into_response();
}
let used_free = free_views > 0;
if used_free {
sqlx::query(
"UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1"
)
.bind(company.id)
.execute(&state.pool)
.await
.ok();
} else {
sqlx::query(
"UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1"
)
.bind(company.id)
.execute(&state.pool)
.await
.ok();
}
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
r#"
SELECT u.name, u.email, u.phone
FROM users u
WHERE u.id = $1
"#,
)
.bind(app.applicant_user_id)
.fetch_optional(&state.pool)
.await;
match contact {
Ok(Some((name, email, phone))) => {
let new_free = if used_free { free_views - 1 } else { free_views };
let new_purchased = if used_free { purchased_views } else { purchased_views - 1 };
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#
)
.bind(app.applicant_user_id)
.bind("Your contact was viewed")
.bind(format!("{} viewed your application for {}", company.company_name, job.title))
.bind("APPLICATION")
.bind(id)
.execute(&state.pool)
.await
.ok();
(StatusCode::OK, Json(serde_json::json!({
"application_id": id,
"name": name,
"email": email,
"phone": phone,
"quota": {
"used_free_view": used_free,
"free_remaining": new_free,
"purchased_remaining": new_purchased,
"total_remaining": new_free + new_purchased
}
}))).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "Applicant not found").into_response(),
Err(e) => {
tracing::error!("Failed to fetch applicant contact: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch contact info").into_response()
}
}
}